@mutmutco/installer-launcher 0.1.13 → 0.1.15

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/dist/launcher.js CHANGED
@@ -1,18 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { randomUUID as randomUUID4 } from "node:crypto";
5
6
 
6
7
  // src/acquisition.ts
7
- import { randomUUID as randomUUID3 } from "node:crypto";
8
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, realpathSync as realpathSync2 } from "node:fs";
9
- import { join as join3, resolve, relative as relative2, isAbsolute as isAbsolute2, dirname } from "node:path";
8
+ import { createHash as createHash3, randomUUID as randomUUID3 } from "node:crypto";
9
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, unlinkSync, realpathSync as realpathSync2 } from "node:fs";
10
+ import { join as join4, resolve, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname2 } from "node:path";
10
11
 
11
12
  // src/runtime.ts
12
13
  import { createHash, randomUUID } from "node:crypto";
13
14
  import { spawnSync } from "node:child_process";
14
15
  import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync, renameSync, rmSync, realpathSync } from "node:fs";
15
16
  import { join, relative, isAbsolute } from "node:path";
17
+
18
+ // src/download.ts
19
+ async function readDownload(response, onProgress, expectedSize) {
20
+ const length = expectedSize ?? Number(response.headers.get("content-length"));
21
+ const total = Number.isSafeInteger(length) && length > 0 ? length : void 0;
22
+ let done = 0;
23
+ const report = () => onProgress?.({ done, ...total === void 0 ? {} : { total } });
24
+ report();
25
+ const chunks = [];
26
+ if (response.body) {
27
+ for await (const chunk of response.body) {
28
+ chunks.push(chunk);
29
+ done += chunk.length;
30
+ report();
31
+ }
32
+ }
33
+ return Buffer.concat(chunks);
34
+ }
35
+
36
+ // src/runtime.ts
16
37
  var NODE_VERSION = "24.20.0";
17
38
  var NPM_VERSION = "12.0.2";
