@mutmutco/installer-launcher 1.0.1 → 1.2.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.
@@ -0,0 +1,335 @@
1
+ // src/refresh.ts
2
+ import { execFile } from "node:child_process";
3
+ import { chmodSync, existsSync as existsSync2, lstatSync, readdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname as dirname2, join as join3 } from "node:path";
6
+
7
+ // src/acquisition.ts
8
+ import { createHash as createHash2, randomUUID } from "node:crypto";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, realpathSync } from "node:fs";
10
+ import { join as join2, resolve, relative, isAbsolute, dirname } from "node:path";
11
+
12
+ // src/download.ts
13
+ async function readDownload(response, onProgress, expectedSize) {
14
+ const length = expectedSize ?? Number(response.headers.get("content-length"));
15
+ const total = Number.isSafeInteger(length) && length > 0 ? length : void 0;
16
+ let done = 0;
17
+ const report = () => onProgress?.({ done, ...total === void 0 ? {} : { total } });
18
+ report();
19
+ const chunks = [];
20
+ if (response.body) {
21
+ for await (const chunk of response.body) {
22
+ chunks.push(chunk);
23
+ done += chunk.length;
24
+ report();
25
+ }
26
+ }
27
+ return Buffer.concat(chunks);
28
+ }
29
+
30
+ // src/store.ts
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ function defaultProductDir(product) {
34
+ if (process.platform === "win32") {
35
+ const base = process.env.LOCALAPPDATA ?? join(tmpdir(), "launcher-fallback");
36
+ return join(base, product);
37
+ }
38
+ const home = process.env.HOME ?? tmpdir();
39
+ return join(home, `.${product}`);
40
+ }
41
+
42
+ // src/payload.ts
43
+ import { createHash, createPublicKey, verify } from "node:crypto";
44
+
45
+ // src/canonical.ts
46
+ function canonicalJson(value) {
47
+ return encode(value);
48
+ }
49
+ function encode(value) {
50
+ if (value === null) return "null";
51
+ if (typeof value === "string") return JSON.stringify(value);
52
+ if (typeof value === "boolean") return value ? "true" : "false";
53
+ if (typeof value === "number") {
54
+ if (!Number.isFinite(value)) {
55
+ throw new TypeError("canonicalJson: cannot encode a non-finite number");
56
+ }
57
+ return JSON.stringify(value);
58
+ }
59
+ if (Array.isArray(value)) {
60
+ return `[${value.map((entry) => encode(entry)).join(",")}]`;
61
+ }
62
+ if (typeof value === "object") {
63
+ const record = value;
64
+ const keys = Object.keys(record).filter((key) => record[key] !== void 0).sort();
65
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(",")}}`;
66
+ }
67
+ throw new TypeError(`canonicalJson: cannot encode a value of type ${typeof value}`);
68
+ }
69
+
70
+ // src/config.ts
71
+ import { getAsset } from "node:sea";
72
+ function publicKeyBytes(config) {
73
+ const raw = Buffer.from(config.publicKey, "base64");
74
+ if (raw.length !== 32) {
75
+ throw new Error(`product config for "${config.product}" has a bad publicKey (want 32 raw bytes)`);
76
+ }
77
+ return raw;
78
+ }
79
+
80
+ // src/payload.ts
81
+ function canonicalManifestBytes(manifest) {
82
+ return Buffer.from(
83
+ canonicalJson({
84
+ created: manifest.created,
85
+ files: manifest.files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size })),
86
+ version: manifest.version
87
+ }),
88
+ "utf8"
89
+ );
90
+ }
91
+ function ed25519PublicKey(config) {
92
+ const raw = publicKeyBytes(config);
93
+ return createPublicKey({
94
+ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
95
+ format: "jwk"
96
+ });
97
+ }
98
+ function verifyManifest(manifest, signature, config) {
99
+ try {
100
+ const signatureBytes = Buffer.from(signature, "base64");
101
+ if (signatureBytes.length === 0) return false;
102
+ return verify(null, canonicalManifestBytes(manifest), ed25519PublicKey(config), signatureBytes);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+ function verifyFileBytes(entry, bytes) {
108
+ if (entry.size !== bytes.length) return false;
109
+ return createHash("sha256").update(bytes).digest("hex") === entry.sha256.toLowerCase();
110
+ }
111
+ function parseManifest(json) {
112
+ if (typeof json !== "object" || json === null) throw new Error("the release manifest is not an object");
113
+ const record = json;
114
+ if (typeof record.version !== "string" || !record.version) throw new Error("the release manifest has no version");
115
+ if (typeof record.created !== "string" || !record.created) throw new Error("the release manifest has no created stamp");
116
+ if (!Array.isArray(record.files)) throw new Error("the release manifest has no files list");
117
+ if (typeof record.signature !== "string" || !record.signature) {
118
+ throw new Error("the release manifest is unsigned");
119
+ }
120
+ const files = record.files.map((entry) => {
121
+ const file = entry;
122
+ if (typeof file.path !== "string" || typeof file.sha256 !== "string" || typeof file.size !== "number") {
123
+ throw new Error("the release manifest lists a malformed file");
124
+ }
125
+ const safe = safeManifestPath(file.path);
126
+ if (safe === null) throw new Error(`the release manifest lists an unsafe path: ${file.path}`);
127
+ return { path: safe, sha256: file.sha256, size: file.size };
128
+ });
129
+ return { version: record.version, created: record.created, files, signature: record.signature };
130
+ }
131
+ function safeManifestPath(rawPath) {
132
+ if (rawPath.length === 0 || rawPath.includes("\0") || rawPath.includes("\\")) return null;
133
+ if (rawPath.startsWith("/")) return null;
134
+ const segments = rawPath.split("/");
135
+ for (const segment of segments) {
136
+ if (segment === "" || segment === "." || segment === "..") return null;
137
+ }
138
+ return segments.join("/");
139
+ }
140
+
141
+ // src/acquisition.ts
142
+ function lockInstallation(dir) {
143
+ mkdirSync(dir, { recursive: true });
144
+ const path = join2(dir, "installation.lock");
145
+ const owner = `${process.pid}:${randomUUID()}`;
146
+ try {
147
+ writeFileSync(path, owner, { flag: "wx", mode: 384 });
148
+ } catch (error) {
149
+ if (error.code !== "EEXIST") throw error;
150
+ const recovery = `${path}.recovery`;
151
+ try {
152
+ writeFileSync(recovery, owner, { flag: "wx", mode: 384, flush: true });
153
+ } catch {
154
+ throw new Error("installation lock recovery is already pending");
155
+ }
156
+ try {
157
+ const previous = readFileSync(path, "utf8");
158
+ const pid = Number(previous.split(":")[0]);
159
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("installation lock is malformed; recovery required");
160
+ try {
161
+ process.kill(pid, 0);
162
+ throw new Error("another installer is running");
163
+ } catch (probe) {
164
+ if (probe.code !== "ESRCH") throw probe;
165
+ }
166
+ unlinkSync(path);
167
+ writeFileSync(path, owner, { flag: "wx", mode: 384, flush: true });
168
+ } finally {
169
+ unlinkSync(recovery);
170
+ }
171
+ }
172
+ let released = false;
173
+ return () => {
174
+ if (!released && readFileSync(path, "utf8") === owner) unlinkSync(path);
175
+ released = true;
176
+ };
177
+ }
178
+
179
+ // src/refresh.ts
180
+ var LAUNCHER_RECORD_FILE = "launcher.json";
181
+ var RECORD_TIMEOUT_MS = 1e4;
182
+ function platformKey(platform, arch) {
183
+ if (platform === "win32") return arch === "x64" || arch === "arm64" ? `win-${arch}` : null;
184
+ return platform === "darwin" && arch === "arm64" ? "darwin-arm64" : null;
185
+ }
186
+ function verifierFor(target) {
187
+ return { product: target.product, host: target.host, loginKind: "github", publicKey: target.key, binName: target.bin };
188
+ }
189
+ async function fetchRecord(target, options) {
190
+ let json;
191
+ try {
192
+ const response = await (options.fetch ?? fetch)(
193
+ `${target.host.replace(/\/+$/u, "")}/release/launcher`,
194
+ { signal: AbortSignal.timeout(options.timeoutMs ?? RECORD_TIMEOUT_MS) }
195
+ );
196
+ if (!response.ok) return null;
197
+ json = await response.json();
198
+ } catch {
199
+ return null;
200
+ }
201
+ let record;
202
+ try {
203
+ record = parseManifest(json);
204
+ } catch {
205
+ return null;
206
+ }
207
+ if (!verifyManifest(record, record.signature, verifierFor(target))) return "bad-signature";
208
+ return { version: record.version, files: record.files };
209
+ }
210
+ async function readLauncherRecord(target, options = {}) {
211
+ const record = await fetchRecord(target, options);
212
+ return record === "bad-signature" ? null : record;
213
+ }
214
+ function compareVersions(a, b) {
215
+ const split = (v) => {
216
+ const [core, pre = ""] = v.split("-", 2);
217
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
218
+ };
219
+ const x = split(a);
220
+ const y = split(b);
221
+ for (let i = 0; i < 3; i++) if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0)) return (x.nums[i] ?? 0) > (y.nums[i] ?? 0) ? 1 : -1;
222
+ if (x.pre === y.pre) return 0;
223
+ if (!x.pre) return 1;
224
+ if (!y.pre) return -1;
225
+ return x.pre > y.pre ? 1 : -1;
226
+ }
227
+ function recordedPath(productDir) {
228
+ try {
229
+ const saved = JSON.parse(readFileSync2(join3(productDir, LAUNCHER_RECORD_FILE), "utf8"));
230
+ return typeof saved.path === "string" && saved.path ? saved.path : null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+ function installedPath(target, productDir, platform) {
236
+ return platform === "win32" ? join3(productDir, "bin", `${target.bin}.exe`) : join3(homedir(), ".local", "bin", target.bin);
237
+ }
238
+ function parseLauncherVersion(stdout, bin) {
239
+ const text = String(stdout).trim();
240
+ if (!text.startsWith(`${bin} `)) return null;
241
+ return text.match(/launcher (\d+\.\d+\.\d+[^\s)]*)/u)?.[1] ?? null;
242
+ }
243
+ function runVersion(path, bin) {
244
+ return new Promise((resolve2) => {
245
+ execFile(path, ["--version"], { timeout: 3e4, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
246
+ resolve2(error ? null : parseLauncherVersion(stdout, bin));
247
+ });
248
+ });
249
+ }
250
+ function removeStale(file) {
251
+ const prefix = `${basename(file)}.old-`;
252
+ let names;
253
+ try {
254
+ names = readdirSync(dirname2(file));
255
+ } catch {
256
+ return;
257
+ }
258
+ for (const name of names) {
259
+ if (!name.startsWith(prefix)) continue;
260
+ try {
261
+ rmSync(join3(dirname2(file), name), { force: true });
262
+ } catch {
263
+ }
264
+ }
265
+ }
266
+ async function refreshLauncher(target, options = {}) {
267
+ const started = Date.now();
268
+ const platform = options.platform ?? process.platform;
269
+ const key = platformKey(platform, options.arch ?? process.arch);
270
+ if (!key) return null;
271
+ const productDir = options.productDir ?? defaultProductDir(target.product);
272
+ const recorded = recordedPath(productDir);
273
+ const file = options.path ?? (recorded && existsSync2(recorded) ? recorded : installedPath(target, productDir, platform));
274
+ if (!existsSync2(file) || lstatSync(file).isSymbolicLink()) return null;
275
+ if (!options.dryRun) removeStale(file);
276
+ const from = await (options.readVersion ?? ((path) => runVersion(path, target.bin)))(file);
277
+ if (!from) return null;
278
+ const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail, durationMs: Date.now() - started });
279
+ const record = await fetchRecord(target, options);
280
+ if (record === "bad-signature") return arm("fail", from, "The launcher record failed its signature check.");
281
+ const entry = record?.files.find((candidate) => candidate.path === key);
282
+ if (!record || !entry) return arm("skip", from, "No launcher update is offered yet.");
283
+ if (compareVersions(record.version, from) <= 0) return arm("ok", from, "");
284
+ if (options.dryRun) return arm("ok", record.version, "");
285
+ let unlock;
286
+ if (!options.lockHeld) {
287
+ try {
288
+ unlock = lockInstallation(productDir);
289
+ } catch {
290
+ return arm("defer", from, "Another install is running; the launcher updates next time.");
291
+ }
292
+ }
293
+ const next = `${file}.new-${process.pid}`;
294
+ let moved;
295
+ try {
296
+ let bytes;
297
+ try {
298
+ const response = await (options.fetch ?? fetch)(
299
+ `${(target.dl ?? target.host).replace(/\/+$/u, "")}/dl/${target.product}/${key}`,
300
+ { signal: AbortSignal.timeout(3e5) }
301
+ );
302
+ if (!response.ok) return arm("defer", from, "The launcher download did not complete; it retries next time.");
303
+ bytes = await readDownload(response, void 0, entry.size);
304
+ } catch {
305
+ return arm("defer", from, "The launcher download did not complete; it retries next time.");
306
+ }
307
+ if (!verifyFileBytes(entry, bytes)) return arm("fail", from, "The downloaded launcher failed its checksum.");
308
+ try {
309
+ writeFileSync2(next, bytes, { mode: 493 });
310
+ if (platform !== "win32") chmodSync(next, 493);
311
+ if (platform === "win32") {
312
+ moved = `${file}.old-${process.pid}-${Date.now()}`;
313
+ renameSync(file, moved);
314
+ }
315
+ renameSync(next, file);
316
+ } catch {
317
+ try {
318
+ if (moved && !existsSync2(file)) renameSync(moved, file);
319
+ } catch {
320
+ }
321
+ rmSync(next, { force: true });
322
+ return arm("fail", from, "The launcher could not be replaced.");
323
+ }
324
+ return arm("ok", record.version, "");
325
+ } finally {
326
+ unlock?.();
327
+ }
328
+ }
329
+ export {
330
+ LAUNCHER_RECORD_FILE,
331
+ compareVersions,
332
+ parseLauncherVersion,
333
+ readLauncherRecord,
334
+ refreshLauncher
335
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-launcher",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Single-executable (SEA) launcher: sign-in, gated payload fetch, self-update. One copy ships per product.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -13,7 +13,8 @@
13
13
  "main": "dist/launcher.js",
14
14
  "exports": {
15
15
  ".": "./dist/launcher.js",
16
- "./acquisition-required": "./dist/acquisition-required.mjs"
16
+ "./acquisition-required": "./dist/acquisition-required.mjs",
17
+ "./refresh": "./dist/refresh.js"
17
18
  },
18
19
  "files": [
19
20
  "dist",