@mutmutco/installer-launcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # launcher — single-executable installer for one product
2
+
3
+ A small Node **>= 22**, **ESM**, **TypeScript + vitest** single-executable-application (SEA)
4
+ launcher. One copy ships per product: the binary bakes a `product.json` asset, signs in the
5
+ user, fetches the gated release payload, verifies its Ed25519 signature, and unpacks it under
6
+ the product dir. Zero runtime dependencies — `node:` builtins only (`fetch` is global in
7
+ Node 22). Dev dependencies: `typescript`, `vitest`, `@types/node`, `esbuild` (mirrors `cli/`).
8
+
9
+ Reference: the google loopback PKCE flow is lifted from MM-Strategy's
10
+ `src/cli/login.ts` (read-only reference) — same four steps (loopback listener, dynamic client
11
+ registration, PKCE + browser, code exchange), adapted to the launcher's token store.
12
+
13
+ ## Commands
14
+
15
+ ```
16
+ launcher login [--config <path>] [--dir <path>] sign in (the ONLY command that opens a browser)
17
+ launcher logout wipe tokens AND payload
18
+ launcher install login if no token -> fetch+verify+unpack -> print next step
19
+ launcher update same fetch, re-checks manifest; no-op when current
20
+ launcher doctor config, token presence/expiry, payload version, paths
21
+ launcher --run <file> [args…] run a payload ESM file with the launcher's own runtime
22
+ launcher --version
23
+ ```
24
+
25
+ `--run <file> [args…]` runs a payload file with the launcher's OWN embedded runtime — no Node on
26
+ the machine. `<file>` resolves against the cwd and is loaded with `import()`; the file sees
27
+ `process.argv = [execPath, <abs file>, ...args]` and its `process.exitCode` is propagated. A
28
+ missing file prints one sentence on stderr and exits 1. `--run` never loads the product config and
29
+ never reads the token store, so it works with no config and no login. The file must be
30
+ **self-contained ESM**: bundled, with no `node_modules` resolution across the SEA boundary and no
31
+ `require()` of anything that is not a `node:` builtin.
32
+
33
+ `--config <path>` / `LAUNCHER_CONFIG` selects the product config; `--dir <path>` /
34
+ `LAUNCHER_DIR` overrides the product dir. Flags work before or after the command.
35
+
36
+ Only `login` (and the login half of `install`) opens a browser:
37
+
38
+ - **github**: prints the `user_code` + `verification_uri` and opens the browser
39
+ (`verification_uri_complete` when the server sends one). Polls the device token endpoint per
40
+ `interval`; on `slow_down` adds 5s. Set `LAUNCHER_NO_OPEN=1` to print only (CI/tests).
41
+ - **google**: loopback `127.0.0.1` redirect with PKCE; the browser opens automatically.
42
+
43
+ The product dir defaults to `~/.<product>/` on mac/linux and `%LOCALAPPDATA%\<product>` on
44
+ Windows, and holds `tokens.json` (0600), `state.json`, and `payload/`.
45
+
46
+ ## Config
47
+
48
+ `config/product.template.json` per product:
49
+
50
+ ```json
51
+ {
52
+ "product": "example-product",
53
+ "host": "https://gate.example.com",
54
+ "loginKind": "github",
55
+ "githubClientId": "EXAMPLE_CLIENT_ID",
56
+ "publicKey": "<base64 of the RAW 32-byte Ed25519 public key>",
57
+ "binName": "example-product"
58
+ }
59
+ ```
60
+
61
+ `loginKind` is `"github"` or `"google"`; `githubClientId` is required for the github kind.
62
+ `publicKey` is the base64 of the raw 32-byte Ed25519 key the manifest signature verifies
63
+ against. No secrets are committed — the client id and public key are public; the client
64
+ secret (github) lives only on the gate server.
65
+
66
+ ## Build
67
+
68
+ ```sh
69
+ npm install
70
+ npm run build # node build.mjs -> dist/launcher.js + dist/launcher.sea.cjs (+ dist/product.json staging)
71
+ npm run typecheck # tsc --noEmit
72
+ npm test # vitest run (builds dist/ first for the integration test)
73
+ node build.mjs --product-config ./config/my-product.json # stage a real product asset
74
+ node build.mjs --sea # also stamp the SEA single-executable (needs postject)
75
+ ```
76
+
77
+ Two bundles ship from one source: `dist/launcher.js` is **ESM** (run by `node` in dev and by the
78
+ integration tests) and `dist/launcher.sea.cjs` is **CommonJS**, baked as the SEA main (see below).
79
+
80
+ ## How it meets the contract (wire contract v1)
81
+
82
+ - **Device flow (github)**: `POST {host}/gate/device/code` body `{client_id}` ->
83
+ `200 {device_code, user_code, verification_uri, verification_uri_complete?, expires_in,
84
+ interval}`; `POST {host}/gate/device/token` body `{client_id, device_code}` ->
85
+ `200 {access_token, refresh_token, expires_in}` or `400 {error:
86
+ authorization_pending|slow_down|expired_token|denied}`. Implemented byte-identically in
87
+ `src/login-github.ts`.
88
+ - **Refresh**: `POST {host}/gate/refresh` body `{refresh_token}` ->
89
+ `200 {access_token, expires_in}` or `403 {error}`. Every start refreshes the access token
90
+ before expiry (within 60s); token TTL is `<= 3600s`.
91
+ - **Gated reads**: `Authorization: Bearer <access_token>` on `GET {host}/release/manifest`
92
+ (`200 {version, created, files: [{path, sha256, size}], signature}`) and
93
+ `GET {host}/release/<path>` (raw bytes, `404 {error}` when absent).
94
+ - **Manifest signature**: base64 detached Ed25519 over exactly the UTF-8 bytes of canonical
95
+ JSON `{"created":…,"files":[{"path":…,"sha256":…,"size":…}],"version":…}` (keys sorted, no
96
+ whitespace; `created` used raw, never reformatted). Verified with the baked public key
97
+ BEFORE writing anything, through the ported `src/canonical.ts` (verbatim from
98
+ `installer/gate/src/canonical.ts`: UTF-16 code-unit key sort, arrays keep order, `undefined`
99
+ members dropped, non-finite numbers throw, strings `JSON.stringify`-escaped).
100
+ - **Refusals**: `401 {error:"unauthorized"}` -> re-login (stored refresh is retried first,
101
+ then a fresh login); `403 {error:"forbidden"}` -> revoked/allowlist-miss, stops with the
102
+ plain sentence "this install is not allowed for your account — access was revoked or never
103
+ granted." The access token is opaque (server-side HMAC) — stored and relayed, never parsed.
104
+ - **google loginKind**: no `/gate/device/*` endpoints; the loopback PKCE flow
105
+ (`src/login-google.ts`) yields the bearer directly, then the same `/release` reads apply.
106
+ - **Behaviors**: `install` = login if no token -> fetch+verify+unpack under the product dir
107
+ (download to a temp staging dir, verify every size + SHA-256, then swap into `payload/`) ->
108
+ print next step; `update` = same fetch, re-checks manifest; `doctor` = config, token
109
+ presence/expiry, payload version, path wiring; `logout` = wipe tokens AND payload.
110
+
111
+ ## The last mile (`payload.json.entry`) and `$self`
112
+
113
+ `install` ends by reading the payload's `payload.json` `entry` and running it with cwd on the
114
+ payload dir (relative paths, no home-dir quoting hazard). `entry` is either a whitespace-separated
115
+ string or a string array. Its first element may be the token **`$self`**, replaced at run time by
116
+ `process.execPath` — the launcher binary itself. A product that must not require Node on the
117
+ machine therefore declares:
118
+
119
+ ```json
120
+ { "entry": ["$self", "--run", "dist/index.mjs", "install", "--from-payload"] }
121
+ ```
122
+
123
+ the launcher's embedded runtime then runs the payload's bundled ESM. Everything else in
124
+ `defaultRunEntry` is unchanged: when the command cannot run (spawn error, non-zero exit) the
125
+ launcher prints the exact command instead and still exits `0` (the command shown is the `$self`
126
+ entry already resolved).
127
+
128
+ ## SEA: `import()` and the embedded runtime
129
+
130
+ `launcher --run` relies on Node's dynamic `import()` of an on-disk ESM file inside the SEA binary.
131
+ Proven locally on **Node v24.20.0** with `postject`:
132
+
133
+ - A Node 22/24 SEA **main script is always CommonJS**: `mainFormat` exists only in Node >= 25.5,
134
+ and injecting the ESM `dist/launcher.js` as the main fails with
135
+ `SyntaxError: Cannot use import statement outside a module`. `build.mjs` therefore also bundles a
136
+ **CommonJS** `dist/launcher.sea.cjs` (target `esnext`) and bakes THAT as the SEA main;
137
+ `src/module-url.ts` bridges `__filename` (CJS) and `import.meta.url` (ESM).
138
+ - `import()` of an on-disk `.mjs` from a **CommonJS** SEA main works: the probe imported the file,
139
+ the file saw `argv = [execPath, file, ...args]`, and the process exited with the file's code.
140
+ - Node documents "`import()` does not work when `useCodeCache` is true", so the generated sea
141
+ config sets `useCodeCache: false`. (On 24.20 the probe also passed with it `true`; the flag stays
142
+ `false` so the feature never leans on that caveat.)
143
+ - The postject sentinel fuse is a hash **embedded in the Node binary that changes between
144
+ releases** (24.20 carries `NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2`; older docs cited
145
+ `NODE_SEA_FUSE_fce680ab2cc467b6e840016ee2343c1a`). `build.mjs` reads it out of `process.execPath`
146
+ instead of hardcoding it.
147
+ - `disableExperimentalSEAWarning` stays `true`.
148
+
149
+ The reusable workflow `.github/workflows/launcher-build.yml` smoke-tests this end to end: after
150
+ `--version` it runs `--run test/fixtures/run-hello.mjs a b` against the stamped binary and asserts
151
+ the fixture's stdout.
152
+
153
+ ## Tests
154
+
155
+ - Unit: `test/canonical.test.ts` (canonicalization + Ed25519 verify with a
156
+ `generateKeyPairSync('ed25519')` keypair), `test/flows.test.ts` (device-flow polling/backoff,
157
+ refresh, unpack to temp dir, refusal paths), `test/store-google.test.ts` (token store +
158
+ google loopback PKCE against a fake OAuth server), `test/last-mile.test.ts` (the `payload.json`
159
+ entry, the `$self` token, the manual-command fallback, and `--run` argv/exit-code/missing-file).
160
+ - Integration: `test/integration.test.ts` runs the **built `dist/launcher.js`** through a full
161
+ `install -> doctor -> update -> logout` cycle against a localhost fake gate + release server
162
+ (device polling, refresh, signature verification, 403 revocation, payload swap) and spawns
163
+ `--run test/fixtures/run-hello.mjs a b` for a real stdout + exit-code assertion.
164
+
165
+ ## Release CI (SEA binary)
166
+
167
+ `node build.mjs --sea` follows the Node SEA docs exactly:
168
+
169
+ 1. esbuild bundles `dist/launcher.js` (ESM, for `node`) **and** `dist/launcher.sea.cjs`
170
+ (CommonJS, the SEA main — Node 22/24 SEA main scripts are CommonJS-only);
171
+ 2. a `dist/sea-config.generated.json` is written with the baked `product.json` asset,
172
+ `useCodeCache: false` (the `import()` last mile), and `main` = `dist/launcher.sea.cjs`;
173
+ 3. `node --experimental-sea-config <generated>` produces `dist/sea-prep.blob`;
174
+ 4. `process.execPath` is copied to `dist/<platform>-<arch>` and stamped with
175
+ `postject <binary> NODE_SEA_BLOB <blob> --sentinel-fuse <fuse read from the Node binary>`.
176
+ The name is the EXACT download name the bootstrap scripts request
177
+ (`/dl/<product>/<platform>-<arch>`, platform `darwin|win`, arch `arm64|x64`; `win32` maps to `win`,
178
+ and there is no `.exe` suffix — `install.ps1` downloads `win-x64` and renames it to `<product>.exe`).
179
+
180
+ The SEA binary must be stamped on a native runner for its target OS/architecture — the Node SEA
181
+ blob embeds the host Node binary, so a macOS arm64 binary cannot be produced on Linux. The reusable
182
+ workflow `.github/workflows/launcher-build.yml` implements exactly the steps above on the three
183
+ supported lanes (`macos-14` arm64, `macos-13` x64, `windows-latest` x64): `npm ci`, install
184
+ `postject`, `node build.mjs --product-config <cfg> --sea`, smoke `--version` then
185
+ `--run test/fixtures/run-hello.mjs a b`, write the `.sha256`,
186
+ and upload `launcher-<product>-<platform>-<arch>`. It is callable (`workflow_call`) and dispatchable,
187
+ and it is the only supported path to a real SEA binary — nothing is faked in this lane.
@@ -0,0 +1,9 @@
1
+ {
2
+ "$comment": "Copy to dist/product.json (via build.mjs --product-config) and edit per product. publicKey is the base64 of the RAW 32-byte Ed25519 public key.",
3
+ "product": "example-product",
4
+ "host": "https://gate.example.com",
5
+ "loginKind": "github",
6
+ "githubClientId": "EXAMPLE_CLIENT_ID",
7
+ "publicKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
8
+ "binName": "example-product"
9
+ }