18
39
  var ARTIFACTS = {
@@ -23,10 +44,10 @@ var ARTIFACTS = {
23
44
  "linux-arm64": ["node-v24.20.0-linux-arm64.tar.gz", "3515603e2487879a39bc75716f1a2affd027500c64ba50e845cf72cb33219013"]
24
45
  };
25
46
  var NPM_INTEGRITY = "uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==";
26
- async function verifiedDownload(url, path, algorithm, digest, fetchImpl) {
47
+ async function verifiedDownload(url, path, algorithm, digest, fetchImpl, onProgress) {
27
48
  const response = await fetchImpl(url);
28
49
  if (!response.ok) throw new Error(`runtime download failed (${response.status})`);
29
- const bytes = Buffer.from(await response.arrayBuffer());
50
+ const bytes = await readDownload(response, onProgress);
30
51
  if (createHash(algorithm).update(bytes).digest(algorithm === "sha512" ? "base64" : "hex") !== digest) throw new Error("runtime archive checksum mismatch");
31
52
  writeFileSync(path, bytes);
32
53
  }
@@ -35,7 +56,7 @@ function command(executable, args) {
35
56
  if (result.error || result.status !== 0) throw new Error(`runtime preparation failed: ${result.error?.message ?? result.stderr.trim()}`);
36
57
  return result.stdout.trim();
37
58
  }
38
- async function acquireRuntime(dir, fetchImpl = fetch) {
59
+ async function acquireRuntime(dir, fetchImpl = fetch, onProgress) {
39
60
  const platform = `${process.platform}-${process.arch}`;
40
61
  const artifact = ARTIFACTS[platform];
41
62
  if (!artifact) throw new Error(`managed runtime does not support ${platform}`);
@@ -53,13 +74,20 @@ async function acquireRuntime(dir, fetchImpl = fetch) {
53
74
  }
54
75
  const root = mkdtempSync(join(runtimeDir, "runtime-"));
55
76
  const archive = join(root, artifact[0]);
56
- await verifiedDownload(`https://nodejs.org/dist/v${NODE_VERSION}/${artifact[0]}`, archive, "sha256", artifact[1], fetchImpl);
77
+ let downloaded = 0;
78
+ let currentBytes = 0;
79
+ const progress = ({ done }) => {
80
+ currentBytes = done;
81
+ onProgress?.({ done: downloaded + done });
82
+ };
83
+ await verifiedDownload(`https://nodejs.org/dist/v${NODE_VERSION}/${artifact[0]}`, archive, "sha256", artifact[1], fetchImpl, progress);
84
+ downloaded += currentBytes;
57
85
  const tar = process.platform === "win32" ? join(process.env.SystemRoot ?? "C:/Windows", "System32", "tar.exe") : "/usr/bin/tar";
58
86
  command(tar, ["-xf", archive, "-C", root]);
59
87
  const unpacked = join(root, artifact[0].replace(/\.(zip|tar\.gz)$/, ""));
60
88
  const node = join(unpacked, process.platform === "win32" ? "node.exe" : "bin/node");
61
89
  const npmArchive = join(root, "npm.tgz");
62
- await verifiedDownload(`https://registry.npmjs.org/npm/-/npm-${NPM_VERSION}.tgz`, npmArchive, "sha512", NPM_INTEGRITY, fetchImpl);
90
+ await verifiedDownload(`https://registry.npmjs.org/npm/-/npm-${NPM_VERSION}.tgz`, npmArchive, "sha512", NPM_INTEGRITY, fetchImpl, progress);
63
91
  const npmRoot = join(root, "npm");
64
92
  mkdirSync(npmRoot);
65
93
  command(tar, ["-xf", npmArchive, "-C", npmRoot]);
@@ -164,7 +192,315 @@ function wipeProductDir(dir) {
164
192
  rmSync2(payload, { force: true, recursive: true });
165
193
  }
166
194
 
195
+ // src/payload.ts
196
+ import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
197
+ import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, renameSync as renameSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
198
+ import { tmpdir as tmpdir2 } from "node:os";
199
+ import { dirname, join as join3 } from "node:path";
200
+
201
+ // src/canonical.ts
202
+ function canonicalJson(value) {
203
+ return encode(value);
204
+ }
205
+ function encode(value) {
206
+ if (value === null) return "null";
207
+ if (typeof value === "string") return JSON.stringify(value);
208
+ if (typeof value === "boolean") return value ? "true" : "false";
209
+ if (typeof value === "number") {
210
+ if (!Number.isFinite(value)) {
211
+ throw new TypeError("canonicalJson: cannot encode a non-finite number");
212
+ }
213
+ return JSON.stringify(value);
214
+ }
215
+ if (Array.isArray(value)) {
216
+ return `[${value.map((entry2) => encode(entry2)).join(",")}]`;
217
+ }
218
+ if (typeof value === "object") {
219
+ const record = value;
220
+ const keys = Object.keys(record).filter((key) => record[key] !== void 0).sort();
221
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(",")}}`;
222
+ }
223
+ throw new TypeError(`canonicalJson: cannot encode a value of type ${typeof value}`);
224
+ }
225
+
226
+ // src/config.ts
227
+ import { readFileSync as readFileSync3 } from "node:fs";
228
+ import { getAsset } from "node:sea";
229
+
230
+ // src/module-url.ts
231
+ import { pathToFileURL } from "node:url";
232
+ function moduleUrl() {
233
+ if (typeof __filename === "string") return pathToFileURL(__filename).href;
234
+ return import.meta.url;
235
+ }
236
+
237
+ // src/config.ts
238
+ function publicKeyBytes(config) {
239
+ const raw = Buffer.from(config.publicKey, "base64");
240
+ if (raw.length !== 32) {
241
+ throw new Error(`product config for "${config.product}" has a bad publicKey (want 32 raw bytes)`);
242
+ }
243
+ return raw;
244
+ }
245
+ function loadProductConfig(options = {}) {
246
+ const explicit = options.configPath ?? process.env.LAUNCHER_CONFIG;
247
+ const devFallback = new URL("../config/product.template.json", moduleUrl());
248
+ if (explicit) {
249
+ return parseProductConfig(readFileSync3(explicit, "utf8"));
250
+ }
251
+ try {
252
+ return parseProductConfig(readFileSync3(devFallback, "utf8"));
253
+ } catch {
254
+ }
255
+ try {
256
+ const asset = getAsset("product.json", "utf8");
257
+ if (typeof asset === "string") return parseProductConfig(asset);
258
+ } catch {
259
+ }
260
+ throw new Error(
261
+ "no product config found (pass --config <path>, set LAUNCHER_CONFIG, or bake product.json into the SEA binary)"
262
+ );
263
+ }
264
+ function parseProductConfig(text) {
265
+ let data;
266
+ try {
267
+ data = JSON.parse(text);
268
+ } catch {
269
+ throw new Error("product config is not valid JSON");
270
+ }
271
+ if (typeof data !== "object" || data === null) throw new Error("product config must be an object");
272
+ const record = data;
273
+ const product = field(record, "product");
274
+ const host = field(record, "host").replace(/\/+$/, "");
275
+ const loginKind = field(record, "loginKind");
276
+ const binName = field(record, "binName");
277
+ if (!product || !host || !binName) throw new Error("product config needs product, host and binName");
278
+ if (loginKind !== "github" && loginKind !== "google") {
279
+ throw new Error('product config loginKind must be "github" or "google"');
280
+ }
281
+ const config = { product, host, loginKind, publicKey: field(record, "publicKey"), binName };
282
+ if (!config.publicKey) throw new Error("product config needs publicKey (base64 raw Ed25519)");
283
+ publicKeyBytes(config);
284
+ if (loginKind === "github") {
285
+ const clientId = record.githubClientId;
286
+ if (typeof clientId !== "string" || clientId.length === 0) {
287
+ throw new Error("product config needs githubClientId for the github loginKind");
288
+ }
289
+ config.githubClientId = clientId;
290
+ } else if (typeof record.githubClientId === "string") {
291
+ config.githubClientId = record.githubClientId;
292
+ }
293
+ try {
294
+ const url = new URL(host);
295
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error();
296
+ } catch {
297
+ throw new Error("product config host must be an http(s) URL");
298
+ }
299
+ return config;
300
+ }
301
+ function field(record, key) {
302
+ const value = record[key];
303
+ return typeof value === "string" ? value : "";
304
+ }
305
+
306
+ // src/payload.ts
307
+ var NeedsLoginError = class extends Error {
308
+ constructor() {
309
+ super("signed out \u2014 run login first");
310
+ this.name = "NeedsLoginError";
311
+ }
312
+ };
313
+ var ForbiddenError = class extends Error {
314
+ constructor() {
315
+ super("this install is not allowed for your account \u2014 access was revoked or never granted.");
316
+ this.name = "ForbiddenError";
317
+ }
318
+ };
319
+ function canonicalManifestBytes(manifest) {
320
+ return Buffer.from(
321
+ canonicalJson({
322
+ created: manifest.created,
323
+ files: manifest.files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size })),
324
+ version: manifest.version
325
+ }),
326
+ "utf8"
327
+ );
328
+ }
329
+ function ed25519PublicKey(config) {
330
+ const raw = publicKeyBytes(config);
331
+ return createPublicKey({
332
+ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
333
+ format: "jwk"
334
+ });
335
+ }
336
+ function verifyManifest(manifest, signature, config) {
337
+ try {
338
+ const signatureBytes = Buffer.from(signature, "base64");
339
+ if (signatureBytes.length === 0) return false;
340
+ return verify(null, canonicalManifestBytes(manifest), ed25519PublicKey(config), signatureBytes);
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+ function verifyFileBytes(entry2, bytes) {
346
+ if (entry2.size !== bytes.length) return false;
347
+ return createHash2("sha256").update(bytes).digest("hex") === entry2.sha256.toLowerCase();
348
+ }
349
+ async function readErrorCode(response) {
350
+ try {
351
+ const data = await response.json();
352
+ return typeof data.error === "string" ? data.error : "";
353
+ } catch {
354
+ return "";
355
+ }
356
+ }
357
+ function throwForStatus(status, errorCode) {
358
+ if (status === 401) throw new NeedsLoginError();
359
+ if (status === 403) throw new ForbiddenError();
360
+ throw new Error(
361
+ errorCode ? `the release server refused the request (${status}: ${errorCode})` : `the release server refused the request (${status})`
362
+ );
363
+ }
364
+ async function getJson(url, accessToken, fetchImpl) {
365
+ const response = await fetchImpl(url, { headers: { authorization: `Bearer ${accessToken}` } });
366
+ if (!response.ok) throwForStatus(response.status, await readErrorCode(response));
367
+ return { status: response.status, json: await response.json() };
368
+ }
369
+ function parseManifest(json) {
370
+ if (typeof json !== "object" || json === null) throw new Error("the release manifest is not an object");
371
+ const record = json;
372
+ if (typeof record.version !== "string" || !record.version) throw new Error("the release manifest has no version");
373
+ if (typeof record.created !== "string" || !record.created) throw new Error("the release manifest has no created stamp");
374
+ if (!Array.isArray(record.files)) throw new Error("the release manifest has no files list");
375
+ if (typeof record.signature !== "string" || !record.signature) {
376
+ throw new Error("the release manifest is unsigned");
377
+ }
378
+ const files = record.files.map((entry2) => {
379
+ const file = entry2;
380
+ if (typeof file.path !== "string" || typeof file.sha256 !== "string" || typeof file.size !== "number") {
381
+ throw new Error("the release manifest lists a malformed file");
382
+ }
383
+ const safe = safeManifestPath(file.path);
384
+ if (safe === null) throw new Error(`the release manifest lists an unsafe path: ${file.path}`);
385
+ return { path: safe, sha256: file.sha256, size: file.size };
386
+ });
387
+ return { version: record.version, created: record.created, files, signature: record.signature };
388
+ }
389
+ function safeManifestPath(rawPath) {
390
+ if (rawPath.length === 0 || rawPath.includes("\0") || rawPath.includes("\\")) return null;
391
+ if (rawPath.startsWith("/")) return null;
392
+ const segments = rawPath.split("/");
393
+ for (const segment of segments) {
394
+ if (segment === "" || segment === "." || segment === "..") return null;
395
+ }
396
+ return segments.join("/");
397
+ }
398
+ async function fetchVerifiedManifest(config, accessToken, fetchImpl = fetch) {
399
+ const { json } = await getJson(`${config.host}/release/manifest`, accessToken, fetchImpl);
400
+ const manifest = parseManifest(json);
401
+ if (!verifyManifest(manifest, manifest.signature, config)) {
402
+ throw new Error("the release manifest signature is invalid \u2014 refusing to install anything.");
403
+ }
404
+ return manifest;
405
+ }
406
+ async function fetchReleaseVersion(config, fetchImpl = fetch) {
407
+ try {
408
+ const response = await fetchImpl(`${config.host}/release/version`);
409
+ if (!response.ok) return null;
410
+ const json = await response.json();
411
+ const version = typeof json?.version === "string" ? json.version.trim() : "";
412
+ return version || null;
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+ async function fetchFileBytes(config, accessToken, path, fetchImpl, onProgress, expectedSize) {
418
+ const encoded = path.split("/").map(encodeURIComponent).join("/");
419
+ const response = await fetchImpl(`${config.host}/release/${encoded}`, {
420
+ headers: { authorization: `Bearer ${accessToken}` }
421
+ });
422
+ if (!response.ok) {
423
+ if (response.status === 404) throw new Error(`the release server has no file at ${path}`);
424
+ throwForStatus(response.status, await readErrorCode(response));
425
+ }
426
+ return readDownload(response, onProgress, expectedSize);
427
+ }
428
+ var VERSION_CACHE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
429
+ function versionCacheRoot(dir, version) {
430
+ return VERSION_CACHE_RE.test(version) ? join3(dir, "payload-cache", version) : "";
431
+ }
432
+ function cachedPayloadForVersion(dir, version) {
433
+ const root = versionCacheRoot(dir, version);
434
+ if (!root) return null;
435
+ const payload = join3(root, "payload");
436
+ return existsSync4(payload) ? payload : null;
437
+ }
438
+ function rememberPayloadVersion(dir, version, payload) {
439
+ try {
440
+ const root = versionCacheRoot(dir, version);
441
+ if (!root) return;
442
+ const dest = join3(root, "payload");
443
+ if (existsSync4(dest)) return;
444
+ mkdirSync3(root, { recursive: true });
445
+ cpSync(payload, dest, { recursive: true });
446
+ } catch {
447
+ }
448
+ }
449
+ async function downloadAndUnpack(config, dir, manifest, accessToken, options = {}) {
450
+ const target = join3(dir, "payload");
451
+ const cached = options.cacheDir ? cachedPayloadForVersion(options.cacheDir, manifest.version) : null;
452
+ if (cached) {
453
+ mkdirSync3(dir, { recursive: true });
454
+ rmSync3(target, { force: true, recursive: true });
455
+ cpSync(cached, target, { recursive: true });
456
+ return manifest.version;
457
+ }
458
+ const fetchImpl = options.fetchImpl ?? fetch;
459
+ const staging = join3(tmpdir2(), `launcher-${config.product}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
460
+ mkdirSync3(staging, { recursive: true });
461
+ try {
462
+ const total = manifest.files.reduce((sum, entry2) => sum + entry2.size, 0);
463
+ let downloaded = 0;
464
+ for (const entry2 of manifest.files) {
465
+ const bytes = await fetchFileBytes(config, accessToken, entry2.path, fetchImpl, (progress) => options.onProgress?.({ done: downloaded + progress.done, total }), entry2.size);
466
+ downloaded += bytes.length;
467
+ if (!verifyFileBytes(entry2, bytes)) {
468
+ throw new Error(`file ${entry2.path} failed its checksum \u2014 refusing to install anything.`);
469
+ }
470
+ const dest = join3(staging, entry2.path);
471
+ mkdirSync3(dirname(dest), { recursive: true });
472
+ writeFileSync3(dest, bytes);
473
+ }
474
+ mkdirSync3(dir, { recursive: true });
475
+ rmSync3(target, { force: true, recursive: true });
476
+ renameSync3(staging, target);
477
+ } catch (error) {
478
+ rmSync3(staging, { force: true, recursive: true });
479
+ throw error;
480
+ }
481
+ if (options.cacheDir) rememberPayloadVersion(options.cacheDir, manifest.version, target);
482
+ return manifest.version;
483
+ }
484
+
167
485
  // src/acquisition.ts
486
+ function reusableCandidate(dir, manifest) {
487
+ try {
488
+ const saved = JSON.parse(readFileSync4(join4(dir, "acquisition-cache.json"), "utf8"));
489
+ if (saved.signature !== manifest.signature) return null;
490
+ const root = candidateRoot(dir, saved.candidate);
491
+ if (!existsSync5(join4(root, "acquired.json"))) return null;
492
+ for (const file of manifest.files) {
493
+ if (!safePath(file.path) || !verifyFileBytes(file, readFileSync4(join4(root, "payload", file.path)))) return null;
494
+ }
495
+ return saved.candidate;
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+ function rememberCandidate(dir, candidate, manifest) {
501
+ candidateRoot(dir, candidate);
502
+ writeFileSync4(join4(dir, "acquisition-cache.json"), JSON.stringify({ candidate, signature: manifest.signature }), { mode: 384 });
503
+ }
168
504
  function safePath(value) {
169
505
  return typeof value === "string" && value.length > 0 && !/[\\:\0]/.test(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
170
506
  }
@@ -172,7 +508,7 @@ function argumentsValid(value) {
172
508
  return Array.isArray(value) && value.every((arg) => typeof arg === "string" && !arg.includes("\0") && (!arg.includes("$") || arg === "$prefix" || arg === "$version"));
173
509
  }
174
510
  function readAcquisition(payload) {
175
- const metadata = JSON.parse(readFileSync3(join3(payload, "payload.json"), "utf8"));
511
+ const metadata = JSON.parse(readFileSync4(join4(payload, "payload.json"), "utf8"));
176
512
  if (metadata.acquisition === void 0) return null;
177
513
  const a = metadata.acquisition;
178
514
  if (!a || a.schema !== 1 || !/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(a.package) || !safePath(a.archive) || !a.archive.endsWith(".tgz") || !safePath(a.convergeEntry) || !safePath(a.rollbackEntry) || !safePath(a.runEntry) || !argumentsValid(a.convergeArgs) || !argumentsValid(a.rollbackArgs) || !argumentsValid(a.repairArgs) || !Array.isArray(a.platforms) || !a.platforms.length || a.platforms.some((p) => !["win32-x64", "win32-arm64", "darwin-arm64", "linux-x64", "linux-arm64"].includes(p))) {
@@ -188,25 +524,25 @@ function runtimeEnvironment(node, inherited, platform = process.platform) {
188
524
  current = (keys.length ? env[keys[0]] : "") ?? "";
189
525
  for (const key of keys) delete env[key];
190
526
  }
191
- env.PATH = `${dirname(node)}${platform === "win32" ? ";" : ":"}${current}`;
527
+ env.PATH = `${dirname2(node)}${platform === "win32" ? ";" : ":"}${current}`;
192
528
  return env;
193
529
  }
194
530
  function lockInstallation(dir) {
195
- mkdirSync3(dir, { recursive: true });
196
- const path = join3(dir, "installation.lock");
531
+ mkdirSync4(dir, { recursive: true });
532
+ const path = join4(dir, "installation.lock");
197
533
  const owner = `${process.pid}:${randomUUID3()}`;
198
534
  try {
199
- writeFileSync3(path, owner, { flag: "wx", mode: 384 });
535
+ writeFileSync4(path, owner, { flag: "wx", mode: 384 });
200
536
  } catch (error) {
201
537
  if (error.code !== "EEXIST") throw error;
202
538
  const recovery = `${path}.recovery`;
203
539
  try {
204
- writeFileSync3(recovery, owner, { flag: "wx", mode: 384, flush: true });
540
+ writeFileSync4(recovery, owner, { flag: "wx", mode: 384, flush: true });
205
541
  } catch {
206
542
  throw new Error("installation lock recovery is already pending");
207
543
  }
208
544
  try {
209
- const previous = readFileSync3(path, "utf8");
545
+ const previous = readFileSync4(path, "utf8");
210
546
  const pid = Number(previous.split(":")[0]);
211
547
  if (!Number.isInteger(pid) || pid <= 0) throw new Error("installation lock is malformed; recovery required");
212
548
  try {
@@ -216,29 +552,29 @@ function lockInstallation(dir) {
216
552
  if (probe.code !== "ESRCH") throw probe;
217
553
  }
218
554
  unlinkSync(path);
219
- writeFileSync3(path, owner, { flag: "wx", mode: 384, flush: true });
555
+ writeFileSync4(path, owner, { flag: "wx", mode: 384, flush: true });
220
556
  } finally {
221
557
  unlinkSync(recovery);
222
558
  }
223
559
  }
224
560
  let released = false;
225
561
  return () => {
226
- if (!released && readFileSync3(path, "utf8") === owner) unlinkSync(path);
562
+ if (!released && readFileSync4(path, "utf8") === owner) unlinkSync(path);
227
563
  released = true;
228
564
  };
229
565
  }
230
566
  function pendingPath(dir) {
231
- return join3(dir, "acquisition-pending.json");
567
+ return join4(dir, "acquisition-pending.json");
232
568
  }
233
569
  function candidateRoot(dir, candidate) {
234
570
  if (!/^[a-zA-Z0-9_-]+$/.test(candidate)) throw new Error("invalid acquisition candidate");
235
- return join3(dir, "candidates", candidate);
571
+ return join4(dir, "candidates", candidate);
236
572
  }
237
573
  function packageRoot(prefix, acquisition) {
238
- return join3(prefix, "node_modules", acquisition.package);
574
+ return join4(prefix, "node_modules", acquisition.package);
239
575
  }
240
576
  function entry(root, path) {
241
- const actual = realpathSync2(join3(root, path));
577
+ const actual = realpathSync2(join4(root, path));
242
578
  const rel = relative2(realpathSync2(root), actual);
243
579
  if (rel.startsWith("..") || isAbsolute2(rel)) throw new Error("acquired entry escapes package");
244
580
  return actual;
@@ -250,54 +586,59 @@ function acquisitionRepairCommand(dir, version) {
250
586
  const state = readState(dir);
251
587
  if (!state?.acquired || state.version !== version) throw new Error("installed acquisition selection is missing");
252
588
  const root = candidateRoot(dir, state.acquired.candidate);
253
- const acquisition = readAcquisition(join3(root, "payload"));
589
+ const acquisition = readAcquisition(join4(root, "payload"));
254
590
  if (!acquisition) throw new Error("installed acquisition declaration is missing");
255
- const prefix = join3(root, "prefix");
591
+ const prefix = join4(root, "prefix");
256
592
  const productRoot = packageRoot(prefix, acquisition);
257
- const pkg = JSON.parse(readFileSync3(join3(productRoot, "package.json"), "utf8"));
593
+ const pkg = JSON.parse(readFileSync4(join4(productRoot, "package.json"), "utf8"));
258
594
  if (pkg.name !== acquisition.package || pkg.version !== version) throw new Error("installed package identity does not match signed release");
259
595
  return argv(realpathSync2(state.acquired.node), productRoot, acquisition.runEntry, acquisition.repairArgs, prefix, version);
260
596
  }
261
597
  async function recoverAcquisition(dir, run2, env) {
262
- if (!existsSync3(pendingPath(dir))) return;
263
- const pending = JSON.parse(readFileSync3(pendingPath(dir), "utf8"));
598
+ if (!existsSync5(pendingPath(dir))) return;
599
+ const pending = JSON.parse(readFileSync4(pendingPath(dir), "utf8"));
264
600
  if (pending.schema !== 1 || typeof pending.version !== "string" || typeof pending.node !== "string") throw new Error("invalid pending acquisition receipt");
265
601
  const root = candidateRoot(dir, pending.candidate);
266
602
  if (readState(dir)?.acquired?.candidate === pending.candidate) {
267
603
  unlinkSync(pendingPath(dir));
268
604
  return;
269
605
  }
270
- const acquisition = readAcquisition(join3(root, "payload"));
606
+ const acquisition = readAcquisition(join4(root, "payload"));
271
607
  if (!acquisition) throw new Error("pending acquisition lost its signed declaration");
272
- const prefix = join3(root, "prefix");
608
+ const prefix = join4(root, "prefix");
273
609
  const command2 = argv(pending.node, packageRoot(prefix, acquisition), acquisition.rollbackEntry, acquisition.rollbackArgs, prefix, pending.version);
274
610
  if (!await run2(command2, root, runtimeEnvironment(pending.node, env))) throw new Error("installation recovery is pending; product rollback did not finish");
275
611
  if (pending.previous) writeState(dir, pending.previous);
276
- else if (existsSync3(statePath(dir))) unlinkSync(statePath(dir));
612
+ else if (existsSync5(statePath(dir))) unlinkSync(statePath(dir));
277
613
  unlinkSync(pendingPath(dir));
278
614
  }
279
615
  async function installAcquisition(dir, candidate, version, acquisition, options) {
280
616
  if (!acquisition.platforms.includes(`${process.platform}-${process.arch}`)) throw new Error("this product does not support this platform");
281
617
  const root = candidateRoot(dir, candidate);
282
- const payload = join3(root, "payload");
283
- const prefix = join3(root, "prefix");
284
- mkdirSync3(prefix);
285
- const runtime = await (options.runtime ?? acquireRuntime)(dir, options.fetchImpl);
618
+ const payload = join4(root, "payload");
619
+ const prefix = join4(root, "prefix");
620
+ const prepared = join4(root, "acquired.json");
621
+ const archiveHash = createHash3("sha256").update(readFileSync4(join4(payload, acquisition.archive))).digest("hex");
622
+ const reused = existsSync5(prepared);
623
+ if (reused && JSON.parse(readFileSync4(prepared, "utf8")).archiveHash !== archiveHash) throw new Error("cached acquisition archive changed");
624
+ if (!reused) mkdirSync4(prefix);
625
+ const runtime = await (options.runtime ?? acquireRuntime)(dir, options.fetchImpl, options.onProgress);
286
626
  const env = runtimeEnvironment(runtime.node, options.env);
287
627
  const npmEnv = { ...env };
288
628
  for (const key of Object.keys(npmEnv)) {
289
629
  if (/^npm_config_/i.test(key) || /^(NPM_TOKEN|NODE_AUTH_TOKEN|MM_INSTALLER_TOKEN)$/i.test(key)) delete npmEnv[key];
290
630
  }
291
- const npmrc = join3(root, "public.npmrc");
292
- const globalrc = join3(root, "global.npmrc");
293
- writeFileSync3(npmrc, "registry=https://registry.npmjs.org/\n");
294
- writeFileSync3(globalrc, "");
631
+ const npmrc = join4(root, "public.npmrc");
632
+ const globalrc = join4(root, "global.npmrc");
633
+ writeFileSync4(npmrc, "registry=https://registry.npmjs.org/\n");
634
+ writeFileSync4(globalrc, "");
295
635
  const install = [
296
636
  runtime.node,
297
637
  runtime.npm,
298
638
  "install",
299
639
  "--prefix",
300
640
  prefix,
641
+ "--install-strategy=shallow",
301
642
  "--ignore-scripts",
302
643
  "--no-audit",
303
644
  "--no-fund",
@@ -309,15 +650,16 @@ async function installAcquisition(dir, candidate, version, acquisition, options)
309
650
  "https://registry.npmjs.org/",
310
651
  resolve(payload, acquisition.archive)
311
652
  ];
312
- if (!await options.run(install, prefix, npmEnv)) throw new Error("local product archive installation failed");
653
+ if (!reused && !await options.run(install, prefix, npmEnv)) throw new Error("local product archive installation failed");
313
654
  const productRoot = packageRoot(prefix, acquisition);
314
- const pkg = JSON.parse(readFileSync3(join3(productRoot, "package.json"), "utf8"));
655
+ const pkg = JSON.parse(readFileSync4(join4(productRoot, "package.json"), "utf8"));
315
656
  if (pkg.name !== acquisition.package || pkg.version !== version) throw new Error("acquired package identity does not match signed release");
316
657
  const runEntry = entry(productRoot, acquisition.runEntry);
317
658
  const convergence = argv(runtime.node, productRoot, acquisition.convergeEntry, acquisition.convergeArgs, prefix, version);
318
659
  entry(productRoot, acquisition.rollbackEntry);
660
+ if (!reused) writeFileSync4(prepared, JSON.stringify({ archiveHash }), { flag: "wx", mode: 384, flush: true });
319
661
  const pending = { schema: 1, candidate, version, node: runtime.node, previous: readState(dir) };
320
- writeFileSync3(pendingPath(dir), JSON.stringify(pending), { flag: "wx", mode: 384, flush: true });
662
+ writeFileSync4(pendingPath(dir), JSON.stringify(pending), { flag: "wx", mode: 384, flush: true });
321
663
  try {
322
664
  if (!await options.run(convergence, root, env)) throw new Error("product convergence did not finish");
323
665
  (options.commit ?? writeState)(dir, { version, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), acquired: { candidate, node: runtime.node, entry: runEntry } });
@@ -330,42 +672,42 @@ async function installAcquisition(dir, candidate, version, acquisition, options)
330
672
 
331
673
  // src/index.ts
332
674
  import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
333
- import { existsSync as existsSync6, readFileSync as readFileSync7, statSync, mkdtempSync as mkdtempSync2, unlinkSync as unlinkSync2, rmdirSync, mkdirSync as mkdirSync6, renameSync as renameSync4, rmSync as rmSync5 } from "node:fs";
675
+ import { existsSync as existsSync7, readFileSync as readFileSync7, statSync, mkdtempSync as mkdtempSync2, unlinkSync as unlinkSync2, rmdirSync, mkdirSync as mkdirSync6, renameSync as renameSync4, rmSync as rmSync5 } from "node:fs";
334
676
  import { tmpdir as tmpdir4 } from "node:os";
335
677
  import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
336
678
  import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "node:url";
337
679
 
338
680
  // ../face/src/face.ts
339
- import { writeSync } from "node:fs";
681
+ import { appendFileSync, writeSync } from "node:fs";
340
682
 
341
683
  // ../face/src/products.ts
342
684
  var PRODUCTS = Object.freeze({
343
685
  "mm-strategy": Object.freeze({
344
686
  name: "MM Strategy",
345
- installWarm: "Welcome. Let's set up MM Strategy \u2014 about a minute.",
687
+ installWarm: "Welcome. Let's get you ready.",
346
688
  accent: "38;2;249;115;22",
347
- warm: "Welcome. Let's set up MM Strategy \u2014 about a minute.",
689
+ warm: "Welcome back. Checking for updates...",
348
690
  doctor: "mm-strategy doctor"
349
691
  }),
350
692
  "mmi-hub": Object.freeze({
351
693
  name: "mmi-hub",
352
- installWarm: "Welcome. Setting up mmi-hub \u2014 about a minute.",
694
+ installWarm: "Welcome. Let's get you ready.",
353
695
  accent: "38;2;125;211;252",
354
- warm: "Welcome back. Checking your surfaces\u2026",
696
+ warm: "Welcome back. Checking for updates...",
355
697
  doctor: "mmi doctor"
356
698
  }),
357
699
  "jerv-hub": Object.freeze({
358
700
  name: "jerv-hub",
359
- installWarm: "Welcome. Setting up jerv-hub \u2014 about a minute.",
701
+ installWarm: "Welcome. Let's get you ready.",
360
702
  accent: "38;2;248;113;113",
361
- warm: "Welcome back. Checking your surfaces\u2026",
703
+ warm: "Welcome back. Checking for updates...",
362
704
  doctor: "jerv doctor"
363
705
  }),
364
706
  jervcode: Object.freeze({
365
707
  name: "JervCode",
366
- installWarm: "Welcome. Setting up JervCode \u2014 about a minute.",
708
+ installWarm: "Welcome. Let's get you ready.",
367
709
  accent: "38;2;192;132;252",
368
- warm: "Welcome back. Keeping JervCode current\u2026",
710
+ warm: "Welcome back. Checking for updates...",
369
711
  doctor: "jervcode doctor"
370
712
  })
371
713
  });
@@ -379,6 +721,7 @@ function identityFor(product) {
379
721
 
380
722
  // ../face/src/face.ts
381
723
  var GLYPH = Object.freeze({
724
+ clock: "\u23F0",
382
725
  diamond: "\u25C6",
383
726
  hollow: "\u25C7",
384
727
  bar: "\u2502",
@@ -450,12 +793,16 @@ function createFace({ product, color = false, columns, env = process.env, operat
450
793
  const continuesFace = continuedPhases.size > 0;
451
794
  const nested = env.MM_OUTER_CONSOLE === "1";
452
795
  const progressFd = readProgressFd(env);
796
+ const progressFile = env.MM_PROGRESS_PROTOCOL === "1" ? env.MM_PROGRESS_FILE : void 0;
453
797
  const emitMilestone = (title, measure, kind) => {
454
- if (progressFd === null) return false;
798
+ if (progressFd === null && !progressFile) return false;
455
799
  const record = { v: PROGRESS_PROTOCOL, step: stripColor(title), state: kind };
456
800
  if (typeof measure === "number") record.ms = Math.max(0, Math.round(measure * 1e3));
801
+ if (kind === "running" && typeof measure === "string") record.measure = measure;
457
802
  try {
458
- writeSync(progressFd, `${JSON.stringify(record)}
803
+ if (progressFile) appendFileSync(progressFile, `${JSON.stringify(record)}
804
+ `);
805
+ else writeSync(progressFd, `${JSON.stringify(record)}
459
806
  `);
460
807
  return true;
461
808
  } catch {
@@ -474,7 +821,7 @@ function createFace({ product, color = false, columns, env = process.env, operat
474
821
  };
475
822
  const step = (title, measure = null, kind = "ok") => {
476
823
  if (emitMilestone(title, measure, kind)) return "";
477
- const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
824
+ const glyph = title === "Armed hourly updates" && kind === "ok" ? GLYPH.clock : kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
478
825
  const measured = measure === null || measure === void 0 ? "" : paint(PALETTE.muted, typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : String(measure).trim());
479
826
  const time = measured;
480
827
  const column = Math.min(44, Math.max(0, width - 8));
@@ -484,7 +831,7 @@ function createFace({ product, color = false, columns, env = process.env, operat
484
831
  const pad = Math.max(1, column - visibleWidth(head));
485
832
  return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
486
833
  };
487
- const relay = (text) => String(text).split("\n").flatMap((line) => line.trim() === "" ? [bar()] : (visibleWidth(line) <= width - TITLE_COLUMN ? [line] : wrapWords(line, width - TITLE_COLUMN)).map((part) => `${bar()}${indent}${part}`)).join("\n");
834
+ const relay = (text) => String(text).replace(/\r?\n$/, "").split(/\r?\n/).flatMap((line) => line.trim() === "" ? [bar()] : (visibleWidth(line) <= width - TITLE_COLUMN ? [line] : wrapWords(line, width - TITLE_COLUMN)).map((part) => `${bar()}${indent}${part}`)).join("\n");
488
835
  const receipt = (lines, { ready = true } = {}) => {
489
836
  if (ready && nested) return [];
490
837
  const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
@@ -515,16 +862,35 @@ function createFace({ product, color = false, columns, env = process.env, operat
515
862
  };
516
863
  const signOff = () => nested ? "" : `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
517
864
  const refusal = (message) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message}`;
518
- return { identity, width, nested, emitsProgress: progressFd !== null, welcome, continues, step, relay, receipt, outcome, signOff, refusal, paint };
865
+ return { identity, width, nested, emitsProgress: progressFd !== null || Boolean(progressFile), welcome, continues, step, progress: (title, measure = null) => emitMilestone(title, measure, "running"), relay, receipt, outcome, signOff, refusal, paint };
519
866
  }
520
867
 
521
868
  // ../face/src/shell.ts
522
869
  var RELAY_INDENT = " ".repeat(TITLE_COLUMN - 1);
523
870
 
524
871
  // ../face/src/spinner.ts
525
- import { appendFileSync, writeSync as writeSync2 } from "node:fs";
872
+ import { appendFileSync as appendFileSync2, writeSync as writeSync2 } from "node:fs";
526
873
  import { Worker } from "node:worker_threads";
527
- var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
874
+
875
+ // ../face/src/encoding.ts
876
+ var UNICODE_SPINNER_FRAMES = Object.freeze(["\u25D2", "\u25D0", "\u25D3", "\u25D1"]);
877
+ var ASCII_SPINNER_FRAMES = Object.freeze(["-", "\\", "|", "/"]);
878
+ function consoleRendersUnicode(env = process.env, platform = process.platform) {
879
+ if (env.MM_FACE_ASCII === "1" || env.NO_UNICODE === "1") return false;
880
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
881
+ if (locale === "C" || locale === "POSIX" || /^C\./u.test(locale) || /^POSIX\./u.test(locale)) return false;
882
+ if (/cp437|ibm437|\b437\b/iu.test(locale)) return false;
883
+ if (/utf-?8/iu.test(locale)) return true;
884
+ if (platform === "win32") {
885
+ return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode" || env.CHCP === "65001" || env.ConEmuANSI === "ON";
886
+ }
887
+ return true;
888
+ }
889
+ function spinnerFramesFor(env = process.env, platform = process.platform) {
890
+ return consoleRendersUnicode(env, platform) ? UNICODE_SPINNER_FRAMES : ASCII_SPINNER_FRAMES;
891
+ }
892
+
893
+ // ../face/src/spinner.ts
528
894
  var WORKER_SOURCE = `
529
895
  const { parentPort, workerData } = require('node:worker_threads');
530
896
  const { writeSync, appendFileSync } = require('node:fs');
@@ -534,7 +900,7 @@ function draw() {
534
900
  if (Atomics.compareExchange(control, 1, 0, 1) !== 0) return;
535
901
  try {
536
902
  if (Atomics.load(control, 0) || Atomics.load(control, 2) || !frames.length) return;
537
- const text = frames[frame++ % frames.length];
903
+ const text = frames[frame++ % frames.length].replace('__elapsed__', Math.floor((Date.now() - workerData.started) / 1000) + 's');
538
904
  writeSync(2, text);
539
905
  if (workerData.transcriptPath) appendFileSync(workerData.transcriptPath, JSON.stringify({ channel: 'spinner', text }) + '\\n');
540
906
  } finally {
@@ -546,31 +912,35 @@ parentPort.on('message', (next) => {
546
912
  if (Atomics.load(control, 2)) return;
547
913
  frames = next.frames;
548
914
  frame = next.frame;
549
- Atomics.store(control, 0, 0);
550
915
  });
551
916
  setInterval(draw, workerData.intervalMs);
552
917
  `;
553
- function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath }) {
918
+ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath, env = process.env, platform = process.platform }) {
554
919
  animate = animate && !face.nested && !face.emitsProgress;
920
+ const glyphs = spinnerFramesFor(env, platform);
921
+ const ascii = glyphs[0] === ASCII_SPINNER_FRAMES[0];
555
922
  let timer = null;
556
923
  let worker = null;
557
924
  let control = null;
558
925
  let frames = [];
559
926
  let frame = 0;
927
+ let started = 0;
928
+ let suspended = false;
560
929
  const write = (text) => {
561
930
  if (stream) stream.write(text);
562
931
  else {
563
932
  writeSync2(2, text);
564
- if (transcriptPath) appendFileSync(transcriptPath, `${JSON.stringify({ channel: "spinner", text })}
933
+ if (transcriptPath) appendFileSync2(transcriptPath, `${JSON.stringify({ channel: "spinner", text })}
565
934
  `);
566
935
  }
567
936
  };
568
937
  const render = (text, measure) => {
569
- const line = face.step(text, measure, "note").split("\n")[0];
570
- frames = line ? FRAMES.map((glyph) => `\r\x1B[2K${line.replace(GLYPH.dot, glyph)}`) : [];
938
+ const line = face.step(text, measure ?? "__elapsed__", "note").split("\n")[0];
939
+ const prepared = line && ascii ? line.replaceAll(GLYPH.bar, "|") : line;
940
+ frames = prepared ? glyphs.map((glyph) => `\r\x1B[2K${prepared.replace(GLYPH.dot, glyph)}`) : [];
571
941
  };
572
942
  const draw = () => {
573
- if (animate && frames.length) write(frames[frame++ % frames.length]);
943
+ if (animate && !suspended && frames.length) write(frames[frame++ % frames.length].replace("__elapsed__", `${Math.floor((Date.now() - started) / 1e3)}s`));
574
944
  };
575
945
  const pause = () => {
576
946
  if (!control) return;
@@ -590,6 +960,8 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
590
960
  start(text, measure = null) {
591
961
  if (!animate) return;
592
962
  halt();
963
+ suspended = false;
964
+ started = Date.now();
593
965
  render(text, measure);
594
966
  frame = 0;
595
967
  draw();
@@ -603,7 +975,8 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
603
975
  frames,
604
976
  frame,
605
977
  intervalMs,
606
- transcriptPath
978
+ transcriptPath,
979
+ started
607
980
  } });
608
981
  const active = worker;
609
982
  worker.on("error", () => {
@@ -621,23 +994,35 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
621
994
  render(text, measure);
622
995
  draw();
623
996
  worker?.postMessage({ frames, frame });
997
+ if (control && !suspended) Atomics.store(control, 0, 0);
998
+ },
999
+ pause() {
1000
+ if (suspended) return;
1001
+ suspended = true;
1002
+ pause();
1003
+ if (animate && frames.length) write("\r\x1B[2K");
1004
+ },
1005
+ resume() {
1006
+ suspended = false;
1007
+ if (control) Atomics.store(control, 0, 0);
1008
+ if (timer || worker) draw();
624
1009
  },
625
1010
  stop() {
626
1011
  halt();
1012
+ frames = [];
627
1013
  if (animate) write("\r\x1B[2K");
628
1014
  }
629
1015
  };
630
1016
  }
631
- var SPINNER_FRAMES = Object.freeze([...FRAMES]);
632
1017
 
633
1018
  // ../face/src/conformance.ts
634
1019
  var ALLOWED = new Set(Object.values(GLYPH));
635
1020
 
636
1021
  // ../face/src/run.ts
637
- import { appendFileSync as appendFileSync2 } from "node:fs";
1022
+ import { appendFileSync as appendFileSync3 } from "node:fs";
638
1023
 
639
1024
  // ../face/src/outcome.ts
640
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
1025
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
641
1026
  var counts = ["total", "updated", "failed"];
642
1027
  var strings = ["version", "retry", "detail", "logPath"];
643
1028
  var flags = ["dryRun", "installed", "deferred", "operationFailed"];
@@ -659,12 +1044,12 @@ function validateInstallerOutcome(value) {
659
1044
  return { ...facts };
660
1045
  }
661
1046
  function writeInstallerOutcome(path, value) {
662
- writeFileSync4(path, JSON.stringify(validateInstallerOutcome(value)), { encoding: "utf8", mode: 384 });
1047
+ writeFileSync5(path, JSON.stringify(validateInstallerOutcome(value)), { encoding: "utf8", mode: 384 });
663
1048
  }
664
1049
  function readInstallerOutcome(path) {
665
1050
  let text;
666
1051
  try {
667
- text = readFileSync4(path, "utf8");
1052
+ text = readFileSync5(path, "utf8");
668
1053
  } catch (error) {
669
1054
  if (error.code === "ENOENT") return void 0;
670
1055
  throw error;
@@ -727,10 +1112,19 @@ var PHASES = {
727
1112
  "verify-release": ["Checking the release version", "Verified the release version"],
728
1113
  verify: ["Verifying the payload", "Verified the payload"],
729
1114
  install: ["Installing the product", "Installed the product"],
1115
+ configure: ["Configuring the product", "Configured the product"],
730
1116
  activate: ["Activating surfaces", "Activated surfaces"],
731
1117
  doctor: ["Checking health", "Checked health"],
732
1118
  rollback: ["Restoring the previous version", "Restored the previous version"]
733
1119
  };
1120
+ var UPDATE_CHECK_PHASES = /* @__PURE__ */ new Set([
1121
+ "preflight",
1122
+ "resolve",
1123
+ "verify-release",
1124
+ "download",
1125
+ "sign-in",
1126
+ "check"
1127
+ ]);
734
1128
  function createInstallerRun(value, options = {}) {
735
1129
  const declaration = validateInstallerProduct(value);
736
1130
  const env = options.env ?? process.env;
@@ -756,7 +1150,7 @@ function createInstallerRun(value, options = {}) {
756
1150
  if (!text) return;
757
1151
  write(text, channel);
758
1152
  if (env.MM_FACE_TRANSCRIPT) {
759
- appendFileSync2(env.MM_FACE_TRANSCRIPT, `${JSON.stringify({ channel, text: recorded })}
1153
+ appendFileSync3(env.MM_FACE_TRANSCRIPT, `${JSON.stringify({ channel, text: recorded })}
760
1154
  `, "utf8");
761
1155
  }
762
1156
  };
@@ -766,13 +1160,26 @@ function createInstallerRun(value, options = {}) {
766
1160
  };
767
1161
  const spinner = createSpinner(face, {
768
1162
  animate: tty && env.TERM !== "dumb" && (options.animate ?? Boolean(process.stderr.isTTY)),
1163
+ env,
769
1164
  ...options.write ? { stream: { write: (text) => {
770
1165
  emit(String(text), "spinner");
771
1166
  return true;
772
1167
  } } } : { transcriptPath: env.MM_FACE_TRANSCRIPT }
773
1168
  });
1169
+ let activeTitle;
1170
+ let chunkOpen = false;
1171
+ let chunkChannel = "stdout";
1172
+ let chunkColumn = 0;
1173
+ const trailingCR = /* @__PURE__ */ new Set();
1174
+ let deferredActivity;
1175
+ const closeChunk = () => {
1176
+ if (chunkOpen) emit("\n", chunkChannel);
1177
+ chunkOpen = false;
1178
+ chunkColumn = 0;
1179
+ };
774
1180
  let started = false;
775
1181
  let finished = false;
1182
+ let operation = options.operation;
776
1183
  const start = () => {
777
1184
  if (started || finished) return;
778
1185
  started = true;
@@ -780,11 +1187,14 @@ function createInstallerRun(value, options = {}) {
780
1187
  const welcome = face.welcome();
781
1188
  if (tty) lines(welcome);
782
1189
  else if (welcome.length) {
783
- const warm = options.operation === "install" ? face.identity.installWarm : face.identity.warm;
1190
+ const warm = operation === "install" ? face.identity.installWarm : face.identity.warm;
784
1191
  lines([`${face.identity.name} - Mutatis Mutandis`, warm.replaceAll("\u2014", "-").replaceAll("\u2026", "...")]);
785
1192
  }
786
1193
  };
787
1194
  const durable = (title, measure, kind) => {
1195
+ closeChunk();
1196
+ activeTitle = void 0;
1197
+ deferredActivity = void 0;
788
1198
  spinner.stop();
789
1199
  const rendered = face.step(title, measure, kind);
790
1200
  if (rendered) lines([tty ? rendered : `${kind === "fail" ? "Failed: " : ""}${title}${measure === null ? "" : ` (${typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : measure})`}`]);
@@ -793,15 +1203,31 @@ function createInstallerRun(value, options = {}) {
793
1203
  get errors() {
794
1204
  return [...errors];
795
1205
  },
1206
+ get operation() {
1207
+ return operation;
1208
+ },
1209
+ set operation(value2) {
1210
+ operation = value2;
1211
+ },
796
1212
  start,
797
1213
  phase(id, facts = {}) {
798
1214
  if (finished) throw new Error("installer run already finished");
799
1215
  if (!Object.hasOwn(PHASES, id)) throw new Error("installer run: unknown phase");
800
1216
  start();
801
1217
  const state = facts.state ?? "ok";
802
- const title = PHASES[id][state === "ok" ? 1 : 0];
1218
+ const fold = operation === "update" && UPDATE_CHECK_PHASES.has(id) && state !== "fail";
1219
+ const title = PHASES[fold ? "check" : id][state === "ok" ? 1 : 0];
803
1220
  if (state === "running") {
804
- spinner.start(title, facts.measure ?? null);
1221
+ if (face.progress(title, facts.measure ?? null)) return;
1222
+ if (activeTitle === title) spinner.say(title, facts.measure ?? null);
1223
+ else {
1224
+ activeTitle = title;
1225
+ spinner.start(title, facts.measure ?? null);
1226
+ }
1227
+ return;
1228
+ }
1229
+ if (fold && id !== "check") {
1230
+ if (facts.detail) run2.relay(facts.detail);
805
1231
  return;
806
1232
  }
807
1233
  if (!face.continues(id, state)) durable(title, facts.seconds ?? facts.measure ?? null, state);
@@ -821,8 +1247,20 @@ function createInstallerRun(value, options = {}) {
821
1247
  durable(`${facts.id}${versions} ${separator} ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
822
1248
  if (facts.detail) run2.relay(facts.detail);
823
1249
  },
824
- milestone({ step, state, ms }) {
1250
+ milestone({ step, state, ms, measure }) {
825
1251
  start();
1252
+ if (state === "running") {
1253
+ if (chunkOpen) {
1254
+ deferredActivity = { title: step, measure: measure ?? null };
1255
+ return;
1256
+ }
1257
+ if (activeTitle === step) spinner.say(step, measure ?? null);
1258
+ else {
1259
+ activeTitle = step;
1260
+ spinner.start(step, measure ?? null);
1261
+ }
1262
+ return;
1263
+ }
826
1264
  durable(step, ms === void 0 ? null : ms / 1e3, state);
827
1265
  },
828
1266
  signIn({ url, code }) {
@@ -836,7 +1274,8 @@ function createInstallerRun(value, options = {}) {
836
1274
  },
837
1275
  // Only pass safe diagnostic text, never authentication output or credentials.
838
1276
  relay(text, channel = "stdout", record = true) {
839
- spinner.stop();
1277
+ closeChunk();
1278
+ spinner.pause();
840
1279
  const rendered = tty ? face.relay(text) : stripColor(text);
841
1280
  for (const row of rendered.split("\n")) if (row) {
842
1281
  emit(`${row}
@@ -844,6 +1283,37 @@ function createInstallerRun(value, options = {}) {
844
1283
  ` : `${tty ? face.relay("[external output omitted]") : "[external output omitted]"}
845
1284
  `);
846
1285
  }
1286
+ spinner.resume();
1287
+ },
1288
+ relayChunk(text, channel = "stdout") {
1289
+ spinner.pause();
1290
+ if (chunkOpen && chunkChannel !== channel) closeChunk();
1291
+ chunkChannel = channel;
1292
+ if (trailingCR.delete(channel) && text.startsWith("\n")) text = text.slice(1);
1293
+ if (text.endsWith("\r")) trailingCR.add(channel);
1294
+ const parts = text.replace(/\r\n|\r/g, "\n").split("\n");
1295
+ for (let i = 0; i < parts.length; i++) {
1296
+ const characters = [...stripColor(parts[i])];
1297
+ while (characters.length) {
1298
+ if (tty && chunkColumn >= face.width - 6) closeChunk();
1299
+ const part = characters.splice(0, tty ? face.width - 6 - chunkColumn : characters.length).join("");
1300
+ emit(tty && !chunkOpen ? face.relay(part) : part, channel, chunkOpen ? "" : tty ? face.relay("[external output omitted]") : "[external output omitted]");
1301
+ chunkColumn += [...part].length;
1302
+ chunkOpen = true;
1303
+ }
1304
+ if (i < parts.length - 1) {
1305
+ emit("\n", channel);
1306
+ chunkOpen = false;
1307
+ chunkColumn = 0;
1308
+ }
1309
+ }
1310
+ if (!chunkOpen) {
1311
+ if (deferredActivity) {
1312
+ activeTitle = deferredActivity.title;
1313
+ spinner.start(deferredActivity.title, deferredActivity.measure);
1314
+ deferredActivity = void 0;
1315
+ } else spinner.resume();
1316
+ }
847
1317
  },
848
1318
  cancel() {
849
1319
  if (finished) return;
@@ -852,6 +1322,8 @@ function createInstallerRun(value, options = {}) {
852
1322
  { total: 0, updated: 0, failed: 0, deferred: true, detail: "Operation cancelled." }
853
1323
  );
854
1324
  start();
1325
+ closeChunk();
1326
+ activeTitle = void 0;
855
1327
  spinner.stop();
856
1328
  finished = true;
857
1329
  if (!face.nested || !env.MM_INSTALLER_OUTCOME_FILE) {
@@ -864,6 +1336,8 @@ function createInstallerRun(value, options = {}) {
864
1336
  validateInstallerOutcome(facts);
865
1337
  if (env.MM_INSTALLER_OUTCOME_FILE) writeInstallerOutcome(env.MM_INSTALLER_OUTCOME_FILE, facts);
866
1338
  start();
1339
+ closeChunk();
1340
+ activeTitle = void 0;
867
1341
  spinner.stop();
868
1342
  finished = true;
869
1343
  if (options.quiet && facts.updated === 0 && facts.failed === 0 && !facts.operationFailed) return;
@@ -884,6 +1358,8 @@ function createInstallerRun(value, options = {}) {
884
1358
  if (tty) lines([face.signOff()]);
885
1359
  },
886
1360
  stop() {
1361
+ closeChunk();
1362
+ activeTitle = void 0;
887
1363
  spinner.stop();
888
1364
  }
889
1365
  };
@@ -892,9 +1368,9 @@ function createInstallerRun(value, options = {}) {
892
1368
 
893
1369
  // src/autoupdate.ts
894
1370
  import { spawnSync as spawnSync2 } from "node:child_process";
895
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
896
- import { tmpdir as tmpdir2 } from "node:os";
897
- import { join as join4 } from "node:path";
1371
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "node:fs";
1372
+ import { tmpdir as tmpdir3 } from "node:os";
1373
+ import { join as join5 } from "node:path";
898
1374
  function schedulePlatform(override) {
899
1375
  const platform = override ?? process.platform;
900
1376
  if (platform === "win32" || platform === "darwin" || platform === "linux") return platform;
@@ -911,8 +1387,8 @@ function defaultExec(command2, args) {
911
1387
  }
912
1388
  function homeOf(options) {
913
1389
  if (options.homeDir) return options.homeDir;
914
- if (process.platform === "win32") return process.env.USERPROFILE ?? tmpdir2();
915
- return process.env.HOME ?? tmpdir2();
1390
+ if (process.platform === "win32") return process.env.USERPROFILE ?? tmpdir3();
1391
+ return process.env.HOME ?? tmpdir3();
916
1392
  }
917
1393
  function scheduleName(config) {
918
1394
  return `${config.binName} autoupdate`;
@@ -938,10 +1414,10 @@ function enableSchedule(config, command2, options = {}) {
938
1414
  }
939
1415
  if (platform === "darwin") {
940
1416
  const label2 = scheduleLabel(config);
941
- const dir2 = join4(home, "Library", "LaunchAgents");
942
- mkdirSync4(dir2, { recursive: true });
943
- const plist = join4(dir2, `${label2}.plist`);
944
- writeFileSync5(plist, darwinPlist(label2, command2));
1417
+ const dir2 = join5(home, "Library", "LaunchAgents");
1418
+ mkdirSync5(dir2, { recursive: true });
1419
+ const plist = join5(dir2, `${label2}.plist`);
1420
+ writeFileSync6(plist, darwinPlist(label2, command2));
945
1421
  exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${label2}`]);
946
1422
  const result2 = exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, plist]);
947
1423
  if (result2.code !== 0) {
@@ -950,10 +1426,10 @@ function enableSchedule(config, command2, options = {}) {
950
1426
  return;
951
1427
  }
952
1428
  const label = scheduleLabel(config);
953
- const dir = join4(home, ".config", "systemd", "user");
954
- mkdirSync4(dir, { recursive: true });
955
- writeFileSync5(join4(dir, `${label}.service`), linuxService(command2));
956
- writeFileSync5(join4(dir, `${label}.timer`), linuxTimer(label));
1429
+ const dir = join5(home, ".config", "systemd", "user");
1430
+ mkdirSync5(dir, { recursive: true });
1431
+ writeFileSync6(join5(dir, `${label}.service`), linuxService(command2));
1432
+ writeFileSync6(join5(dir, `${label}.timer`), linuxTimer(label));
957
1433
  const reload = exec("systemctl", ["--user", "daemon-reload"]);
958
1434
  if (reload.code !== 0) {
959
1435
  throw new Error(`could not turn autoupdate on: ${firstLine(reload.stderr || reload.stdout) || `exit ${reload.code}`}`);
@@ -978,14 +1454,14 @@ function disableSchedule(config, options = {}) {
978
1454
  if (platform === "darwin") {
979
1455
  const label2 = scheduleLabel(config);
980
1456
  exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${label2}`]);
981
- rmSync3(join4(home, "Library", "LaunchAgents", `${label2}.plist`), { force: true });
1457
+ rmSync4(join5(home, "Library", "LaunchAgents", `${label2}.plist`), { force: true });
982
1458
  return;
983
1459
  }
984
1460
  const label = scheduleLabel(config);
985
- const dir = join4(home, ".config", "systemd", "user");
1461
+ const dir = join5(home, ".config", "systemd", "user");
986
1462
  exec("systemctl", ["--user", "disable", "--now", `${label}.timer`]);
987
- rmSync3(join4(dir, `${label}.service`), { force: true });
988
- rmSync3(join4(dir, `${label}.timer`), { force: true });
1463
+ rmSync4(join5(dir, `${label}.service`), { force: true });
1464
+ rmSync4(join5(dir, `${label}.timer`), { force: true });
989
1465
  }
990
1466
  function querySchedule(config, options = {}) {
991
1467
  const platform = schedulePlatform(options.platform);
@@ -1003,12 +1479,12 @@ function querySchedule(config, options = {}) {
1003
1479
  return state2;
1004
1480
  }
1005
1481
  if (platform === "darwin") {
1006
- const plist = join4(home, "Library", "LaunchAgents", `${scheduleLabel(config)}.plist`);
1007
- if (!existsSync4(plist)) return { supported: true, enabled: false };
1482
+ const plist = join5(home, "Library", "LaunchAgents", `${scheduleLabel(config)}.plist`);
1483
+ if (!existsSync6(plist)) return { supported: true, enabled: false };
1008
1484
  return { supported: true, enabled: true, cadence: "hourly" };
1009
1485
  }
1010
- const timer = join4(home, ".config", "systemd", "user", `${scheduleLabel(config)}.timer`);
1011
- if (!existsSync4(timer)) return { supported: true, enabled: false };
1486
+ const timer = join5(home, ".config", "systemd", "user", `${scheduleLabel(config)}.timer`);
1487
+ if (!existsSync6(timer)) return { supported: true, enabled: false };
1012
1488
  const state = { supported: true, enabled: true, cadence: "hourly" };
1013
1489
  const shown = exec("systemctl", ["--user", "show", `${scheduleLabel(config)}.service`, "-p", "ExecMainStartTimestamp", "--value"]);
1014
1490
  const stamp = (shown.stdout ?? "").trim();
@@ -1065,86 +1541,6 @@ function firstLine(text) {
1065
1541
  return String(text).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] ?? "";
1066
1542
  }
1067
1543
 
1068
- // src/config.ts
1069
- import { readFileSync as readFileSync6 } from "node:fs";
1070
- import { getAsset } from "node:sea";
1071
-
1072
- // src/module-url.ts
1073
- import { pathToFileURL } from "node:url";
1074
- function moduleUrl() {
1075
- if (typeof __filename === "string") return pathToFileURL(__filename).href;
1076
- return import.meta.url;
1077
- }
1078
-
1079
- // src/config.ts
1080
- function publicKeyBytes(config) {
1081
- const raw = Buffer.from(config.publicKey, "base64");
1082
- if (raw.length !== 32) {
1083
- throw new Error(`product config for "${config.product}" has a bad publicKey (want 32 raw bytes)`);
1084
- }
1085
- return raw;
1086
- }
1087
- function loadProductConfig(options = {}) {
1088
- const explicit = options.configPath ?? process.env.LAUNCHER_CONFIG;
1089
- const devFallback = new URL("../config/product.template.json", moduleUrl());
1090
- if (explicit) {
1091
- return parseProductConfig(readFileSync6(explicit, "utf8"));
1092
- }
1093
- try {
1094
- return parseProductConfig(readFileSync6(devFallback, "utf8"));
1095
- } catch {
1096
- }
1097
- try {
1098
- const asset = getAsset("product.json", "utf8");
1099
- if (typeof asset === "string") return parseProductConfig(asset);
1100
- } catch {
1101
- }
1102
- throw new Error(
1103
- "no product config found (pass --config <path>, set LAUNCHER_CONFIG, or bake product.json into the SEA binary)"
1104
- );
1105
- }
1106
- function parseProductConfig(text) {
1107
- let data;
1108
- try {
1109
- data = JSON.parse(text);
1110
- } catch {
1111
- throw new Error("product config is not valid JSON");
1112
- }
1113
- if (typeof data !== "object" || data === null) throw new Error("product config must be an object");
1114
- const record = data;
1115
- const product = field(record, "product");
1116
- const host = field(record, "host").replace(/\/+$/, "");
1117
- const loginKind = field(record, "loginKind");
1118
- const binName = field(record, "binName");
1119
- if (!product || !host || !binName) throw new Error("product config needs product, host and binName");
1120
- if (loginKind !== "github" && loginKind !== "google") {
1121
- throw new Error('product config loginKind must be "github" or "google"');
1122
- }
1123
- const config = { product, host, loginKind, publicKey: field(record, "publicKey"), binName };
1124
- if (!config.publicKey) throw new Error("product config needs publicKey (base64 raw Ed25519)");
1125
- publicKeyBytes(config);
1126
- if (loginKind === "github") {
1127
- const clientId = record.githubClientId;
1128
- if (typeof clientId !== "string" || clientId.length === 0) {
1129
- throw new Error("product config needs githubClientId for the github loginKind");
1130
- }
1131
- config.githubClientId = clientId;
1132
- } else if (typeof record.githubClientId === "string") {
1133
- config.githubClientId = record.githubClientId;
1134
- }
1135
- try {
1136
- const url = new URL(host);
1137
- if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error();
1138
- } catch {
1139
- throw new Error("product config host must be an http(s) URL");
1140
- }
1141
- return config;
1142
- }
1143
- function field(record, key) {
1144
- const value = record[key];
1145
- return typeof value === "string" ? value : "";
1146
- }
1147
-
1148
1544
  // src/login-github.ts
1149
1545
  import { spawn } from "node:child_process";
1150
1546
  var realSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
@@ -1267,7 +1663,7 @@ async function refreshAccessToken(config, refreshToken, fetchImpl = fetch) {
1267
1663
  }
1268
1664
 
1269
1665
  // src/login-google.ts
1270
- import { createHash as createHash2, randomBytes } from "node:crypto";
1666
+ import { createHash as createHash4, randomBytes } from "node:crypto";
1271
1667
  import { createServer } from "node:http";
1272
1668
  var b64url = (bytes) => bytes.toString("base64url");
1273
1669
  async function loginGoogle(options) {
@@ -1290,7 +1686,7 @@ async function loginGoogle(options) {
1290
1686
  throw new Error("the sign-in server returned a malformed registration");
1291
1687
  }
1292
1688
  const verifier = b64url(randomBytes(32));
1293
- const challenge = b64url(createHash2("sha256").update(verifier).digest());
1689
+ const challenge = b64url(createHash4("sha256").update(verifier).digest());
1294
1690
  const state = b64url(randomBytes(16));
1295
1691
  const authorize = new URL(`${server}/oauth/authorize`);
1296
1692
  authorize.search = new URLSearchParams({
@@ -1379,175 +1775,8 @@ function page(title, body) {
1379
1775
  return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="dark"><title>${title}</title><style>${style}</style><main><h1>${title}</h1><p>${body}</p></main>`;
1380
1776
  }
1381
1777
 
1382
- // src/payload.ts
1383
- import { createHash as createHash3, createPublicKey, verify } from "node:crypto";
1384
- import { mkdirSync as mkdirSync5, renameSync as renameSync3, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "node:fs";
1385
- import { tmpdir as tmpdir3 } from "node:os";
1386
- import { dirname as dirname2, join as join5 } from "node:path";
1387
-
1388
- // src/canonical.ts
1389
- function canonicalJson(value) {
1390
- return encode(value);
1391
- }
1392
- function encode(value) {
1393
- if (value === null) return "null";
1394
- if (typeof value === "string") return JSON.stringify(value);
1395
- if (typeof value === "boolean") return value ? "true" : "false";
1396
- if (typeof value === "number") {
1397
- if (!Number.isFinite(value)) {
1398
- throw new TypeError("canonicalJson: cannot encode a non-finite number");
1399
- }
1400
- return JSON.stringify(value);
1401
- }
1402
- if (Array.isArray(value)) {
1403
- return `[${value.map((entry2) => encode(entry2)).join(",")}]`;
1404
- }
1405
- if (typeof value === "object") {
1406
- const record = value;
1407
- const keys = Object.keys(record).filter((key) => record[key] !== void 0).sort();
1408
- return `{${keys.map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(",")}}`;
1409
- }
1410
- throw new TypeError(`canonicalJson: cannot encode a value of type ${typeof value}`);
1411
- }
1412
-
1413
- // src/payload.ts
1414
- var NeedsLoginError = class extends Error {
1415
- constructor() {
1416
- super("signed out \u2014 run login first");
1417
- this.name = "NeedsLoginError";
1418
- }
1419
- };
1420
- var ForbiddenError = class extends Error {
1421
- constructor() {
1422
- super("this install is not allowed for your account \u2014 access was revoked or never granted.");
1423
- this.name = "ForbiddenError";
1424
- }
1425
- };
1426
- function canonicalManifestBytes(manifest) {
1427
- return Buffer.from(
1428
- canonicalJson({
1429
- created: manifest.created,
1430
- files: manifest.files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size })),
1431
- version: manifest.version
1432
- }),
1433
- "utf8"
1434
- );
1435
- }
1436
- function ed25519PublicKey(config) {
1437
- const raw = publicKeyBytes(config);
1438
- return createPublicKey({
1439
- key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
1440
- format: "jwk"
1441
- });
1442
- }
1443
- function verifyManifest(manifest, signature, config) {
1444
- try {
1445
- const signatureBytes = Buffer.from(signature, "base64");
1446
- if (signatureBytes.length === 0) return false;
1447
- return verify(null, canonicalManifestBytes(manifest), ed25519PublicKey(config), signatureBytes);
1448
- } catch {
1449
- return false;
1450
- }
1451
- }
1452
- function verifyFileBytes(entry2, bytes) {
1453
- if (entry2.size !== bytes.length) return false;
1454
- return createHash3("sha256").update(bytes).digest("hex") === entry2.sha256.toLowerCase();
1455
- }
1456
- async function readErrorCode(response) {
1457
- try {
1458
- const data = await response.json();
1459
- return typeof data.error === "string" ? data.error : "";
1460
- } catch {
1461
- return "";
1462
- }
1463
- }
1464
- function throwForStatus(status, errorCode) {
1465
- if (status === 401) throw new NeedsLoginError();
1466
- if (status === 403) throw new ForbiddenError();
1467
- throw new Error(
1468
- errorCode ? `the release server refused the request (${status}: ${errorCode})` : `the release server refused the request (${status})`
1469
- );
1470
- }
1471
- async function getJson(url, accessToken, fetchImpl) {
1472
- const response = await fetchImpl(url, { headers: { authorization: `Bearer ${accessToken}` } });
1473
- if (!response.ok) throwForStatus(response.status, await readErrorCode(response));
1474
- return { status: response.status, json: await response.json() };
1475
- }
1476
- function parseManifest(json) {
1477
- if (typeof json !== "object" || json === null) throw new Error("the release manifest is not an object");
1478
- const record = json;
1479
- if (typeof record.version !== "string" || !record.version) throw new Error("the release manifest has no version");
1480
- if (typeof record.created !== "string" || !record.created) throw new Error("the release manifest has no created stamp");
1481
- if (!Array.isArray(record.files)) throw new Error("the release manifest has no files list");
1482
- if (typeof record.signature !== "string" || !record.signature) {
1483
- throw new Error("the release manifest is unsigned");
1484
- }
1485
- const files = record.files.map((entry2) => {
1486
- const file = entry2;
1487
- if (typeof file.path !== "string" || typeof file.sha256 !== "string" || typeof file.size !== "number") {
1488
- throw new Error("the release manifest lists a malformed file");
1489
- }
1490
- const safe = safeManifestPath(file.path);
1491
- if (safe === null) throw new Error(`the release manifest lists an unsafe path: ${file.path}`);
1492
- return { path: safe, sha256: file.sha256, size: file.size };
1493
- });
1494
- return { version: record.version, created: record.created, files, signature: record.signature };
1495
- }
1496
- function safeManifestPath(rawPath) {
1497
- if (rawPath.length === 0 || rawPath.includes("\0") || rawPath.includes("\\")) return null;
1498
- if (rawPath.startsWith("/")) return null;
1499
- const segments = rawPath.split("/");
1500
- for (const segment of segments) {
1501
- if (segment === "" || segment === "." || segment === "..") return null;
1502
- }
1503
- return segments.join("/");
1504
- }
1505
- async function fetchVerifiedManifest(config, accessToken, fetchImpl = fetch) {
1506
- const { json } = await getJson(`${config.host}/release/manifest`, accessToken, fetchImpl);
1507
- const manifest = parseManifest(json);
1508
- if (!verifyManifest(manifest, manifest.signature, config)) {
1509
- throw new Error("the release manifest signature is invalid \u2014 refusing to install anything.");
1510
- }
1511
- return manifest;
1512
- }
1513
- async function fetchFileBytes(config, accessToken, path, fetchImpl) {
1514
- const encoded = path.split("/").map(encodeURIComponent).join("/");
1515
- const response = await fetchImpl(`${config.host}/release/${encoded}`, {
1516
- headers: { authorization: `Bearer ${accessToken}` }
1517
- });
1518
- if (!response.ok) {
1519
- if (response.status === 404) throw new Error(`the release server has no file at ${path}`);
1520
- throwForStatus(response.status, await readErrorCode(response));
1521
- }
1522
- return Buffer.from(await response.arrayBuffer());
1523
- }
1524
- async function downloadAndUnpack(config, dir, manifest, accessToken, options = {}) {
1525
- const fetchImpl = options.fetchImpl ?? fetch;
1526
- const staging = join5(tmpdir3(), `launcher-${config.product}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
1527
- mkdirSync5(staging, { recursive: true });
1528
- try {
1529
- for (const entry2 of manifest.files) {
1530
- const bytes = await fetchFileBytes(config, accessToken, entry2.path, fetchImpl);
1531
- if (!verifyFileBytes(entry2, bytes)) {
1532
- throw new Error(`file ${entry2.path} failed its checksum \u2014 refusing to install anything.`);
1533
- }
1534
- const dest = join5(staging, entry2.path);
1535
- mkdirSync5(dirname2(dest), { recursive: true });
1536
- writeFileSync6(dest, bytes);
1537
- }
1538
- const target = join5(dir, "payload");
1539
- mkdirSync5(dir, { recursive: true });
1540
- rmSync4(target, { force: true, recursive: true });
1541
- renameSync3(staging, target);
1542
- } catch (error) {
1543
- rmSync4(staging, { force: true, recursive: true });
1544
- throw error;
1545
- }
1546
- return manifest.version;
1547
- }
1548
-
1549
1778
  // src/index.ts
1550
- var LAUNCHER_VERSION = true ? "0.1.13" : readVersionFromPackage();
1779
+ var LAUNCHER_VERSION = true ? "0.1.15" : readVersionFromPackage();
1551
1780
  function defaultPrint(message) {
1552
1781
  process.stdout.write(`${message}
1553
1782
  `);
@@ -1636,7 +1865,7 @@ async function runFile(file, args, printErr) {
1636
1865
  return 1;
1637
1866
  }
1638
1867
  const abs = resolve2(process.cwd(), file);
1639
- if (!existsSync6(abs)) {
1868
+ if (!existsSync7(abs)) {
1640
1869
  printErr(`cannot run ${file}: no such file.`);
1641
1870
  return 1;
1642
1871
  }
@@ -1768,7 +1997,7 @@ function resolveEntry(entry2) {
1768
1997
  function needsShell(command2) {
1769
1998
  if (process.platform !== "win32") return false;
1770
1999
  if (/\.(cmd|bat)$/i.test(command2)) return true;
1771
- return !existsSync6(command2);
2000
+ return !existsSync7(command2);
1772
2001
  }
1773
2002
  function quoteForShell(arg) {
1774
2003
  return /[\s"&|<>^%]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
@@ -1783,12 +2012,13 @@ function parseProgress(raw) {
1783
2012
  const message = JSON.parse(line);
1784
2013
  if (!SUPPORTED_PROGRESS_PROTOCOLS.has(message.v) || typeof message.step !== "string") continue;
1785
2014
  const state = message.state ?? "ok";
1786
- if (state !== "ok" && state !== "fail" && state !== "note") continue;
2015
+ if (state !== "ok" && state !== "fail" && state !== "note" && state !== "running") continue;
1787
2016
  if (message.ms !== void 0 && typeof message.ms !== "number") continue;
1788
2017
  progress.push({
1789
2018
  step: message.step,
1790
2019
  state,
1791
- ...typeof message.ms === "number" ? { ms: message.ms } : {}
2020
+ ...typeof message.ms === "number" && Number.isFinite(message.ms) && message.ms >= 0 ? { ms: message.ms } : {},
2021
+ ...typeof message.measure === "string" ? { measure: message.measure } : {}
1792
2022
  });
1793
2023
  } catch {
1794
2024
  }
@@ -1847,8 +2077,27 @@ async function installOrUpdate(config, dir, options, print, update) {
1847
2077
  let version = readState(dir)?.version ?? "unknown";
1848
2078
  installer.start();
1849
2079
  try {
2080
+ const current = readState(dir);
2081
+ const payloadPresent = existsSync7(payloadDir(dir));
1850
2082
  let started = Date.now();
2083
+ installer.phase("resolve", { state: "running" });
2084
+ const pointer = await fetchReleaseVersion(config, fetchImpl);
2085
+ if (pointer && current?.version === pointer && payloadPresent) {
2086
+ version = pointer;
2087
+ installer.phase("resolve", { seconds: (Date.now() - started) / 1e3 });
2088
+ installer.surface({ id: config.product, from: version, to: version, state: "current" });
2089
+ return await finishLastMile(
2090
+ config,
2091
+ dir,
2092
+ options,
2093
+ installer,
2094
+ version,
2095
+ true,
2096
+ current.acquired ? acquisitionRepairCommand(dir, version) : void 0
2097
+ );
2098
+ }
1851
2099
  installer.phase("sign-in", { state: "running" });
2100
+ started = Date.now();
1852
2101
  const accessToken = await fetchAccessTokenOrLogin(config, dir, options, print, installer);
1853
2102
  installer.phase("sign-in", { seconds: (Date.now() - started) / 1e3 });
1854
2103
  installer.phase("resolve", { state: "running" });
@@ -1856,8 +2105,7 @@ async function installOrUpdate(config, dir, options, print, update) {
1856
2105
  const manifest = await fetchVerifiedManifest(config, accessToken, fetchImpl);
1857
2106
  version = manifest.version;
1858
2107
  installer.phase("resolve", { seconds: (Date.now() - started) / 1e3 });
1859
- const current = readState(dir);
1860
- const unchanged = update && current?.version === version && existsSync6(payloadDir(dir));
2108
+ const unchanged = current?.version === version && existsSync7(payloadDir(dir));
1861
2109
  if (unchanged && current.acquired) {
1862
2110
  installer.surface({ id: config.product, from: version, to: version, state: "current" });
1863
2111
  return await finishLastMile(config, dir, options, installer, version, true, acquisitionRepairCommand(dir, version));
@@ -1865,11 +2113,17 @@ async function installOrUpdate(config, dir, options, print, update) {
1865
2113
  if (!unchanged) {
1866
2114
  installer.phase("download", { state: "running" });
1867
2115
  started = Date.now();
1868
- const candidate = randomUUID4();
2116
+ const cached = reusableCandidate(dir, manifest);
2117
+ const candidate = cached ?? randomUUID4();
1869
2118
  const candidateDir = join6(dir, "candidates", candidate);
1870
- await downloadAndUnpack(config, candidateDir, manifest, accessToken, { fetchImpl });
1871
- const acquisition = existsSync6(join6(candidateDir, "payload", "payload.json")) ? readAcquisition(join6(candidateDir, "payload")) : null;
2119
+ const onProgress = ({ done, total }) => installer.phase("download", {
2120
+ state: "running",
2121
+ measure: total ? `${Math.floor(done / total * 100)}% \xB7 ${done}/${total} B` : `${done} B`
2122
+ });
2123
+ if (!cached) await downloadAndUnpack(config, candidateDir, manifest, accessToken, { fetchImpl, onProgress, cacheDir: dir });
2124
+ const acquisition = existsSync7(join6(candidateDir, "payload", "payload.json")) ? readAcquisition(join6(candidateDir, "payload")) : null;
1872
2125
  if (acquisition) {
2126
+ rememberCandidate(dir, candidate, manifest);
1873
2127
  installer.phase("download", { seconds: (Date.now() - started) / 1e3 });
1874
2128
  installer.phase("activate", { state: "running" });
1875
2129
  let outcome;
@@ -1877,7 +2131,9 @@ async function installOrUpdate(config, dir, options, print, update) {
1877
2131
  await installAcquisition(dir, candidate, version, acquisition, {
1878
2132
  fetchImpl,
1879
2133
  env,
2134
+ onProgress,
1880
2135
  run: async (command2, cwd, childEnv) => {
2136
+ installer.phase(command2.includes("--prefix") ? "install" : "activate", { state: "running" });
1881
2137
  const result = options.runEntry ? options.runEntry(command2, cwd, childEnv) : await runInstallEntry(command2, cwd, childEnv, installer, readTokens(dir));
1882
2138
  if (result.outcome) outcome = validateInstallerOutcome(result.outcome);
1883
2139
  return result.ok && (result.code === void 0 || result.code === 0) && !result.outcome?.operationFailed && !result.outcome?.failed;
@@ -1934,7 +2190,7 @@ function payloadFileAsCommand(entry2, payload) {
1934
2190
  if (!first || first === "$self") return null;
1935
2191
  if (first.includes("/") || first.includes("\\")) return null;
1936
2192
  const candidate = join6(payload, first);
1937
- if (!existsSync6(candidate)) return null;
2193
+ if (!existsSync7(candidate)) return null;
1938
2194
  if (NEVER_A_PROGRAM.test(first)) return first;
1939
2195
  if (process.platform === "win32") return null;
1940
2196
  try {
@@ -1995,17 +2251,22 @@ async function finishLastMile(config, dir, options, installer, version, unchange
1995
2251
  return succeeded ? outcome?.operationFailed || outcome?.failed ? 1 : 0 : result.code || 1;
1996
2252
  }
1997
2253
  async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2254
+ installer.start();
1998
2255
  const [command2, ...args] = entry2;
1999
2256
  const shell = needsShell(command2);
2000
2257
  const progress = !(process.platform === "win32" && shell);
2001
2258
  const outcomeDir = mkdtempSync2(join6(tmpdir4(), "mm-installer-outcome-"));
2002
2259
  const outcomeFile = join6(outcomeDir, "outcome.json");
2260
+ const progressFile = join6(outcomeDir, "progress.jsonl");
2003
2261
  const childEnv = { ...env, MM_INSTALLER_OUTCOME_FILE: outcomeFile };
2004
2262
  delete childEnv.MM_FACE_TRANSCRIPT;
2263
+ delete childEnv.MM_PROGRESS_FILE;
2005
2264
  delete childEnv.MM_PROGRESS_FD;
2006
2265
  delete childEnv.MM_PROGRESS_PROTOCOL;
2007
2266
  if (progress) Object.assign(childEnv, { MM_PROGRESS_FD: "3", MM_PROGRESS_PROTOCOL: "1" });
2267
+ else Object.assign(childEnv, { MM_PROGRESS_FILE: progressFile, MM_PROGRESS_PROTOCOL: "1" });
2008
2268
  const secrets = [tokens?.accessToken, tokens?.refreshToken].filter((value) => Boolean(value));
2269
+ let progressError;
2009
2270
  const redact = (text) => secrets.reduce((safe, value) => safe.replaceAll(value, "[redacted]"), text);
2010
2271
  try {
2011
2272
  const result = await new Promise((resolve3) => {
@@ -2026,24 +2287,44 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2026
2287
  }
2027
2288
  partial = held ? safe.slice(-held) : "";
2028
2289
  const visible = held ? safe.slice(0, -held) : safe;
2029
- if (visible) installer.relay(visible, channel2, false);
2290
+ if (visible) installer.relayChunk(visible, channel2);
2030
2291
  }).on("end", () => {
2031
- if (partial) installer.relay("[redacted]", channel2, false);
2292
+ if (partial) installer.relayChunk("[redacted]", channel2);
2032
2293
  });
2033
2294
  }
2034
2295
  let pending = "";
2296
+ let progressOffset = 0;
2297
+ const progressDecoder = new StringDecoder("utf8");
2298
+ const receive = (text) => {
2299
+ pending += text;
2300
+ const end = pending.lastIndexOf("\n");
2301
+ if (end >= 0) {
2302
+ for (const record of parseProgress(pending.slice(0, end))) installer.milestone({ ...record, step: redact(record.step), ...record.measure ? { measure: redact(record.measure) } : {} });
2303
+ pending = pending.slice(end + 1);
2304
+ }
2305
+ if (pending.length > 65536) pending = "";
2306
+ };
2307
+ const pollProgress = () => {
2308
+ if (progressError) return;
2309
+ try {
2310
+ if (!existsSync7(progressFile)) return;
2311
+ const bytes = readFileSync7(progressFile);
2312
+ receive(progressDecoder.write(bytes.subarray(progressOffset)));
2313
+ progressOffset = bytes.length;
2314
+ } catch {
2315
+ progressError = "installer progress: could not read child progress";
2316
+ if (timer) clearInterval(timer);
2317
+ }
2318
+ };
2319
+ const timer = progress ? void 0 : setInterval(pollProgress, 90);
2320
+ child.once("close", () => {
2321
+ if (timer) clearInterval(timer);
2322
+ pollProgress();
2323
+ });
2035
2324
  const channel = child.stdio[3];
2036
2325
  if (channel && "setEncoding" in channel) {
2037
2326
  channel.setEncoding("utf8");
2038
- channel.on("data", (text) => {
2039
- pending += text;
2040
- const end = pending.lastIndexOf("\n");
2041
- if (end >= 0) {
2042
- for (const record of parseProgress(pending.slice(0, end))) installer.milestone({ ...record, step: redact(record.step) });
2043
- pending = pending.slice(end + 1);
2044
- }
2045
- if (pending.length > 65536) pending = "";
2046
- });
2327
+ channel.on("data", receive);
2047
2328
  }
2048
2329
  child.on("error", (error) => resolve3({ ok: false, error: error.message }));
2049
2330
  child.on("close", (code) => resolve3({
@@ -2052,6 +2333,7 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2052
2333
  ...code !== 0 ? { error: `exit code ${code}` } : {}
2053
2334
  }));
2054
2335
  });
2336
+ if (progressError) return { ...result, ok: false, code: result.code || 1, error: progressError };
2055
2337
  try {
2056
2338
  const outcome = readInstallerOutcome(outcomeFile);
2057
2339
  return { ...result, ...outcome ? { outcome } : {} };
@@ -2059,12 +2341,13 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2059
2341
  return { ...result, ok: false, code: result.code || 1, error: "installer outcome: invalid child result" };
2060
2342
  }
2061
2343
  } finally {
2062
- if (existsSync6(outcomeFile)) unlinkSync2(outcomeFile);
2344
+ if (existsSync7(progressFile)) unlinkSync2(progressFile);
2345
+ if (existsSync7(outcomeFile)) unlinkSync2(outcomeFile);
2063
2346
  rmdirSync(outcomeDir);
2064
2347
  }
2065
2348
  }
2066
2349
  function doForward(config, dir, options, command2, argv2, print, unlock) {
2067
- if (command2 && existsSync6(command2)) {
2350
+ if (command2 && existsSync7(command2)) {
2068
2351
  print(`${command2} is a file, not a command \u2014 did you mean \`--run ${command2}\`?`);
2069
2352
  return 2;
2070
2353
  }
@@ -2144,7 +2427,7 @@ function doDoctor(config, dir, options, print, unlock) {
2144
2427
  function doLauncherDoctor(config, dir, options, print) {
2145
2428
  const tokens = readTokens(dir);
2146
2429
  const state = readState(dir);
2147
- const payloadPresent = existsSync6(payloadDir(dir));
2430
+ const payloadPresent = existsSync7(payloadDir(dir));
2148
2431
  print(`product: ${config.product}`);
2149
2432
  print(`host: ${config.host}`);
2150
2433
  print(`login: ${config.loginKind}`);