projects/start-sdk/CHANGELOG.md
Rama master · réplica de lectura del repositorio original.
# Changelog
## [4.1.0]
### Added
- Add `prepareUpload` with byte limits and file or directory destinations, File inputs with extension filters and authoritative upload metadata, `prepareDownload`, and File action results with optional cleanup. This SDK requires StartOS 0.4.2 or newer; File inputs require a prepared `uploadId`.
### Fixed
- Numeric action fields enforce their inclusive bounds and integer constraints during SDK validation, including CLI and direct API submissions.
- Fixed services hanging when a daemon captures only stdout or stderr and fills the other stream. Rebuild packages with this SDK version to receive the fix.
- A health check reporting `loading` while it starts shows its next status within seconds, and a daemon that `requires` it starts soon after it passes.
- Build packages on the first attempt after removing an image build directory or local dependency input from the manifest.
## [4.0.0]
### Added
- Added `sdk.host.getAddressGateways`, which resolves the gateways publishing the addresses a service advertises beside the gateway its outbound connections leave through, for health checks that report a mismatch.
- `make install HOST=<profile-or-URL>` selects the device for architecture detection and installation. Local development installs use `--trust` to override signer trust while retaining signature and integrity checks.
- `make sign` builds the configured targets and endorses all local `.s9pk` files with the configured identity key.
- `make universal` and `make publish` run even when a file has the target's name.
- Browser S9PK readers expose declared embedded and supplemental signing keys and metadata parsing diagnostics. Signature validation belongs to the backend, using the caller's signer pins. Shared Ed25519 public-key codecs accept canonical DER64 and PEM.
### Changed
- Replace the `waiting` health result with `waiting: true` on `starting` or `loading`. Waiting checks show a clock, and every status can display a nonempty custom message verbatim. Update health checks returning `result: 'waiting'` to use `result: 'starting', waiting: true`, and remove `waiting` entries from `statusTrigger` interval maps.
- Configure HTTP proxy authentication and forwarded headers under `bindPort`'s `proxy` option, independently of `addSsl`. Proxy authentication applies to both the original and TLS-wrapper legs.
- Inherit proxy defaults from the protocol: enabled for HTTP-family protocols, disabled otherwise. Every protocol accepts an explicit `proxy` object or `proxy: null` override.
- Move `addSsl.auth` to `proxy.auth` and `addSsl.addXForwardedHeaders` to `proxy.addXForwardedHeaders` when rebuilding packages. Keep TLS settings under `addSsl`.
- Require StartOS 0.4.1 or later for packages built with this SDK.
### Fixed
- Fixed a daemon that keeps restarting, and repeated `exec` calls sharing one `abort` controller, logging `MaxListenersExceededWarning` and growing in memory. Rebuild packages with this SDK version to receive the fix.
- Fixed daemon `ready` checks and `addHealthCheck()` checks using more memory every time they run while the service is up. Rebuild packages with this SDK version to receive the fix.
- Fixed `merge()` keeping a file model's stored date when given a new `Date`. Rebuild packages with this SDK version to receive the fix.
- Fixed `merge()` changing file-model keys it was not given, and `.const()` reads not re-running, when keys fall back to a fixed `.catch()` object. Rebuild packages with this SDK version to receive the fix.
- Preserve caller-owned options when reusing subcontainer `spawn` or `launch` options.
### Security
- Keep daemon and subcontainer command environment values out of process command lines, splitting large environments into bounded chunks.
- Preserve multiline image environment values in packages built with start-cli 2.6.0 or later.
- Require StartOS 0.4.1 or later for packages built with this SDK.
## [3.1.1]
### Fixed
- Fixed subcontainer startup from an image with no `/proc`, such as one built `FROM scratch`. Rebuild packages with this SDK version to receive the fix.
- Fixed subcontainer startup with a symlinked `/etc/resolv.conf` and preserved other files linked to an image's resolver. Rebuild packages with this SDK version to receive the fix.
## [3.1.0]
### Added
- New service packages include Forgejo Actions workflows alongside GitHub Actions, with build artifacts, releases, registry publishing, and automatic `next` branch synchronization.
- Forward Email is a provider preset in the SMTP input spec, and the presets are listed alphabetically.
- Package manifests accept local OCI image layout directories and tar archives through `source.ociLayout`, with optional reference selection.
- **`sdk.trigger.onceTrigger` runs a health check once, when it starts.** Use it with `gracePeriod: 0` for a check whose result cannot change until `main` restarts.
- **`schemas` exports zod validators for every OS type.** `schemas.Manifest.parse(...)` and friends validate values against the same shapes as the `T` types; `schemas.RPC` holds the StartOS and effects API trees, and `z.toJSONSchema(schema, { io: 'input' | 'output' })` renders JSON Schema for either wire direction. Schemas a package never imports are dropped from its bundle.
### Changed
- `make` formats `startos/` instead of rejecting unformatted source. A `Makefile` that overrode `FORMAT_CHECK` overrides `FORMAT` instead.
- **zod `4.4.3` → `4.6.7`.** `z.deepPartial` is now zod's own: it keeps each object's unknown-key mode but re-unions a discriminated union without its discriminator, as upstream documents; the `zod-deep-partial` dependency is gone and `z.deepLoose` is unchanged. `InputSpec.partialValidator` keeps its own contract (discriminators required, enum-keyed records partial). Also from upstream: `z.compile()` and `.validate()`, `.exactPartial()`, string length bounds counted in code points, and `z.toJSONSchema` merging object intersections and referencing a registered root through `$defs`.
### Fixed
- A daemon that `requires` another starts soon after that daemon becomes ready, even when its ready check reported `loading` while starting.
- Health checks reporting `waiting` poll every 30 seconds by default, with faster polling while a daemon first becomes ready.
- Health checks created with `health.HealthCheck` poll again after stop/start, including checks using `onceTrigger`, and respect the trigger's initial delay.
- `make install` builds the current variant for the target server's architecture when needed and installs that exact package.
- Keep running daemons' lazy subcontainers alive through dynamic reconciles while their filesystems are still being prepared.
- An action input field that is not required accepts an omitted key, read as `null`, so a service calling the action need not send it.
- `cooldownTrigger` and `statusTrigger` wait out an interval longer than about 24.8 days instead of polling every millisecond, and an interval of `Infinity` stops polling.
- A health check with `gracePeriod: 0` reports its first result as returned, never as `starting`.
## [3.0.6]
### Fixed
- **Manifests, notifications and `getContainerIp` type-check as written for 3.0.4.** Dockerfile images may omit `workdir`, device filters and notifications may omit their optional fields, and `getContainerIp` for the service's own container returns a string.
- **Action result values require every field**, including `launchable`; set it to `false` where a value has no link to open.
## [3.0.5]
### Changed
- **Shared types distinguish serde inputs from outputs.** `T.FooInput` describes accepted requests and constructor values; `T.Foo` describes serialized values, including required nullable fields. Action and network builders use input types. `RPC` exposes generated method, parameter and result inference. Rust documentation is available in editor hovers, and `RecoverySource<Password>` and `RegistryAsset<Commitment>` retain their caller-selected field types.
- **Effects types describe their actual wire results.** `getContainerIp` may return `null` for an unavailable dependency, `getSslCertificate` returns a variable-length string array, and `mount` resolves to `null`, not a path.
### Fixed
- Fixed a service restart during startup leaving a daemon from the previous run running alongside the new one.
## [3.0.4]
### Fixed
- **`sdk.action.run` can run actions with input from init, dependency init handlers, and main**, as well as action handlers.
- **`sdk.getServiceManifest` reads `null` for a package that is not installed**, instead of failing, and a `.const()` read re-runs once it is installed.
## 3.0.3 — StartOS 0.4.0.2
### Added
- **`setupPrimaryUrl` takes `filter`, `ssl` and `fallback`.** `filter` and
`ssl` narrow the addresses offered. `fallback: false` starts the form and
the task with nothing selected, and makes `bestUsable` `null` while nothing
is stored or the stored hostname is gone
### Changed
- **`setupPrimaryUrl` prefers a public domain.** With nothing stored, or the
stored hostname gone, the form, the task and `bestUsable` use a public
domain, HTTPS first, ahead of the `.local` address
- **`setupPrimaryUrl`'s task is raised even when the interface offers no
address**, and stays active until one is offered and chosen
### Fixed
- **A file model built on an `InputSpec`'s `partialValidator` keeps keys the
spec does not declare**, at every level the spec builds with
`z.looseObject`, and a `z.discriminatedUnion` in a hidden field keeps the
variant its discriminator names. Drop a `z.deepLoose()` wrapped around it
- **`setupPrimaryUrl`'s task exists only while the URL is unresolved**, so
updating a running service to a release that adds a critical one leaves it
running
- **A YAML file model keeps custom tags that parse to class instances**, such
as `!include` and `!secret`, when it writes
- **`setupPrimaryUrl` treats an empty stored URL as unset**
- **`i18n()` inserts a parameter value verbatim, at every occurrence.** A
value containing `$&`, `` $` `` or `$'` was rewritten, and a placeholder
used twice was filled only once
## 3.0.2 — StartOS 0.4.0.2
### Fixed
- **A flavor switch records the exact version installed as the service's
data version**, so the receiving package's later migrations run. A range
recorded by an earlier switch is replaced by the current version at the
next initialization; the migrations it skipped are not replayed, so a
package that needs one of them on such an install has to do that work
outside a migration
## 3.0.1 — StartOS 0.4.0.2
### Changed
- **Breaking — file models parse with their shape exactly as written.** A
`z.object` shape deletes every key it doesn't declare on the next `merge()`,
`write()` or `update()`. Build every file model with `z.looseObject` at every
nesting level; use `z.object` only for a file you have fully modeled and will
always keep fully modeled. A `z.discriminatedUnion` keeps the variant its
discriminator names
## 3.0.0 — StartOS 0.4.0.2
### Security
- **Update SDK build and test dependencies with security fixes.**
### Changed
- **Breaking — read-only volume and asset mounts are enforced.** Writes through a volume mount declared `readonly: true`, or through any asset mount, fail with `EROFS`. Mount volumes writable wherever the service writes to them. Copy assets that need modification into a writable volume
- **Breaking — `Watchable<A>` takes only the type it reads.** A reader that
maps a raw value extends `MappedWatchable<Raw, Mapped>` and implements
`fetchRaw`/`produceRaw` in place of `fetch`/`produce`. A type written
`Watchable<Raw, Mapped>` becomes `Watchable<Mapped>`
- **Breaking — define dependencies once in `dependencies.ts`.** Create each base with `sdk.Dependency.required` or `.optional` (including metadata, version range, kind and health checks), add it to `sdk.Dependencies.of()`, and pass the builder to `buildManifest(versionGraph, sdkManifest, dependencies)` and `setupInit`. Move runtime conditions to `enabled` and `withDynamicNarrowing`, tasks to `withInit`, and use `dependencies.check(effects)` in place of `sdk.checkDependencies(effects)`. The base version range, kind, and health checks are also included in the package manifest and registry metadata, allowing StartOS to record required dependencies independently of init effects and enforce the published base for enabled optional dependencies. `enabled`, the narrowing, and each `.withInit` handler rerun independently when a watched value changes; the requirements are republished only when they change, and init handlers run only while the dependency is enabled. StartOS hides the tasks a service created on a dependency while that dependency is disabled.
- **Breaking — `sdk.action.run` opens the action's form and passes it to
`input`.** `input` is a function from the opened form to the input to submit;
a plain value is no longer accepted. The run then answers that form, which is
what lets a service run an action that takes input — another service's that
`access` admits, via the new `packageId`, or its own. `prefill` seeds the
form. Underneath, `effects.action.getInput` accepts `prefill`, and the form
and the run that answers it share the calling procedure's event id
- **Breaking — a filled address lists the server's `.local` name whenever the
user has it enabled, and `utils.mdnsResolvable` is removed.** `.local` was
left out while no LAN IP on its gateways was enabled, which dropped it
whenever the network did and made a stored URL compare as removed. The
Interfaces tab and a filled address now agree on every address.
- **Minimum StartOS version is now `0.4.0.2`**, which is what a package built
with this SDK writes as its manifest `osVersion`
- **Breaking — image architecture fallback is now `emulateMissing: boolean`.**
Remove `emulateMissingAs`; StartOS selects the image available in the s9pk,
so a cross-architecture restore runs the backed-up image under emulation. The
new field defaults to `true`; set it to
`false` when an image cannot run under emulation. Existing s9pks retain their
fallback behavior when read
- **Breaking — `z.object` strips unknown keys.** Every file-model shape must use
`z.looseObject`, at every nesting level, or the next `merge()` discards
whatever the user's config file holds that the shape doesn't declare. Replace
`z.object(` with `z.looseObject(` throughout `startos/`, keeping `z.object`
only where unknown keys should be dropped. A `.loose()` or `.passthrough()`
already on a shape is now redundant and can go. Every other `z` export,
`z.deepLoose` and `z.deepPartial` included, is unchanged
- **Breaking — the SDK supplies the package toolchain.** TypeScript, Prettier,
ESLint and `@vercel/ncc` install with `@start9labs/start-sdk`, so a package
declares the SDK and nothing else. Drop `typescript`, `@types/node`,
`@vercel/ncc` and `prettier` from `devDependencies`, drop the `build`,
`check` and `prettier` scripts and the `prettier` config block, and add a
`.prettierrc` containing `"@start9labs/start-sdk/prettier.config.json"` so
your editor formats the way the build gate checks. `s9pk.mk` runs each step
itself through `TS_CHECK`, `FORMAT_CHECK` and `JS_BUNDLE`, any of which a
`Makefile` can override above the include
- **The build gate rejects unformatted `startos/`.** `make format` writes. The
shared config is the four settings every package already declared, so nothing
that was formatted needs reformatting
- **`@start9labs/start-core` is the only bundled dependency.** Everything else
installs normally, so a package can clear a security advisory anywhere in the
SDK's dependency tree with its own `overrides` entry rather than waiting for
an SDK release
- **Breaking — `mountDependency` no longer accepts `type`.** A dependency mount
has been a directory since StartOS 0.4.0-alpha.16 disabled file mounts on
dependencies, so the option was silently doing nothing; passing it is now a
compile error. To reach a single file, mount the directory holding it.
`mountVolume` and `mountAssets` still take `type`
- **Breaking — MySQL and MariaDB use separate dump builders.** MySQL callers
keep `Backups.withMysqlDump`; `engine: 'mysql'` remains accepted but is
optional. MariaDB callers use `Backups.withMariadbDump`, remove
`engine: 'mariadb'`, and rename `mysqldOptions` to `mariadbdOptions`
- **`addSsl.alpn` is written as the list of protocols itself: `['h2']`, or
`null` for no filter.** It used to be `{ specified: ['h2'] }` or the string
`'reflect'`, and setting either changed how StartOS dialled the container —
so `'reflect'` and leaving the option unset said the same thing once that
stopped being true. Both older forms are gone. A binding that wrote
`{ specified: [...] }` writes the list, and one that wrote `'reflect'` writes
`null`; bindings already stored on a server are converted on update.
- **`effects.getServicePortForward` resolves `null` instead of throwing when
the binding does not exist.** It is the one host effect with no `callback`,
so a caller cannot react to a change — and throwing was the worst available
answer for exactly that caller. It also could not tell "no such binding" from
"the host itself is gone", and a binding merely disabled by `clearBindings`
still reported its stale ports, which retiring now makes an observable
difference. Prefer `sdk.host.getBridgeAddress` to reach a dependency; this is
raw allocator metadata
- **The scaffolded `build.yml` no longer passes `DEV_KEY`** — a PR build only
compiles and packs, so it never needed the signing key. Existing packages
should drop the `secrets:` block from their own `build.yml`
- **Breaking — `SubContainer.exec` / `execFail` take `timeout` and `abort` as
named options** rather than as third and fourth positional arguments:
`sub.execFail(cmd, { user: 'root' }, null)` becomes
`sub.execFail(cmd, { user: 'root', timeout: null })`. Passing either
positionally is now a compile error
- **StartOS 0.4.0.2 collects the processes a daemon orphans**, so a daemon that
shells out to helper programs no longer needs its command wrapped in
`tini -s`. Set `runAsInit: true` to opt out and take PID 1 yourself, which
suits images built around `s6-overlay`, `tini`, `dumb-init` or `supervisord`
- `assets/` and `startos/fileModels/` are scaffolded with a `.gitkeep` rather
than a `README.md`
### Added
- **`waitFor` takes an optional `AbortSignal`**, as `watch` does, and rejects
with `AbortedError` when it aborts, including while waiting for a file or
its parent directory to be created. Pass one to cancel a wait you race against
a timeout. `watch` and `waitFor` end at once on a signal that has already
aborted.
- **`FileHelper.update(effects, change)`** computes a complete replacement under the writer lock. The callback receives the validated current value and returns the replacement or `null` to skip writing. Reads inside it remain reentrant; nested writes, merges, or updates to the same file throw immediately. The callback has a five-second deadline, which also bounds file access it starts; a timed-out callback cannot commit later.
- **`preDownloadAlert` in `setupManifest()`** displays a localized Markdown confirmation before downloading an update from an installed version matching `when.sourceVersion`.
- **An `env` variable set to `undefined` is removed from the process**,
including one the image or StartOS would otherwise supply, such as `LANG`.
- **An action learns who is running it.** The `run` handler, the prefill
function and a function-valued input spec each receive `caller`: the id of
the service that reached the action through `effects.action`, or `null` when
the user did. An action with `access: 'dependent'` or `'public'` can act on
the caller's own resources instead of trusting a package id in its input
- **`utils.isAddressEnabled(addresses, hostname)`** reports whether the user's
overrides leave one of a binding's addresses on
- **Scaffolded packages get a fourth workflow, `syncNext.yml`**, which keeps the
`next` iteration branch in step with the base branch it stacks on. A repo with
no `next` gets one created at the base tip on the first run
- **`addDaemon()` / `addOneshot()` accept a `uses` value** that
`Daemons.dynamic` folds into the entry's `configHash`, so a value a closure
captures can restart the daemon on the next reconcile. Only JSON-serializable
values are useful
- **`MultiHost.retire()` and `MultiHost.retirePort()` permanently remove a host
or a binding**, returning its external ports to the server's pool — where
`setupInterfaces` otherwise only _disables_ what it did not declare. Call it
from the `up()` of the version that stops binding; re-running is safe. See
[Retiring a Host or Binding](https://docs.start9.com/packaging/interfaces.html#retiring-a-host-or-binding)
- **`sdk.getRootCa(effects)` returns this server's root CA certificate**, for a
service that has to dial a user-supplied LAN address over HTTPS. See
[Trusting this server's certificates](https://docs.start9.com/packaging/service-to-service.html#trusting-this-servers-certificates)
- **`hardwareVirtualization: true` in the manifest exposes `/dev/kvm`**, for a
service that runs its own virtual machines — QEMU/KVM, Firecracker, a device
emulator. The device appears only on a host whose CPU supports virtualization,
so handle its absence. See
[Hardware Virtualization (KVM)](https://docs.start9.com/packaging/manifest.html#hardware-virtualization-kvm)
- **`Watchable.combine(effects, [a, b], map?, eq?)` builds one reader from
several.** Its raw value is the tuple of the sources' values, and `map`/`eq`
work as on any reader: it emits when `map`'s result differs from the last by
`eq`. `Watchable.from(effects, source, eq?)` makes a reader of a single
source. A source is any `WatchSource` (`once()` and `watch(abort)`), which
every `Watchable` is
- **`sdk.setupPrimaryUrl()` replaces the hand-rolled "Set Primary URL" action
and watcher.** Give it the interface the URL belongs to, a reader for the
stored choice (`storeJson.read(s => s.primaryUrl)`) and a function that
writes it. It returns the action to register;
`bestUsable(effects)`, a reader for the stored URL while its hostname is one
of the interface's addresses and the `.local` address otherwise; and
`setupTask(severity, options)`, an init script that keeps a task raised while
the stored URL is unset or gone, which StartOS clears once it is back. See
[Set a Primary URL](https://docs.start9.com/packaging/recipe-primary-url.html)
- **`createInterface` accepts `preferredLauncherAddress`.** A UI interface can
nominate the absolute URL that StartOS should open when a service depends on
one canonical origin. See
[Nominating an Address to Open](https://docs.start9.com/packaging/interfaces.html#nominating-an-address-to-open).
- **`Value.select`, `Value.dynamicSelect`, `Value.union` and `Value.dynamicUnion`
accept `default: null`**, which renders the field unselected and holds the form
unsubmittable until the user picks one
- **`launchable` on a `single` action result opens the value in a new tab**, for
a result that hands the user a link — an authorization URL, an admin panel.
The value must be an `http(s)` URL. `copyable`, `qr` and `masked` are optional
alongside it and default to `false`. See
[Single Value](https://docs.start9.com/packaging/actions.html#single-value)
- **An action result can be a `multiline` value**: a read-only monospace box
that keeps its line breaks, taking the same optional `copyable` / `qr` /
`masked` flags as `single`, plus an optional `filename` that offers it as a
download. A `single` value is one line, and a newline in one is not rendered.
See [Result Types](https://docs.start9.com/packaging/actions.html#result-types)
### Fixed
- **An awaited `waitFor` waits until its predicate holds.** Awaiting
`waitFor` on a status, file or other reader no longer fails with
`AbortedError` after garbage collection while the condition is still false.
- **A file model's reads see every change to the file.** `watch`, `const` and
`waitFor` no longer miss a write made while the previous value was being
read or handled, or a file created just as the wait began.
- **FileHelper writes replace files atomically.** Writers hold a cross-process lock on the file, waiting up to ten seconds for it, and `merge()` and `update()` hold it through their complete read-modify-write; replacements retain the file's owner and permissions.
- **Own-volume file mounts follow atomic source replacement in running subcontainers.** Refreshes preserve idmaps and readonly settings and run before FileHelper operations return and commands launch. Refresh failures propagate to the caller, including after a write has replaced the source. Existing descriptors retain the previous inode until the application reopens the file.
- **Reactive init re-runs receive `kind: null`** after the initial install,
update, or restore pass. Lifecycle-only work guarded by `kind` runs once for
that event, even when a watched value changes.
- **Lazy subcontainers retry filesystem materialization after a transient
failure**, allowing daemons to recover without a service restart
- **`Backups.withMariadbDump` works against MariaDB 11 images**, official or
packaged from a distribution
- `import { backup } from '@start9labs/start-sdk'` exposes `backup.Backups`
and `backup.mountBackupTarget`
- **Scaffolded package CI builds a draft PR when it becomes ready and rebuilds
against every new base after retargeting.** Metadata edits preserve active
builds and their conclusions
- **`merge()` given a value of `undefined` removes the key from an `.env` file
model**, the way it already did for every other format. It wrote the literal
`KEY=undefined`, which a shape's `.catch()` then masked on read — so the file
the service actually parses held the word while the model reported the default
- **`VersionGraph` reports a missing migration path in terms a service owner can
act on**, rather than as an assertion about the version range the host handed
it
- `i18n()` no longer throws when a number or a `Date` is interpolated: a service
container's `LANG=C.UTF-8` reduces to a locale `Intl` rejects
- `SubContainer.exec` closes the child's stdin when it has no input to write,
instead of leaving a command that reads stdin blocked until the timeout
- **A text field's `patterns` are enforced on every path into an action**, not
only by the web form. A pattern is anchored unless it already is, and an empty
value passes, left to `required`. Covers `Value.text`, `Value.textarea`,
`List.text` and their dynamic variants
- **A build whose `start-cli s9pk list-ingredients` fails now stops**, rather
than silently repacking the previous s9pk and reporting success
- **`hardwareRequirements.ram` is documented in bytes**, not the megabytes its
TSDoc claimed — a package following the old `ram: 8192` example declared an
8 KiB floor that gated nothing. The example now writes `8 * 1024 ** 3`, and
the manifest page gained a Minimum RAM section
- **A `runUntilSuccess` timeout names each un-ready daemon** with its health
result, the count and cause of any abnormal exits, and the budget that
elapsed, rather than a bare list of ids
- **Package template cleanup.** Dropped the `alerts` manifest block, removed in
2.0.0, and the `hello-world` guard job from `release.yml` / `tagAndRelease.yml`
- `make install` no longer announces "Initializing StartOS developer
environment…" on every run
- **A database dump backup or restore is no longer killed after exactly thirty
seconds.** Every step of `Backups.withPgDump`, `withMysqlDump` and
`withMariadbDump` whose duration
follows the size of the data now opts out of `SubContainer.exec`'s 30 s cap,
and `PgDumpConfig.readyTimeout` supplies `pg_ctl`'s `-t` so that raising it
reaches the step that actually blocks. Fixes
[#3636](https://github.com/Start9Labs/start-technologies/issues/3636)
- **A command killed by `SubContainer.exec`'s own timeout now says it timed
out**, where the bare signal had read like an OOM kill. `exec`'s result
carries `timedOutAfter` alongside `exitCode` and `exitSignal`
- **`checkDependencies(...)`'s boolean version check honours the dependency's
`satisfies` list**, matching `throwIfNotSatisfied()` and the web UI.
`satisfied()` and `installedVersionSatisfied()` compared only the installed
version against the declared range, ignoring the versions that release stands
in for. This matters most for a flavor, which is incomparable to an unflavored
version: a `#knots` Bitcoin or a `#quantum` File Browser read as unsatisfied
while the throwing surface passed
- **A dependency release is matched against `versionRange` as one set of declared versions.**
One installed or aliased version must satisfy a complete conjunction. `!=`
and negated ranges exclude the release when a declared version satisfies the
complete excluded range. `VersionRange.satisfiedByRelease` is the evaluator
behind the SDK, dependency warnings and marketplace, and `normalize()`
preserves the same answer. Numeric
prerelease identifiers retain exact ordering and serialization beyond
JavaScript's safe-integer limit
- **`checkDependencies(...)`'s `satisfied()` takes an optional package id, and
`healthCheckSatisfied()`'s is optional.** Both were declared narrower than the
functions behind them, so `deps.satisfied('bitcoind')` was a compile error for
a call that has always worked, and the only way to check one dependency was to
reimplement the predicate
- **A prerelease segment may mix letters, digits and hyphens**, matching the
grammar StartOS parses. `1.0.0-rc1:0` and `1.0.0-alpha-1:0` threw a parse error
out of `ExtendedVersion.parse` where the OS accepted them, so a dependency
published on such a version crashed a dependent's `checkDependencies`. A
numeric segment with a leading zero is rejected, as it already was on the OS
side
- **Backup and restore progress no longer falls back mid-sync**
- **`checkPortListening` counts a TCP port as listening only while a socket is
in the `LISTEN` state.** It matched any socket on the port, so the
connections a process leaves in `TIME_WAIT` when it exits kept its port
reading as listening for up to a minute: a daemon's `ready` check passed, and
the health checks that require it ran, while nothing was listening
### Security
- **ESLint and typescript-eslint carry patched `brace-expansion` and
`js-yaml`.** A package scaffolded from the template now reports
`found 0 vulnerabilities` from `npm audit --omit=dev`. Fixes
[#3592](https://github.com/Start9Labs/start-technologies/issues/3592)
## 2.0.9 — StartOS 0.4.0-beta.10 (2026-07-25)
### Fixed
- **`sdk.host.getBridgeAddress` now types its result as non-null when
`fallbackPort` is given.** `fallbackPort` exists precisely so the value is
never `null` — it resolves to `<osIp>:<fallbackPort>` while the dependency is
absent — but 2.0.8 typed every call `string | null` regardless, so the one
case the option exists for still forced callers to handle a `null` that cannot
occur. Assigning it straight to a non-nullable config field (Bitcoin's
`proxy`, LND's `tor.socks`) failed to compile. `getBridgeAddress` is now
overloaded on the presence of `fallbackPort`, matching the per-package helper
it replaced, which carried the same overload
## 2.0.8 — StartOS 0.4.0-beta.10 (2026-07-25)
### Added
- **`sdk.host.getBridgeAddress` resolves the address another container reaches a
dependency at, replacing a helper every package was copy-pasting.** Packages
resolved a dependency by reading `bindings[<internalPort>].net.assignedPort`
and prefixing `getOsIp`. That field is raw metadata: only one of
`assignedPort` / `assignedSslPort` is ever populated, and which one is a
property of how the _dependency_ bound the port — a binding with `addSsl` and
`secure.ssl` frees `assignedPort` entirely and carries only
`assignedSslPort`, a passthrough binding is the reverse. So every caller was
implicitly asserting how its dependency terminates TLS, and resolved `null`
the day that changed, which is exactly what happened to LND's dependents when
LND moved its REST interface behind the OS reverse proxy. The new helper
resolves the binding's own derived address instead, which is correct under
either arrangement, and returns a `Watchable` so callers get the same
`const`/`once`/`watch`/`onChange`/`waitFor` strategies as `sdk.host.get`.
Because it keys off the binding rather than an exported interface, it also
resolves bridge-only ports such as tor's SOCKS proxy. `ssl` narrows a binding
that publishes both a plaintext and a TLS address (`protocol: 'http'`/`'ws'`,
or `secure: null` with `addSsl` — bitcoind's RPC is reachable at both);
`fallbackPort` keeps the value non-null while the dependency is absent, for a
flag that must be passed unconditionally against an allocator-guaranteed port
## 2.0.7 — StartOS 0.4.0-beta.10 (2026-07-23)
### Changed
- **Backup and restore progress now weights the rsync transfer far above the
pre/post hooks, so the bar tracks the copy.** Every progress phase — the
optional pre/post hooks and each rsync sync — previously carried equal weight,
so on a typical single-volume package the instant pre-backup hook alone
accounted for a third of the bar while the rsync (the part that actually takes
time) was squeezed into a band too narrow for its fractional progress to move
the rounded total: it sat at 33% for the whole transfer and then jumped. Each
rsync sync phase now defaults to weight `80` (`DEFAULT_SYNC_WEIGHT`) and each
pre/post hook phase to `10` (`DEFAULT_HOOK_WEIGHT`), and a pre/post phase is
added only when that hook is actually set — so a hook-less backup is just its
rsync phase(s) and tracks the copy directly. All weights are overridable:
`setPreBackup` / `setPostBackup` / `setPreRestore` / `setPostRestore` take an
optional second `weight` argument, and a sync's weight is set via the new
`BackupSync.weight` field (or the `weight` key on `addVolume`'s options).
Pre/post hooks now receive a `PhaseHandle` for their own phase rather than a
full sub-tracker.
- **`createBackup` no longer marks its own progress tracker complete.** The
StartOS backup harness owns per-package completion and holds the phase open
until the package's `.s9pk` image has finished writing to the backup target,
so a package reports 100% and "still working" during the image write rather
than a premature "done". Restore is driven by the init harness and was already
finalized externally, so it is unchanged in this respect.
## 2.0.6 — StartOS 0.4.0-beta.10 (2026-07-17)
### Fixed
- **`make` no longer fails in a git repository that has no index yet.** `s9pk.mk` listed `$(GIT_DIR)/index` as an unconditional prerequisite of the s9pk targets, but `git init` does not create an index until the first `git add` — so make aborted with `No rule to make target '.git/index', needed by '<id>_x86_64.s9pk'` before the pack step ever ran. This hit every freshly scaffolded package: `s9pk init-package` runs `git init` and stages nothing, and the generated `TODO.md` sends the packager straight to `make` as their first build, while the workflow guide tells them to iterate with a dirty tree and commit once at the end. The existing `$(if $(GIT_DIR),…)` guard asked only whether a repo exists, not whether these files do. `GIT_DEPS` now filters through `$(wildcard …)`, so a missing `HEAD` or `index` drops out of the prerequisite list instead of halting the build. Nothing else needed to change: `s9pk pack` already handles a commit-less repo, reporting `No git commit found in . — building without a commit hash in the manifest` and packing with a null `gitHash`. These entries are rebuild triggers, not build requirements
## 2.0.5 — StartOS 0.4.0-beta.10 (2026-07-13)
### Fixed
- **ExVer range operations no longer ignore the downstream revision.** `compareVersionRangePoints` and `adjacentVersionRangePoints` compared the upstream version twice instead of comparing the downstream on the second pass, so two points that shared an upstream but differed in downstream (`1.0.0:3` vs `1.0.0:15`) collapsed into a single point. Everything built on the truth tables inherited the error — `normalize()` silently dropped the lower of the two, and `intersects()` / `satisfiable()` could answer on a merged point. `=1.0.0:0 || =1.0.0:1` normalized to `=1.0.0:1`.
The visible consequence was in packed manifests: `canMigrateFrom` / `canMigrateTo` are derived from the version graph and normalized, so any package declaring an `other` version sharing `current`'s upstream advertised a range narrower than the truth (`mempool` at `3.3.1:15` with `other: [3.3.1:3]` shipped `canMigrateFrom: <=3.3.1:3` rather than `<=3.3.1:15`). No upgrade actually broke — StartOS resolves migrations through the version graph rather than gating on this field, and the registry index does not yet populate `sourceVersion` from it — but the manifests were wrong and would have become load-bearing the moment either changed. The Rust implementation derives its point ordering and was never affected.
## 2.0.4 — StartOS 0.4.0-beta.10 (2026-07-13)
### Fixed
- **`Daemons.dynamic` now composes with `setupMain` instead of replacing it.** `main` is always `sdk.setupMain(...)`; what varies is the `DaemonBuildable` you return from it — a static `sdk.Daemons.of(...)` chain, or the reconciler for a runtime-varying daemon set. The OS ABI has always said as much (`ExpectedExports.main` returns a `DaemonBuildable`, and **both** `Daemons` and `DaemonsReconciler` implement it), but two signatures made that composition unwritable: `setupMain` demanded a `Daemons` rather than the ABI's `DaemonBuildable`, and `Daemons.dynamic(fn)` returned a `main` export — burying the `DaemonsReconciler` it constructs inside a closure. The only shape that compiled was the wrong one: replace `main` with `Daemons.dynamic(...)`, then nest `Daemons.of()` inside its builder. Packages adopting dynamic daemons were funnelled straight into it. Now:
- **Breaking — `Daemons.dynamic` takes `effects` and returns the reconciler:** `sdk.Daemons.dynamic(effects, fn): DaemonsReconciler` (was `sdk.Daemons.dynamic(fn): main`). Return it from `setupMain`.
- `setupMain`'s callback is widened to the ABI: it accepts any `T.DaemonBuildable`.
Migration — wrap the builder in `setupMain` and pass `effects`:
```typescript
// before (2.0.0–2.0.3)
export const main = sdk.Daemons.dynamic(async ({ effects }) => {
return sdk.Daemons.of(effects).addDaemon(...)
})
// after
export const main = sdk.setupMain(async ({ effects }) => {
return sdk.Daemons.dynamic(effects, async ({ effects }) => {
return sdk.Daemons.of(effects).addDaemon(...)
})
})
```
Reconcile semantics are unchanged: inside the builder, `constRetry` reruns-and-reconciles rather than firing `effects.restart()`, so a watched-state change touches only the daemons whose `configHash` moved and the service stays `running`. Reported in [#3470](https://github.com/Start9Labs/start-technologies/issues/3470)
## 2.0.3 — StartOS 0.4.0-beta.10 (2026-07-08)
### Fixed
- **A dependency-gated daemon no longer wedges permanently after its dependency's readiness flaps.** Since 2.0.0's hold/release refactor, `Daemon.term()` unconditionally destroyed the daemon's `SubContainer`, and `HealthDaemon` calls `term()` every time a dependency goes not-ready. When the dependency's health then recovered, the daemon's `start()` called `hold()` on the already-destroyed subcontainer and threw `cannot hold subcontainer …: already destroyed`. Because that `start()` was un-awaited, it surfaced as an unhandled rejection and the daemon never recovered — the health check reported "not ready" / "daemon crashed" indefinitely, curable only by a full package restart. Any service with a dependency-gated daemon was exposed (e.g. `c-lightning`'s and BTCPay's web UIs). The 2.0.0 refactor had removed the `destroySubcontainer` flag that previously kept the subcontainer alive across a stop, collapsing "pause" and "teardown" into one destroying `term()`. This restores the distinction under the hold/release model: `Daemon` gains a non-destroying **`stop()`** (aborts the loop, terminates the process, releases the hold — but leaves the `SubContainer` intact so a later `start()` re-holds it), and `HealthDaemon.changeRunning(false)` now calls `stop()` for a dependency-driven pause. `Daemon.term()` (used by `HealthDaemon.term()` and `Daemons.term()` for genuine teardown) still releases the hold and destroys the subcontainer. `HealthDaemon.changeRunning(true)` additionally awaits and catches `start()`, so a start failure becomes a health failure rather than a silent unhandled-rejection wedge
- **Dependency-driven pause/resume transitions are now serialized.** `HealthDaemon.updateStatus()` runs fire-and-forget from dependency watchers, so a fast readiness flap (not-ready → ready before the pause's `stop()` finished draining the process) could overlap the pause with the following resume. Because `Daemon.start()` is a no-op while the previous run loop is still winding down, the resume's `start()` could silently do nothing and leave the daemon stopped while the health session believed it was running — the same class of wedge, reachable via a fast flap. `HealthDaemon` now applies pause/resume transitions on an in-order promise chain, so a resume always waits for the in-flight pause to complete before re-holding and restarting
## 2.0.2 — StartOS 0.4.0-beta.10 (2026-07-08)
### Changed
- **`s9pk.mk` checks build tools before packing, not just before `install`.** The `check-deps` target now runs as an order-only prerequisite of the build targets, so a missing `start-cli`, `npm`, `git`, or `jq` fails fast with a clear message instead of surfacing partway through a build. `git` is required because `s9pk pack` embeds the repository's commit hash (`git rev-parse HEAD`) in the manifest; `jq` is required because the post-build summary reads the manifest with it (without it the summary would print blank fields). The "not found" messages now link to the current setup guide (`https://docs.start9.com/packaging/environment-setup.html`) instead of a dead pre-reorg URL.
## 2.0.1 — StartOS 0.4.0-beta.10 (2026-07-03)
### Fixed
- **The bundled lint runner (`lint.mjs`) now works in the published package.** 2.0.0 shipped `lint.mjs` and `eslint.config.base.mjs` but only `@start9labs/start-core` was in `bundleDependencies`, so npm never packed the `eslint` / `typescript-eslint` that `lint.mjs` imports — every consumer's build gate (`node node_modules/@start9labs/start-sdk/lint.mjs`, run by `s9pk.mk`) crashed with `ERR_MODULE_NOT_FOUND`. `eslint` and `typescript-eslint` are moved from `devDependencies` to `dependencies` and added to `bundleDependencies`, so the lint toolchain ships inside the SDK's own `node_modules` (as `lint.mjs` already documented). `typescript` stays a peer resolved from the consumer — guaranteed present because `s9pk.mk` runs `tsc` (`npm run check`) before the lint step. Packages need no change beyond taking 2.0.1
## 2.0.0 — StartOS 0.4.0-beta.10 (2026-07-03)
### Added
- `addSsl.upstreamCertValidation` controls how the OS reverse proxy validates the container's TLS certificate when it **rewraps SSL** (`addSsl` set AND the protocol's `secure.ssl === true`, e.g. `https`/`wss` — the OS terminates the client's TLS and opens a fresh TLS connection to the container). Omitted (the default) validates against the StartOS root CA, unchanged from before. `'disable'` skips validation entirely — for containers serving a self-signed cert on the trusted internal bridge. `{ certificate: '<pem>' }` validates against a supplied PEM certificate/chain instead of the root CA. Set it through `bindPort`'s `addSsl`, e.g. `multi.bindPort(443, { protocol: 'https', addSsl: { upstreamCertValidation: 'disable' } })`. Exported as `UpstreamCertValidation`
- **Init progress reporting via `FullProgressTracker` (auto-syncing).** Service code never calls a progress effect — and usually never calls `sync()` either. The init harness builds one root `FullProgressTracker` with the effects context baked in and passes it to every init handler as a third argument: `setupOnInit(async (effects, kind, progress) => …)`. Each handler adds its own phases (with its own names) to the shared tracker, unaware of the others. Add phases and update them (`phase.setTotal/setDone/complete`); **every update auto-reports in the background** to the "Installing" / "Updating" phase of the install. Auto-sync is coalesced — at most one report in flight and one queued, so a burst of updates collapses to the latest snapshot and promises never stack up. `tracker.sync()` is now an explicit _flush_ (no args) that resolves once in-flight + queued reports have drained; the harness calls it before returning. Migrations receive the same tracker via `migrations.up`/`down`/`other` opts (`async ({ effects, progress }) => …`). A handler that didn't keep what `addPhase`/`addNestedPhase` returned can fetch it back by name with `tracker.getPhase(name)` (a `PhaseHandle` or, for a nested phase, a `FullProgressTracker`). `FullProgressTracker` is exported from `utils` (`utils.FullProgressTracker`). The underlying `effects.setInitProgress` / `effects.setBackupProgress` remain but are internal
- **Backup/restore progress mirrors the same structure.** `Backups.createBackup` and `restoreBackup` build an auto-syncing `FullProgressTracker` instead of calling the effect directly. The pre/post backup and restore hooks (`setPreBackup`, `setPostBackup`, `setPreRestore`, `setPostRestore`) now receive a `FullProgressTracker` as a second argument so custom work (DB dumps, etc.) can report sub-progress; restore progress flows through the init tracker since restore runs during init. Existing hooks that ignore the new argument keep working
- `PgDumpConfig.readyTimeout` / `MysqlDumpConfig.readyTimeout` make the post-start readiness wait in `Backups.withPgDump` / `Backups.withMysqlDump` configurable (milliseconds, matching the `gracePeriod` / `sigtermTimeout` convention; defaults 60000 Postgres / 30000 MySQL/MariaDB — unchanged from before). Each dump helper boots a throwaway DB server against the volume and polls until it accepts connections; a large or crash-recovering data directory can exceed the old hard-coded ceiling and fail the backup with "failed to become ready within N seconds" even though the live daemon — which gets a far longer `gracePeriod` — starts fine. Raise it to match, e.g. `withMysqlDump({ …, readyTimeout: 180000 })`
- `userspaceFilesystems` and `virtualNetworking` manifest flags split the former `nestedRuntime` flag into its two independent device grants. `userspaceFilesystems` mounts `/dev/fuse` for fuse-overlayfs storage (the rootless driver behind a nested OCI runtime). `virtualNetworking` mounts `/dev/net/tun` so a service can bring up kernel tun interfaces for VPN / WireGuard / tun-class workloads. The service LXC already retains `CAP_NET_ADMIN` within its user namespace via the standard `userns.conf` include, so no extra capability machinery is required — only the device node was missing
- **`SharedOptions.idmap` on volume / asset / dependency / backup mounts is now functional** (the field was previously declared but inert). Each entry is `{ fromId, toId, range? }` — map `range` consecutive ids (default `1`) from filesystem id `fromId` (u) to mountpoint id `toId` (k) — so a mount can present files under the uid/gid the service expects regardless of how they're stored on the host volume. The container's own LXC id-mapping is applied automatically and must **not** be included here. End-to-end support requires the StartOS 0.4.0-beta.10 host (7.0.7-backports kernel): the inner bind now runs through a new in-LXC `start-container mount` (`open_tree(OPEN_TREE_CLONE)` + `mount_setattr(MOUNT_ATTR_IDMAP)` + `move_mount`) rather than `mount --bind -oX-mount.idmap=…`, and `SubContainer.bind()` uses the same path. The standalone `IdMap` binding is removed (folded into `MountTarget.idmap`) and the host-side `IdMap::stack` workaround (which produced overlapping uid_map ranges on 6.x) is gone. The public `effects.mount(...)` signature is unchanged. Fixes [#3248](https://github.com/Start9Labs/start-technologies/pull/3248)
- `MultiHost.bindPortRange({ internalStartPort, externalStartPort, numberOfPorts })` reserves a contiguous TCP+UDP port range in a single call, intended for real-time / WebRTC servers (coturn, RTP, SIP) that need a public range. The whole range is allocated atomically; any partial collision with already-bound external ports is a hard error (no shifted-range fallback). A range is **2–500 ports**; for a single port use `bindPort`. `externalStartPort` may differ from `internalStartPort`: the forward maps the external range onto the internal range by offset (port-preserving when the two bases are equal). Returns a `RangeOrigin`, on which `.export(sdk.createRangeInterface(effects, { id, name, description, scheme? }))` registers the range's single, restricted `api` service interface — no SSL and no `masked`/`username`/`path`/`query`/`schemeOverride`. A range exposes **exactly one** interface (distinct endpoints are separate `bindPortRange` calls); the optional `scheme` is a transport prefix (e.g. `tcp` for bitcoin ZMQ endpoints) that most ranges (coturn RTP, FTP data) omit. The OS persists the range as a single `RangeBindInfo` record under `Host.binding_ranges` (not N entries in `Host.bindings`), carrying the exported interface and installing one nft rule per chain covering the whole range (`PortForward` gains a `count` field, defaulting to 1 for back-compat). Backed by the effects `effects.bindRange(...)` and `effects.exportRangeServiceInterface(...)` (requires StartOS with the matching backend handlers — landed in this PR). Fixes [#3269](https://github.com/Start9Labs/start-technologies/issues/3269)
- `sdk.Daemons.dynamic(fn)` builds a reactive `main` entrypoint whose daemon set is a function of on-disk state. The supplied builder returns a regular `sdk.Daemons.of({ effects }).addDaemon(...)` chain (now record-then-materialize — see below), and the SDK diffs its entries against the running set on every `effects.constRetry` trigger (typically fired by a `FileHelper.read().const(effects)` watcher). Per id: absent → present **start**, present → absent **stop**, same `configHash` **leave alone**, different `configHash` **restart**. Dependents of any restarted or stopped daemon are also restarted to keep `requires` wiring consistent. Re-runs coalesce while one is in flight. Designed for multi-tenant packages like the registry-portal s9pk that add, rename, and delete sub-instance daemons without restarting the service
- `configHash` covers the subcontainer descriptor (`imageId`, `sharedRun`, `name`, structural `mounts.build()`), exec (`command`, `env`, `cwd`, `user`, `runAsInit`, `sigtermTimeout`), `requires` (sorted), and the structural parts of `ready` (`display`, `gracePeriod`). Closures (`ready.fn`, `ready.trigger`, function-form `exec.fn`) and pre-built `Daemon` instances are intentionally excluded — surface a value through one of the hashed fields if you want the reconciler to react to it changing
- `sdk.SubContainer.eager(...)` creates a SubContainer with its filesystem materialized immediately. Use when you need `createFs` failures to surface at the construction site instead of at first method call, or when you need sync access to `rootfs` / `guid` / `subpath()` before running any methods
- `SubContainerLazy.eager(): Promise<SubContainerEager<M>>` forces materialization on a lazy handle and returns the underlying eager subcontainer for callers that need the narrowed sync interface
- New compile-time validator `ValidateExVerRange<T>` validates exver version-**range** string literals across the full range grammar (comparison / caret / tilde anchors over `[#flavor:]upstream[:downstream]` specs, `&&` / `||`, parentheses, negation, `#flavor` atoms, and `*`), catching a malformed version atom anywhere in the expression. It now guards the two places a range literal is written: a dependency's `versionRange` (via `sdk.setupDependencies`) and the keys of `migrations.other`, so typos like `'>=2.f'` or `'^28 || 30.x:0'` fail at `tsc` time instead of at runtime. Purely additive types — runtime parsing is unchanged, and structural mistakes (unbalanced parens, a dangling operator) are still left to runtime validation
- Actions gain an optional `access` field on the metadata passed to `Action.withInput` / `Action.withoutInput` — `'public' | 'dependent' | 'user'`, defaulting to `'user'` when omitted. It controls who may invoke the action **directly** via `effects.action.run({ packageId, actionId, input })`: `'public'` — any installed package; `'dependent'` — only services that declare this package as a current dependency; `'user'` — only the user (other services must `effects.action.createTask(...)`). Direct cross-package runs against an action whose access denies the caller are rejected. Direct runs still go through the owning service and honor the action's `visibility` (disabled) and `allowedStatuses` checks
- `detach()` on `SubContainer`, `Daemon`, and `Daemons` (plus the object returned by `Daemons.dynamic(...).build()`) severs the instance from the effects context it was created under, so that context leaving (`onLeaveContext`) no longer terminates the daemon(s) or destroys the subcontainer(s). This is the escape hatch for the now-deterministic context cleanup (see _Fixed_): a short-lived context — typically an **action** — can build a `Daemons` chain (or a lone `Daemon`/`SubContainer`), `detach()` it, and let it outlive the RPC that started it. A `Daemon` takes ownership of its subcontainer's teardown at construction (detaching it from the per-context cleanup net so only the daemon's `term()` — stop process, then destroy — can tear it down), so detaching the daemon/chain only governs its own self-term. After detaching, the caller owns the lifecycle and must call `term()` (or `destroy()`) explicitly. Idempotent
- The published package now ships `s9pk.mk` and `tsconfig.base.json` at its root, so a package can consume the canonical build plumbing by reference instead of vendoring copies: its `Makefile` does `include node_modules/@start9labs/start-sdk/s9pk.mk` and its `tsconfig.json` does `"extends": "@start9labs/start-sdk/tsconfig.base.json"`. Bumping the SDK then delivers build-system fixes automatically, ending the per-package drift of these files. The `s9pk.mk` `install` / `publish` targets resolve the deploy host and registry through `start-cli` (the packaging-workspace `.startos/config.yaml` `host`/`registry` profiles, or `-H` / `-r`) instead of parsing `~/.startos/config.yaml` directly, so the plumbing works with the `s9pk init-workspace` model out of the box
### Changed
- **Breaking — `sdk.SubContainer.of(...)` is now lazy by default.** Returns a `SubContainerLazy<M>` _synchronously_ (no `Promise<>` wrapper) whose filesystem is materialized on first method call. `rootfs` / `guid` / `subpath()` on the unified `SubContainer<M>` interface widen to `T | Promise<T>`; the concrete classes narrow (`SubContainerEager` to `T`, `SubContainerLazy` to `Promise<T>`). Code holding the generic interface must `await` those accessors; code holding `SubContainerEager` keeps sync access. Migration: most callers already use only async methods (`.exec`, `.writeFile`, `.spawn`, `.launch`) and need no change beyond dropping the redundant `await` on `SubContainer.of(...)`; callers that read `.rootfs` / `.guid` / `.subpath()` add an `await` or switch to `sdk.SubContainer.eager(...)`. The lazy default enables `Daemons.dynamic`'s "leave alone is load-bearing" guarantee: unchanged daemons across reconciles never materialize a fresh subcontainer
- **Breaking — `SubContainerOwned` / `SubContainerRc` collapsed into `SubContainerEager`.** The reference-counted handle (`SubContainerRc`, `.rc()`, `.isOwned()`) is replaced by an internal hold-count on the unified `SubContainer`. Multiple consumers each call `sub.hold()` (returns a release fn); the container's `destroyFs` fires when `destroy()` has been called and the last hold is released, in either order. `Daemon.start()` takes its own hold for the daemon's lifetime and releases it on `term()`. The `destroySubcontainer` flag on `Daemon.term` / `HealthDaemon.term` is gone — `Daemons.term()` calls `destroy()` on each unique subcontainer in its entries, and the hold machinery handles sharing safely
- **Breaking — `Daemon.sharesSubcontainerWith` removed.** Two `Daemon`s share a subcontainer when constructed with the same `SubContainer` instance (compare `daemon.subcontainer.identity`). Daemons' internal "should I destroy this subc?" branching is gone — it always calls `destroy()`, and hold-count decides
- **Breaking — `Daemons` is record-then-materialize.** `.addDaemon()` / `.addOneshot()` / `.addHealthCheck()` append a recorded entry without constructing `HealthDaemon` or `Daemon`. `Daemons.build()` walks the entries and constructs the chain in one pass. Functionally identical for `setupMain` callers — the original "construct on addDaemon, kick off on build" timing is invisible because nothing actually starts until `build()`'s `updateStatus()` calls anyway. The change is load-bearing for `Daemons.dynamic`, which needs to diff entries without paying the cost of building them eagerly each reconcile
- **Breaking — `SubContainer` adds `identity: symbol`.** Sync, stable handle preserved across `SubContainerLazy.eager()` materialization. Use for sharing checks that must work before materialization (replaces `Daemon.sharesSubcontainerWith`'s previous `guid` comparison, which couldn't fire pre-materialization)
- Container-runtime updated: `SubContainerOwned` → `SubContainer.eager`; `SubContainerRc<M>` → `SubContainer<M>`; `daemon.subcontainerRc()` → `daemon.subcontainer`
- **zod bumped `4.3.6` → `4.4.3`** (it was emergency-pinned to exactly `4.3.6` in 1.4.1). zod 4.4.0 tightened object parsing so a required key wrapped in `.catch()` threw `"expected nonoptional, received undefined"` on a _missing_ key — which broke the SDK at import (`actions/input/inputSpecConstants.ts` parses its SMTP shapes at module load) and the `.merge({})` seed pattern that every FileHelper-based package relies on. zod **4.4.3** (#5939, #5941) restores the 4.3.6 behavior, so the pin is exactly `4.4.3` — **not** `^4.4.0`, which would admit the still-broken 4.4.0–4.4.2. The `zExport` loose-object patch and FileHelper are unaffected (verified against the shipped build). Downstream: packages taking SDK 2.0 inherit zod 4.4.3 transitively and need **no** schema change for the catch-on-missing case; the other cumulative 4.4.0 fixes (stricter `z.base64()` / `z.httpUrl()`, `.merge()` throwing on schemas with refinements, materialized tuple defaults) may surface as `tsc` errors only in packages that use those specific APIs
- **TypeScript bumped `^5.9.3` → `^6.0.3`, and the build migrated off the deprecated `moduleResolution: "node"` (node10).** TS 6.0 deprecates node10 resolution (removed entirely in TS 7.0). The SDK's own `base` / `package` tsconfigs now use `moduleResolution: "bundler"` (keeping `module: "commonjs"` — emitted CJS is byte-identical and the published `.d.ts` API surface is unchanged) plus an explicit `rootDir` (TS 6.0 changed the default to the tsconfig directory). The shipped `tsconfig.base.json` that packages `extends` now uses `target: "ES2022"`, `moduleResolution: "bundler"` + `module: "preserve"`, `types: ["node"]`, and `rootDir: "${configDir}"`, so a package needs only `extends` + `include` and nothing else: node globals are not auto-included under bundler resolution; the prior `ES2018` target predated `Object.fromEntries` and other ES2019+ lib APIs packages rely on; and TS 6.0 requires an explicit `rootDir` for the `ncc` emit (the `${configDir}` template resolves to each extending package's own directory). Putting the fleet on modern resolution before TS 7, no SDK source changes were required
- **Breaking — `input-not-matches` task input split into `accept` (a list) and `set`.** `TaskInput` was `{ kind: 'partial', value }`, where the single `value` both decided whether the task was satisfied (the current action input had to be a superset of it) and prefilled the action form when the user ran the task. It is now `{ kind: 'partial', accept: DeepPartial<Input>[], set: DeepPartial<Input> }`: the task is satisfied when the current input matches **any** entry in `accept`, and when none match the task is shown and prefills `set`. This lets a package accept several already-good configurations while still pushing a single recommended value when none of them hold — e.g. accept either of two valid network modes, but set the preferred one otherwise. The cross-package critical-conflict guard now activates only when the input conflicts with **every** `accept` entry. Migration: replace `value: X` with `accept: [X], set: X` for identical behavior to 1.x. Already-published s9pks built against the pre-2.0 SDK keep working without a rebuild — the StartOS host still accepts the legacy `{ kind: 'partial', value }` payload over the effects socket and normalizes it to `accept: [value], set: value`
- **Breaking (internal) — zod loose-object handling unified onto a single `z` export; the global `require.cache` patch was removed.** The SDK makes `z.object()` preserve unknown keys (so `FileHelper` models that declare only a subset of a config file round-trip the rest). This was previously done by _two_ mechanisms: a shadow `z` (the one exported from `@start9labs/start-sdk`) plus a walk over Node's `require.cache` that mutated the raw `zod` module so the SDK's own internals — which `import { z } from 'zod'` — also got loose objects. The cache walk was fragile (it duck-typed the module cache and silently no-op'd outside CommonJS). It is removed: the SDK's internal modules now import `z` from the shadow directly, and `FileHelper`'s structured factories (`json` / `yaml` / `toml` / `ini` / `env` / `xml`) now deep-loosen their shape explicitly via `z.deepLoose(shape)`, making unknown-key preservation a property of the file-model boundary rather than a global default. **Packaging code is unaffected:** `import { z } from '@start9labs/start-sdk'` still yields loose-by-default objects and every package's file models keep preserving undeclared on-disk keys. The only behavioral change is for code that imported `z` directly from `zod` and relied on the SDK to have patched it — it must import `z` from `@start9labs/start-sdk` instead. No StartOS packaging code does this (only vendored upstream application sources import `zod` directly, and they use their own copy)
- **Breaking — service interfaces moved onto their binding; `sdk.serviceInterface.*` accessors replaced by `sdk.host.*`.** A service interface is no longer a flat entry on `PackageDataEntry.serviceInterfaces` (that field is removed) — it now lives on the binding that exported it: `Host.bindings[internalPort].interfaces` (a `ServiceInterfaceId`-keyed map; a port may back several) for single-port `Origin.export`, and `Host.bindingRanges[internalStartPort].interface` for a range's `RangeOrigin.export`. Correspondingly the SDK drops `sdk.serviceInterface.{getOwn, get, getAllOwn, getAll}` and adds `sdk.host.getOwn(effects, hostId)` / `sdk.host.get(effects, { hostId, packageId? })`, which return the reactive `Host` (same `const`/`once`/`watch`/`onChange`/`waitFor` read strategies). Both accessors take an optional `map` (and `eq`, default deep-equal) selector — matching the old `sdk.serviceInterface.*` — so `const()` can re-run on a change to a single child attr of the host rather than wholesale on the entire host. Reach an interface by walking the host — `Object.values(host.bindings).flatMap(b => Object.values(b.interfaces))` (or `host.bindingRanges[start].interface`). Each single-port interface's `addressInfo` comes back **already filled** — carrying the `filter`/`format`/`nonLocal`/`public`/`bridge`/`toUrl` helpers directly (e.g. `iface.addressInfo.format('url')`), so no separate `utils.filledAddress(host, …)` call is needed. The win: a binding's host is now reachable even when it exports no interface.
### Fixed
- Every materialized `SubContainer` is now torn down when the effects context that created it leaves (`onLeaveContext`), instead of lingering until GC eventually runs its `Drop` finalizer. This closes the gap where a subcontainer created in `main` (or any context) but never attached to a daemon — e.g. an ad-hoc setup/bootstrap container — could outlive its context. One cleanup hook is armed per effects object and each subcontainer removes itself on `destroy()`, so repeated short-lived containers (`withTemp`, per-poll health checks) don't accumulate registrations; teardown still routes through the hold-aware `destroy()`, so a container held by a running daemon defers until the daemon's own shutdown releases it
- `filledAddress` now excludes mDNS (`.local`) addresses whose gateways have no enabled LAN IP. An mDNS name resolves only via a LAN IP on a shared gateway, so when every such IP is disabled the `.local` address is unreachable — it was previously still reported as available, which let the StartOS UI offer (and launch) an unresolvable `.local` URL even though the address table showed it disabled. The rule is now exported as `utils.mdnsResolvable(hostname, enabledHostnames)`, shared between the SDK's reachable-address filter and the UI's address table so the two stay consistent
### Removed
- **Breaking — `nestedRuntime` manifest flag removed**, with no compatibility alias. It conflated two unrelated device grants (`/dev/fuse` for fuse-overlayfs storage and `/dev/net/tun` for kernel tun interfaces). Replace with `userspaceFilesystems` (nested OCI runtimes) and/or `virtualNetworking` (kernel tun interfaces). Packages must republish: a host updating StartOS parses an old `nestedRuntime` field as absent, so both new flags default to `false`
- **Breaking — package `alerts` manifest field removed**, with no compatibility alias. Packages can no longer define `install` / `update` / `uninstall` / `restore` / `start` / `stop` alert messages (the confirmation prompts StartOS showed before those lifecycle actions). Drop the `alerts` block from `setupManifest`; a host updating StartOS parses any leftover `alerts` field as absent and StartOS no longer surfaces these prompts. Built-in confirmations for destructive actions (uninstalling, stopping a service with active dependents) are unaffected
- `SubContainerOwned`, `SubContainerRc`, `SubContainer.rc()`, `SubContainer.isOwned()` — folded into the unified `SubContainerEager` / `SubContainerLazy` with hold/release lifecycle
- `Daemon.subcontainerRc()`, `Daemon.markManaged()`, `Daemon.sharesSubcontainerWith()` — superseded by `daemon.subcontainer` (public readonly) and the hold-count model
- `destroySubcontainer` option on `Daemon.term` / `HealthDaemon.term` — `Daemons` calls `subcontainer.destroy()` for each unique subc on shutdown, and the hold-count decides actual timing
- **Breaking — `sdk.serviceInterface.{getOwn, get, getAllOwn, getAll}` and `PackageDataEntry.serviceInterfaces`** — interfaces moved onto their binding; use `sdk.host.{getOwn, get}` and walk the host's bindings (see _Changed_). The underlying `utils.getServiceInterface` / `utils.getServiceInterfaces` helpers and the `GetServiceInterface` reader class are removed with them — `sdk.host.get`/`getOwn` returns a host whose interface `addressInfo`s are already filled (`filter`/`format`/`nonLocal`/`public`/`bridge`/`toUrl`). The `get_service_interface` **RPC/effect is retained** for backwards compatibility with packages built against older SDKs
## 1.5.3 — StartOS 0.4.0-beta.9 (2026-05-20)
### Fixed
- `Backups.withMysqlDump` no longer wedges the entire backup (and the StartOS host) on the database-shutdown step. After the dump it stopped mysqld with `kill $PID && tail --pid=$PID -f /dev/null`, but the daemonized (mysql) / backgrounded (mariadb) mysqld is never reaped in the dump subcontainer, so it lingers as a zombie that keeps its PID and `tail --pid` waited on it forever. The subcontainer was then never torn down and the backup hung until the box was rebooted — often surfacing afterward as a misleading `ENOTCONN … mkdir` notification as mounts were unwound under the stuck operation. Shutdown now waits for mysqld to reach the zombie (`Z`) state or vanish (treating either as "exited"), with a bounded SIGKILL fallback so it can never deadlock again. Applies to both backup and restore. `withPgDump` was unaffected (it uses `pg_ctl stop -w`, which reaps cleanly). The `withMysqlDump` packages (ghost, mempool) should rebuild against this SDK
## 1.5.2 — StartOS 0.4.0-beta.9 (2026-05-15)
### Fixed
- `Backups.withPgDump` and `Backups.withMysqlDump` now stage the dump file in the subcontainer rootfs (`/tmp/<db>-db.dump`) and `cp` it through to the backup target, on both backup and restore. Previously `pg_dump` / `mysqldump` wrote directly to the `backup-fs` FUSE bind mount, where their writes were silently dropped — the process exited 0 but the resulting `<db>-db.dump` was 0 bytes. Every package using these helpers (immich, spliit, synapse, nextcloud, btcpayserver, mempool, ghost) has been shipping empty database dumps since the helpers were introduced; bump to this SDK and rebuild the s9pk to recover. Plain `cp` writes through the FUSE work, so staging through `/tmp` produces an identical on-disk dump file at the same path — no restore-format change
## 1.5.1 — StartOS 0.4.0-beta.9 (2026-05-13)
### Fixed
- `GetActionInputType` (used to infer the input shape for `TaskOptions` / `task()` partials) now matches against `ActionInfo` instead of `Action`. The conditional previously tested `A extends Action<...>` while `A` was constrained to `ActionInfo<...>`, so inference fell through to `never` and `TaskInput.value` collapsed to `DeepPartial<never>`. Tasks built from `ActionInfo` now infer their input type correctly
- `BindOptions.addSsl` is now `Partial<AddSslOptions>` for protocols without SSL variants, matching the type already used on the SSL-variant branch. The two branches of the discriminated union are now consistent, so callers can omit individual SSL option fields regardless of which protocol they're binding
## 1.5.0 — StartOS 0.4.0-beta.9 (2026-05-08)
### Added
- `sdk.notification.create(effects, options)` lets a package post a notification into the StartOS notifications panel, alongside the ones StartOS itself generates (e.g. on backup completion). `options` is `{ level, title, message, data? }`: omit `data` for a plain notification (panel row only) or pass markdown text for `data` to attach a long-form body the UI renders in a "View Details" modal (release notes, post-update changelogs, structured error reports). The host forces `packageId` to the calling service's id, so a package can't spoof another package
- Backed by a new effect `effects.notification.create(...)` (requires StartOS 0.4.0-beta.9 with the matching backend handler)
### Changed
- Minimum StartOS version bumped to `0.4.0-beta.9` — required for the `notification.create` effect added in this release
## 1.4.3 — StartOS 0.4.0-beta.8 (2026-05-07)
### Fixed
- `FileHelper.produce` no longer leaks an `abort` listener on its parent `AbortSignal` per `fs.watch` event. The listener was registered inside the `while` loop and never detached, so after ~11 file changes during a single `.const()` lifetime Node emitted `MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal]`. The listener is now scoped to a single iteration via `try`/`finally` so it is removed before the next iteration registers its own. Fixes [#3182](https://github.com/Start9Labs/start-technologies/issues/3182)
## 1.4.2 — StartOS 0.4.0-beta.8 (2026-05-06)
### Changed
- `VersionGraph.uninit` now throws when handed a range target with no satisfying version, instead of silently returning without advancing the data version. The previous silent behavior masked host bugs that produced contradictory targets (e.g. an `AND` of a flavored on-disk range with a flavor-less destination's `canMigrateFrom`) — uninit would skip the registered cross-flavor migration, leave the on-disk range untouched, and the destination's init would dead-end with `cannot migrate from <range> to <concrete>` with no signal at the source. Throwing surfaces the contract violation at the source where it can be diagnosed. Packages whose host correctly computes uninit targets are unaffected
## 1.4.1 — StartOS 0.4.0-beta.8 (2026-05-04)
### Fixed
- Pin `zod` to exact version `4.3.6`. Zod 4.4 changed `.catch()` to no longer fire on missing object keys (only on present-but-invalid values), which silently breaks every package's `.merge({})` seed pattern — required fields with `.catch()` defaults throw `"expected nonoptional, received undefined"` instead of falling back to the default. The previous `^4.3.6` range allowed packages installed after 4.4.0 dropped to pick up the new behavior on `npm install`. Pinning ensures consistent behavior across all packages regardless of when they were installed
## 1.4.0 — StartOS 0.4.0-beta.8 (2026-05-03)
### Added
- `FileHelper.yaml` accepts an optional `options` argument that is threaded into both `YAML.stringify` and `YAML.parse`. Enables custom YAML tags (`!include`, `!secret`, `!include_dir_*`, etc.) to round-trip through the file model. Pass `customTags` to register tag handlers; pair the handler's `resolve` (returns a JS value during parse) with `identify`/`stringify` (re-emits the tag during write) to keep tag information across read-modify-write cycles. Other yaml options (e.g. `indent`, `lineWidth`, `aliasDuplicateObjects`) are accepted on the same object
### Changed
- **Breaking:** `FileHelper.yaml`'s transformers overload now takes `options` at position 3 and `transformers` at position 4, matching the argument order of `FileHelper.ini` and `FileHelper.xml`. Call sites that passed `transformers` as the third positional argument must be updated. The simple form (`FileHelper.yaml(path, shape)`) is unchanged
## 1.3.4 — StartOS 0.4.0-beta.8 (2026-05-02)
### Added
- `ProxyAuth` and `BasicCredential` bindings re-exported from `interfaces/Host`. Pass `auth` on `AddSslOptions` to put a `Bearer` or `Basic` gate in front of an HTTPS bind; the OS reverse proxy validates `Authorization` before forwarding upstream and (for `Basic`) injects the matched username as `X-Forwarded-User`. Setting `auth` implies HTTP-aware proxying. Optional `realm` controls the `WWW-Authenticate` challenge string
### Changed
- Minimum StartOS version bumped to `0.4.0-beta.8` — required for the reverse-proxy auth gate added in this release
## 1.3.3 — StartOS 0.4.0-beta.6 (2026-04-24)
### Fixed
- `checkPortListening` reads `/proc/net/{tcp,tcp6,udp,udp6}` via `fs.promises.readFile` instead of `cat` through `child_process.exec`. The prior implementation failed with `stdout maxBuffer length exceeded` on hosts where `/proc/net/tcp` exceeded 1 MB, causing false negatives on busy boxes
## 1.3.1 — StartOS 0.4.0-beta.6 (2026-04-21)
### Changed
- Minimum StartOS version bumped to `0.4.0-beta.6`
## 1.3.0 — StartOS 0.4.0-beta.4 (2026-04-21)
### Added
- `nullToUndefined(obj)` utility and `NullToUndefined<T>` type — recursively convert `null` values to `undefined` through objects and arrays
### Fixed
- Backup `rsync` no longer passes `--no-inc-recursive`; the pre-scan it forced was timing out on large backups. Accurate progress percentages go away as a side effect — to surface progress in the future, count files up front and compute percentage from bytes/files seen
- `withPgDump` pre-backup `touch` and `chown` of the dump file now fail loud instead of silently swallowing errors. Previously, if the backup target couldn't be written or chowned (e.g. a filesystem that doesn't honor Unix ownership), `pg_dump` would hit a confusing `Permission denied` on open instead of surfacing the underlying cause
## 1.2.0 — StartOS 0.4.0-beta.4 (2026-04-17)
### Added
- `footnote: string | null` on all scalar value specs (`text`, `textarea`, `number`, `color`, `datetime`, `toggle`, `select`, `multiselect`) — supplementary text rendered persistently beneath the field. Useful for showing upstream defaults, units, or any dev-authored clarification
- `Value.triState(...)` / `Value.dynamicTriState(...)` — a three-state toggle (boolean toggle with a neutral middle position), rendered as three icon buttons (✕ / — / ✓). Left outputs `false`, right outputs `true`, middle outputs `null`
## 1.1.0 — StartOS 0.4.0-beta.4 (2026-04-15)
### Added
- `trigger.statusTrigger(defaultMs, overrides?)`: single trigger that maps each `HealthStatus` to a polling interval, replacing `changeOnFirstSuccess`, `successFailure`, and `lastStatus`
- TSDocs across the public daemon/health-check surface: `Ready`, `addDaemon`, `addOneshot`, `addHealthCheck`, `runUntilSuccess`, the `trigger` namespace, `healthCheck` helpers, and `HealthCheckResult`
### Changed
- Converted `VersionInfo`, `Action`, `Actions`, and `FileHelper` from classes to interfaces with companion objects, so services importing types across differing SDK versions no longer hit "type X does not match type X" nominal-typing errors
### Fixed
- Default daemon trigger now polls quickly for `starting`/`waiting`/`failure` and slowly for `success`/`loading`/`disabled`; previously `changeOnFirstSuccess` only transitioned on `success`, so a daemon stuck in `loading` (e.g. during Bitcoin IBD) was polled every 1s indefinitely
### Removed
- `trigger.changeOnFirstSuccess`, `trigger.successFailure`, `trigger.lastStatus` (use `statusTrigger` instead)
## 1.0.0 — StartOS 0.4.0-beta.0 (2026-03-31)
### Changed
- **Breaking:** Minimum StartOS version bumped to `0.4.0-beta.0` (from `0.4.0-alpha.23`) — marks the transition out of alpha
### Added
- `GenerateCertificateParams` / `GenerateCertificateResponse` bindings for certificates signed by the root CA
- `RestartReason` binding and `ServerStatus.restart` field for unified, reason-specific restart notifications
- `PackageVersionInfo.satisfies` field so dependency breakage checks can match flavored package versions against flavorless ranges
### Fixed
- `deepEqual` now handles non-plain objects by reference instead of walking them as structural values
## 0.4.0-beta.66 (2026-03-24)
- **Breaking:** `withPgDump()` replaces `pgdata` with required `mountpoint` + `pgdataPath`
- Passwordless/trust auth support for `withPgDump()` and `withMysqlDump()`
- New options: `pgOptions` for postgres, `mysqldOptions` for mysql/mariadb
- Fixed MariaDB backup/restore support
## 0.4.0-beta.65 (2026-03-23)
### Added
- `Backups.withPgDump()`: dump-based PostgreSQL backup using `pg_dump`/`pg_restore`, replacing raw volume rsync of PG data directories
- `Backups.withMysqlDump()`: dump-based MySQL/MariaDB backup using `mysqldump`/`mysql`
- Password configs accept `string | (() => string | Promise<string>)` for deferred resolution during restore
## 0.4.0-beta.63 — StartOS v0.4.0-alpha.22 (2026-03-22)
### Fixed
- Fixed `createTask` failing when input values are undefined
- Fixed daemon lifecycle cleanup and error logging improvements
- Replaced fire-and-forget restart loop in `Daemon` with tracked `AbortController`
- Fixed graceful shutdown for subcontainer daemons
- Fixed rsync progress regex never matching, spamming logs during backup
- Fixed rsync backup bugs and optimized flags for encrypted CIFS targets
- Fixed types in `inputSpecConstants`, `StartSdk`, and exports
## 0.4.0-beta.62 (2026-03-19)
### Fixed
- Fixed `Value.dynamicSelect` and `Value.dynamicMultiselect` crashing with `z.union([])` when `values` is empty (zod v4 compatibility)
### Added
- `FileHelper.xml`: file helper for XML files using `fast-xml-parser`
- `smtpShape`: typed zod schema for persisting SMTP selection in store file models, replacing direct use of `smtpInputSpec.validator` which caused cross-zod-instance errors
## 0.4.0-beta.61 — StartOS v0.4.0-alpha.21 (2026-03-16)
### Fixed
- Fixed bug where leaving the effect context triggered consts
## 0.4.0-beta.60 — StartOS v0.4.0-alpha.20 (2026-03-16)
### Added
- Tunnel TS type exports and port forward labels
- Secure Boot MOK key enrollment fields in `SetupInfo`
### Changed
- Consolidated `Watchable` base class with generic `map`/`eq` support; renamed `call` to `fetch`
- Moved `GetServiceManifest` and `GetSslCertificate` from `package/` to `base/`
- Simplified `getServiceInterface`, `getServiceInterfaces`, `GetOutboundGateway`, `GetSystemSmtp`, and `fileHelper` using `Watchable` base class
- Simplified SDK Makefile with rsync
### Fixed
- Added `restart_again` flag to `DesiredStatus::Restarting`
## 0.4.0-beta.59 — StartOS v0.4.0-alpha.20 (2026-03-06)
### Added
- Support for preferred external ports besides 443
- Bridge filter kind on service interfaces
### Fixed
- Merge version ranges when adding existing package signer
- Task fix for action task system
## 0.4.0-beta.56 — StartOS v0.4.0-alpha.19 (2026-02-02)
### Added
- `getOutboundGateway` effect and SDK wrapper
- Improved service version migration and data version handling
- `zod-deep-partial` integration with `partialValidator` on `InputSpec`
- SMTP rework with improved provider variants and system SMTP spec
### Changed
- Migrated from `ts-matches` to `zod` across all TypeScript packages
- Builder-style `InputSpec` API with prefill plumbing
- Split `row_actions` into `remove_action` and `overflow_actions` for URL plugins
### Fixed
- Scoped public domain to single binding and return single port check
- Preserved `z` namespace types for SDK consumers
- `--arch` flag falls back to emulation when native image unavailable
## 0.4.0-beta.54 — StartOS v0.4.0-alpha.18 (2026-01-27)
### Added
- Device info RPC
- Hardware acceleration and NVIDIA card support on nonfree images
### Changed
- Consolidated setup flow
- Improved SDK abort handling and `InputSpec` filtering
## 0.4.0-beta.49 — StartOS v0.4.0-alpha.17 (2026-01-10)
### Added
- JSDoc comments on all consumer-facing APIs
- StartTunnel random subnet support
- Port 80 to 5443 tunnel mapping
### Fixed
- `EffectCreator` type corrections
- Allow multiple equal signs in ENV `FileHelper` values
- Miscellaneous alpha.16 follow-up fixes
## 0.4.0-beta.45 — StartOS v0.4.0-alpha.16 (2025-12-18)
### Added
- `map` and `eq` on `getServiceInterface` watcher
- Flavor-aware version range handling
### Changed
- Refactored `StatusInfo` types
- Improved shutdown ordering for daemons
- Improved StartTunnel validation and garbage collection
## 0.4.0-beta.43 — StartOS v0.4.0-alpha.15 (2025-11-26)
### Fixed
- Minor bugfixes for alpha.14
## 0.4.0-beta.42 — StartOS v0.4.0-alpha.14 (2025-11-20)
### Fixed
- Bugfixes for alpha.13
## 0.4.0-beta.41 — StartOS v0.4.0-alpha.13 (2025-11-15)
### Fixed
- Bugfixes for alpha.12
## 0.4.0-beta.40 — StartOS v0.4.0-alpha.12 (2025-11-07)
### Added
- StartTunnel integration
- Configurable `textarea` rows in `InputSpec`
## 0.4.0-beta.39 — StartOS v0.4.0-alpha.11 (2025-09-24)
### Added
- Gateway limiting for StartTunnel
- Improved copy UX around Tor SSL
### Changed
- SDK type updates and internal improvements