@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 +187 -0
- package/config/product.template.json +9 -0
- package/dist/launcher.js +825 -0
- package/dist/launcher.sea.cjs +855 -0
- package/dist/product.json +9 -0
- package/package.json +36 -0
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
|
+
}
|