@mutmutco/installer-launcher
v1.3.1
Published
Single-executable (SEA) launcher: sign-in, gated payload fetch, self-update. One copy ships per product.
Readme
launcher — single-executable installer for one product
A small Node >= 22, ESM, TypeScript + vitest single-executable-application (SEA)
launcher. One copy ships per product: the binary bakes a product.json asset, signs in the
user, fetches the gated release payload, verifies its Ed25519 signature, and unpacks it under
the product dir. Zero runtime dependencies — node: builtins only (fetch is global in
Node 22). Dev dependencies: typescript, vitest, @types/node, esbuild (mirrors cli/).
Reference: the google loopback PKCE flow is lifted from MM-Strategy's
src/cli/login.ts (read-only reference) — same four steps (loopback listener, dynamic client
registration, PKCE + browser, code exchange), adapted to the launcher's token store.
Commands
launcher login [--config <path>] [--dir <path>] sign in (the ONLY command that opens a browser)
launcher logout wipe tokens AND payload
launcher install refresh the launcher, login if no token -> fetch+verify+unpack -> last mile
launcher update [--dry-run] refresh the launcher, then the payload; no-op when current
launcher status [--json] read-only: launcher, payload vs the release, sign-in, auto-update
launcher doctor launcher report (no network), then the payload's doctor
launcher autoupdate on|off|status hourly per-user schedule (hidden via wscript on Windows)
launcher <verb> [args…] forwarded untouched when payload.json declares it
launcher [-p …|--model …] no verb: starts the product's session; leading flags the launcher does not own pass through
launcher --run <file> [args…] run a payload ESM file with the launcher's own runtime
launcher --version `<bin> <payload version> (launcher <version>)`Every screen that is not an install/update run is drawn by @mutmutco/installer-face (#7072):
usage, notices, the status/doctor report, and refusals (stderr). Launcher flags (--config,
--dir, --json, --no-color, --dry-run, --help, --version) are read before the command
and around the launcher's own verbs only. Only mutating verbs take the installation lock.
@mutmutco/installer-launcher/refresh exports refreshLauncher and readLauncherRecord (#7070):
the signed GET <host>/release/launcher record (manifest shape and signing) keeps the one-line
installer's launcher current from every update path. A SEA run records its own path in
<productDir>/launcher.json so the refresh finds it.
--run <file> [args…] runs a payload file with the launcher's OWN embedded runtime — no Node on
the machine. <file> resolves against the cwd and is loaded with import(); the file sees
process.argv = [execPath, <abs file>, ...args] and its process.exitCode is propagated. A
missing file prints one sentence on stderr and exits 1. --run never loads the product config and
never reads the token store, so it works with no config and no login. The file must be
self-contained ESM: bundled, with no node_modules resolution across the SEA boundary and no
require() of anything that is not a node: builtin.
--config <path> / LAUNCHER_CONFIG selects the product config; --dir <path> /
LAUNCHER_DIR overrides the product dir. Flags work before or after the command.
Only login (and the login half of install) opens a browser:
- github: prints the
user_code+verification_uriand opens the browser (verification_uri_completewhen the server sends one). Polls the device token endpoint perinterval; onslow_downadds 5s. SetLAUNCHER_NO_OPEN=1to print only (CI/tests). - google: loopback
127.0.0.1redirect with PKCE; the browser opens automatically.
The product dir defaults to ~/.<product>/ on mac/linux and %LOCALAPPDATA%\<product> on
Windows, and holds tokens.json (0600), state.json, and payload/.
Config
config/product.template.json per product:
{
"product": "example-product",
"host": "https://gate.example.com",
"loginKind": "github",
"githubClientId": "EXAMPLE_CLIENT_ID",
"publicKey": "<base64 of the RAW 32-byte Ed25519 public key>",
"binName": "example-product"
}loginKind is "github" or "google"; githubClientId is required for the github kind.
publicKey is the base64 of the raw 32-byte Ed25519 key the manifest signature verifies
against. No secrets are committed — the client id and public key are public; the client
secret (github) lives only on the gate server.
Build
npm install
npm run build # node build.mjs -> dist/launcher.js + dist/launcher.sea.cjs (+ dist/product.json staging)
npm run typecheck # tsc --noEmit
npm test # vitest run (builds dist/ first for the integration test)
node build.mjs --product-config ./config/my-product.json # stage a real product asset
node build.mjs --sea # also stamp the SEA single-executable (needs postject)Two bundles ship from one source: dist/launcher.js is ESM (run by node in dev and by the
integration tests) and dist/launcher.sea.cjs is CommonJS, baked as the SEA main (see below).
How it meets the contract (wire contract v1)
- Device flow (github):
POST {host}/gate/device/codebody{client_id}->200 {device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval};POST {host}/gate/device/tokenbody{client_id, device_code}->200 {access_token, refresh_token, expires_in}or400 {error: authorization_pending|slow_down|expired_token|denied}. Implemented byte-identically insrc/login-github.ts. - Refresh:
POST {host}/gate/refreshbody{refresh_token}->200 {access_token, expires_in}or403 {error}. Every start refreshes the access token before expiry (within 60s); token TTL is<= 3600s. - Gated reads:
Authorization: Bearer <access_token>onGET {host}/release/manifest(200 {version, created, files: [{path, sha256, size}], signature}) andGET {host}/release/<path>(raw bytes,404 {error}when absent). - Manifest signature: base64 detached Ed25519 over exactly the UTF-8 bytes of canonical
JSON
{"created":…,"files":[{"path":…,"sha256":…,"size":…}],"version":…}(keys sorted, no whitespace;createdused raw, never reformatted). Verified with the baked public key BEFORE writing anything, through the portedsrc/canonical.ts(verbatim frominstaller/gate/src/canonical.ts: UTF-16 code-unit key sort, arrays keep order,undefinedmembers dropped, non-finite numbers throw, stringsJSON.stringify-escaped). - Refusals:
401 {error:"unauthorized"}-> re-login (stored refresh is retried first, then a fresh login);403 {error:"forbidden"}-> revoked/allowlist-miss, stops with the plain sentence "this install is not allowed for your account — access was revoked or never granted." The access token is opaque (server-side HMAC) — stored and relayed, never parsed. - google loginKind: no
/gate/device/*endpoints; the loopback PKCE flow (src/login-google.ts) yields the bearer directly, then the same/releasereads apply. - Behaviors:
install= login if no token -> fetch+verify+unpack under the product dir (download to a temp staging dir, verify every size + SHA-256, then swap intopayload/) -> print next step;update= same fetch, re-checks manifest;doctor= config, token presence/expiry, payload version, path wiring;logout= wipe tokens AND payload.
The last mile (payload.json.entry) and $self
install ends by reading the payload's payload.json entry and running it with cwd on the
payload dir (relative paths, no home-dir quoting hazard). entry is either a whitespace-separated
string or a string array. Its first element may be the token $self, replaced at run time by
process.execPath — the launcher binary itself. A product that must not require Node on the
machine therefore declares:
{ "entry": ["$self", "--run", "dist/index.mjs", "install", "--from-payload"] }the launcher's embedded runtime then runs the payload's bundled ESM. Everything else in
defaultRunEntry is unchanged: when the command cannot run (spawn error, non-zero exit) the
launcher prints the exact command instead and still exits 0 (the command shown is the $self
entry already resolved).
SEA: import() and the embedded runtime
launcher --run relies on Node's dynamic import() of an on-disk ESM file inside the SEA binary.
Proven locally on Node v24.20.0 with postject:
- A Node 22/24 SEA main script is always CommonJS:
mainFormatexists only in Node >= 25.5, and injecting the ESMdist/launcher.jsas the main fails withSyntaxError: Cannot use import statement outside a module.build.mjstherefore also bundles a CommonJSdist/launcher.sea.cjs(targetesnext) and bakes THAT as the SEA main;src/module-url.tsbridges__filename(CJS) andimport.meta.url(ESM). import()of an on-disk.mjsfrom a CommonJS SEA main works: the probe imported the file, the file sawargv = [execPath, file, ...args], and the process exited with the file's code.- Node documents "
import()does not work whenuseCodeCacheis true", so the generated sea config setsuseCodeCache: false. (On 24.20 the probe also passed with ittrue; the flag staysfalseso the feature never leans on that caveat.) - The postject sentinel fuse is a hash embedded in the Node binary that changes between
releases (24.20 carries
NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2; older docs citedNODE_SEA_FUSE_fce680ab2cc467b6e840016ee2343c1a).build.mjsreads it out ofprocess.execPathinstead of hardcoding it. disableExperimentalSEAWarningstaystrue.
The reusable workflow .github/workflows/launcher-build.yml smoke-tests this end to end: after
--version it runs --run test/fixtures/run-hello.mjs a b against the stamped binary and asserts
the fixture's stdout.
Tests
- Unit:
test/canonical.test.ts(canonicalization + Ed25519 verify with agenerateKeyPairSync('ed25519')keypair),test/flows.test.ts(device-flow polling/backoff, refresh, unpack to temp dir, refusal paths),test/store-google.test.ts(token store + google loopback PKCE against a fake OAuth server),test/last-mile.test.ts(thepayload.jsonentry, the$selftoken, the manual-command fallback, and--runargv/exit-code/missing-file). - Integration:
test/integration.test.tsruns the builtdist/launcher.jsthrough a fullinstall -> doctor -> update -> logoutcycle against a localhost fake gate + release server (device polling, refresh, signature verification, 403 revocation, payload swap) and spawns--run test/fixtures/run-hello.mjs a bfor a real stdout + exit-code assertion.
Release CI (SEA binary)
node build.mjs --sea follows the Node SEA docs exactly:
- esbuild bundles
dist/launcher.js(ESM, fornode) anddist/launcher.sea.cjs(CommonJS, the SEA main — Node 22/24 SEA main scripts are CommonJS-only); - a
dist/sea-config.generated.jsonis written with the bakedproduct.jsonasset,useCodeCache: false(theimport()last mile), andmain=dist/launcher.sea.cjs; node --experimental-sea-config <generated>producesdist/sea-prep.blob;process.execPathis copied todist/<platform>-<arch>and stamped withpostject <binary> NODE_SEA_BLOB <blob> --sentinel-fuse <fuse read from the Node binary>. The name is the EXACT download name the bootstrap scripts request (/dl/<product>/<platform>-<arch>, platformdarwin|win, archarm64|x64;win32maps towin, and there is no.exesuffix —install.ps1downloadswin-x64and renames it to<product>.exe).
The SEA binary must be stamped on a native runner for its target OS/architecture — the Node SEA
blob embeds the host Node binary, so a macOS arm64 binary cannot be produced on Linux. The reusable
workflow .github/workflows/launcher-build.yml implements exactly the steps above on the two
supported lanes (macos-14 arm64, Apple Silicon, and windows-latest x64 — Intel Macs are not
supported, so there is no darwin-x64 leg): npm ci, install
postject, node build.mjs --product-config <cfg> --sea, smoke --version then
--run test/fixtures/run-hello.mjs a b, write the .sha256,
and upload launcher-<product>-<platform>-<arch>. It is callable (workflow_call) and dispatchable,
and it is the only supported path to a real SEA binary — nothing is faked in this lane.
Signed fresh acquisition
A signed payload.json can declare acquisition instead of implementing installation:
{
"acquisition": {
"schema": 1,
"package": "@scope/product",
"archive": "product-1.2.3.tgz",
"platforms": ["win32-x64", "win32-arm64", "darwin-arm64"],
"convergeEntry": "dist/index.js",
"convergeArgs": ["install", "--from-shared-prefix", "$prefix", "--version", "$version"],
"rollbackEntry": "dist/index.js",
"rollbackArgs": ["install", "--rollback-shared-prefix", "$prefix", "--version", "$version"],
"runEntry": "dist/launcher-entry.js",
"repairArgs": ["--install-converge"]
},
"entry": ["$self", "--run", "acquisition-required.mjs"],
"verbs": ["start", "doctor"]
}The archive and metadata must both be covered by the release signature. Entry paths are
package-relative; placeholders replace entire argv elements only. The launcher owns a pinned,
checksum-verified user-local Node 24.20.0/npm 12.0.2, local-archive project-prefix installation
with lifecycle scripts disabled, credential-free public npm resolution, and stable candidate
paths. Existing system Node/npm installations are unchanged. Updates acquire the selected
archive even when a previous product root exists. An unchanged update reuses its candidate and
runs runEntry with repairArgs for idempotent selected-product convergence; it never repeats
acquisition activation or reports ready from directory existence alone. Products own validation of their complete
release and their activation/host convergence through the declared commands.
Convergence runs the candidate entry directly with real Node, never SEA re-entry. Forwarding
also uses real Node so product grandchildren can use process.execPath. The consumer rollback
command must durably record prior/candidate selection before activation, compare-and-restore
under its own lock, restore absence for a fresh failed install, and succeed harmlessly if no
activation occurred. It must refuse to overwrite a newer concurrent product selection.
A flushed shared pending receipt precedes convergence. The launcher commits its selection
atomically only after convergence succeeds. On failure or interrupted startup it invokes the
consumer rollback before allowing another install or forwarding. Failed rollback stays pending.
A crash after shared commit completes the receipt instead of reverting the committed product.
Host registration is not atomic: failures can leave host references to retained candidates.
After a committed promotion the launcher removes candidate folders state.json no longer references (the active one is kept; symlinks and junctions are skipped; a failed delete is logged). A failed activation keeps its candidate.
An orphan lock-recovery guard fails closed and needs operator diagnosis.
For already-shipped old launchers, copy the exact file exported by
@mutmutco/installer-launcher/acquisition-required into the signed payload as
acquisition-required.mjs, and declare the legacy entry above. Old launchers truthfully refuse
and instruct the user to rerun the public installer. New launchers use acquisition instead.
