@mutmutco/installer-launcher 0.1.14 → 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
@@ -6,7 +6,7 @@ import { randomUUID as randomUUID4 } from "node:crypto";
6
6
 
7
7
  // src/acquisition.ts
8
8
  import { createHash as createHash3, randomUUID as randomUUID3 } from "node:crypto";
9
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, unlinkSync, realpathSync as realpathSync2 } from "node:fs";
9
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, unlinkSync, realpathSync as realpathSync2 } from "node:fs";
10
10
  import { join as join4, resolve, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname2 } from "node:path";
11
11
 
12
12
  // src/runtime.ts
@@ -194,7 +194,7 @@ function wipeProductDir(dir) {
194
194
 
195
195
  // src/payload.ts
196
196
  import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
197
- import { mkdirSync as mkdirSync3, renameSync as renameSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
197
+ import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, renameSync as renameSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
198
198
  import { tmpdir as tmpdir2 } from "node:os";
199
199
  import { dirname, join as join3 } from "node:path";
200
200
 
@@ -403,6 +403,17 @@ async function fetchVerifiedManifest(config, accessToken, fetchImpl = fetch) {
403
403
  }
404
404
  return manifest;
405
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
+ }
406
417
  async function fetchFileBytes(config, accessToken, path, fetchImpl, onProgress, expectedSize) {
407
418
  const encoded = path.split("/").map(encodeURIComponent).join("/");
408
419
  const response = await fetchImpl(`${config.host}/release/${encoded}`, {
@@ -414,7 +425,36 @@ async function fetchFileBytes(config, accessToken, path, fetchImpl, onProgress,
414
425
  }
415
426
  return readDownload(response, onProgress, expectedSize);
416
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
+ }
417
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
+ }
418
458
  const fetchImpl = options.fetchImpl ?? fetch;
419
459
  const staging = join3(tmpdir2(), `launcher-${config.product}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
420
460
  mkdirSync3(staging, { recursive: true });
@@ -431,7 +471,6 @@ async function downloadAndUnpack(config, dir, manifest, accessToken, options = {
431
471
  mkdirSync3(dirname(dest), { recursive: true });
432
472
  writeFileSync3(dest, bytes);
433
473
  }
434
- const target = join3(dir, "payload");
435
474
  mkdirSync3(dir, { recursive: true });
436
475
  rmSync3(target, { force: true, recursive: true });
437
476
  renameSync3(staging, target);
@@ -439,6 +478,7 @@ async function downloadAndUnpack(config, dir, manifest, accessToken, options = {
439
478
  rmSync3(staging, { force: true, recursive: true });
440
479
  throw error;
441
480
  }
481
+ if (options.cacheDir) rememberPayloadVersion(options.cacheDir, manifest.version, target);
442
482
  return manifest.version;
443
483
  }
444
484
 
@@ -448,7 +488,7 @@ function reusableCandidate(dir, manifest) {
448
488
  const saved = JSON.parse(readFileSync4(join4(dir, "acquisition-cache.json"), "utf8"));
449
489
  if (saved.signature !== manifest.signature) return null;
450
490
  const root = candidateRoot(dir, saved.candidate);
451
- if (!existsSync4(join4(root, "acquired.json"))) return null;
491
+ if (!existsSync5(join4(root, "acquired.json"))) return null;
452
492
  for (const file of manifest.files) {
453
493
  if (!safePath(file.path) || !verifyFileBytes(file, readFileSync4(join4(root, "payload", file.path)))) return null;
454
494
  }
@@ -555,7 +595,7 @@ function acquisitionRepairCommand(dir, version) {
555
595
  return argv(realpathSync2(state.acquired.node), productRoot, acquisition.runEntry, acquisition.repairArgs, prefix, version);
556
596
  }
557
597
  async function recoverAcquisition(dir, run2, env) {
558
- if (!existsSync4(pendingPath(dir))) return;
598
+ if (!existsSync5(pendingPath(dir))) return;
559
599
  const pending = JSON.parse(readFileSync4(pendingPath(dir), "utf8"));
560
600
  if (pending.schema !== 1 || typeof pending.version !== "string" || typeof pending.node !== "string") throw new Error("invalid pending acquisition receipt");
561
601
  const root = candidateRoot(dir, pending.candidate);
@@ -569,7 +609,7 @@ async function recoverAcquisition(dir, run2, env) {
569
609
  const command2 = argv(pending.node, packageRoot(prefix, acquisition), acquisition.rollbackEntry, acquisition.rollbackArgs, prefix, pending.version);
570
610
  if (!await run2(command2, root, runtimeEnvironment(pending.node, env))) throw new Error("installation recovery is pending; product rollback did not finish");
571
611
  if (pending.previous) writeState(dir, pending.previous);
572
- else if (existsSync4(statePath(dir))) unlinkSync(statePath(dir));
612
+ else if (existsSync5(statePath(dir))) unlinkSync(statePath(dir));
573
613
  unlinkSync(pendingPath(dir));
574
614
  }
575
615
  async function installAcquisition(dir, candidate, version, acquisition, options) {
@@ -579,7 +619,7 @@ async function installAcquisition(dir, candidate, version, acquisition, options)
579
619
  const prefix = join4(root, "prefix");
580
620
  const prepared = join4(root, "acquired.json");
581
621
  const archiveHash = createHash3("sha256").update(readFileSync4(join4(payload, acquisition.archive))).digest("hex");
582
- const reused = existsSync4(prepared);
622
+ const reused = existsSync5(prepared);
583
623
  if (reused && JSON.parse(readFileSync4(prepared, "utf8")).archiveHash !== archiveHash) throw new Error("cached acquisition archive changed");
584
624
  if (!reused) mkdirSync4(prefix);
585
625
  const runtime = await (options.runtime ?? acquireRuntime)(dir, options.fetchImpl, options.onProgress);
@@ -632,7 +672,7 @@ async function installAcquisition(dir, candidate, version, acquisition, options)
632
672
 
633
673
  // src/index.ts
634
674
  import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
635
- 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";
636
676
  import { tmpdir as tmpdir4 } from "node:os";
637
677
  import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
638
678
  import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "node:url";
@@ -831,7 +871,26 @@ var RELAY_INDENT = " ".repeat(TITLE_COLUMN - 1);
831
871
  // ../face/src/spinner.ts
832
872
  import { appendFileSync as appendFileSync2, writeSync as writeSync2 } from "node:fs";
833
873
  import { Worker } from "node:worker_threads";
834
- 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
835
894
  var WORKER_SOURCE = `
836
895
  const { parentPort, workerData } = require('node:worker_threads');
837
896
  const { writeSync, appendFileSync } = require('node:fs');
@@ -856,8 +915,10 @@ parentPort.on('message', (next) => {
856
915
  });
857
916
  setInterval(draw, workerData.intervalMs);
858
917
  `;
859
- function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath }) {
918
+ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath, env = process.env, platform = process.platform }) {
860
919
  animate = animate && !face.nested && !face.emitsProgress;
920
+ const glyphs = spinnerFramesFor(env, platform);
921
+ const ascii = glyphs[0] === ASCII_SPINNER_FRAMES[0];
861
922
  let timer = null;
862
923
  let worker = null;
863
924
  let control = null;
@@ -875,7 +936,8 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
875
936
  };
876
937
  const render = (text, measure) => {
877
938
  const line = face.step(text, measure ?? "__elapsed__", "note").split("\n")[0];
878
- frames = line ? FRAMES.map((glyph) => `\r\x1B[2K${line.replace(GLYPH.dot, glyph)}`) : [];
939
+ const prepared = line && ascii ? line.replaceAll(GLYPH.bar, "|") : line;
940
+ frames = prepared ? glyphs.map((glyph) => `\r\x1B[2K${prepared.replace(GLYPH.dot, glyph)}`) : [];
879
941
  };
880
942
  const draw = () => {
881
943
  if (animate && !suspended && frames.length) write(frames[frame++ % frames.length].replace("__elapsed__", `${Math.floor((Date.now() - started) / 1e3)}s`));
@@ -952,7 +1014,6 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
952
1014
  }
953
1015
  };
954
1016
  }
955
- var SPINNER_FRAMES = Object.freeze([...FRAMES]);
956
1017
 
957
1018
  // ../face/src/conformance.ts
958
1019
  var ALLOWED = new Set(Object.values(GLYPH));
@@ -1056,6 +1117,14 @@ var PHASES = {
1056
1117
  doctor: ["Checking health", "Checked health"],
1057
1118
  rollback: ["Restoring the previous version", "Restored the previous version"]
1058
1119
  };
1120
+ var UPDATE_CHECK_PHASES = /* @__PURE__ */ new Set([
1121
+ "preflight",
1122
+ "resolve",
1123
+ "verify-release",
1124
+ "download",
1125
+ "sign-in",
1126
+ "check"
1127
+ ]);
1059
1128
  function createInstallerRun(value, options = {}) {
1060
1129
  const declaration = validateInstallerProduct(value);
1061
1130
  const env = options.env ?? process.env;
@@ -1091,6 +1160,7 @@ function createInstallerRun(value, options = {}) {
1091
1160
  };
1092
1161
  const spinner = createSpinner(face, {
1093
1162
  animate: tty && env.TERM !== "dumb" && (options.animate ?? Boolean(process.stderr.isTTY)),
1163
+ env,
1094
1164
  ...options.write ? { stream: { write: (text) => {
1095
1165
  emit(String(text), "spinner");
1096
1166
  return true;
@@ -1109,6 +1179,7 @@ function createInstallerRun(value, options = {}) {
1109
1179
  };
1110
1180
  let started = false;
1111
1181
  let finished = false;
1182
+ let operation = options.operation;
1112
1183
  const start = () => {
1113
1184
  if (started || finished) return;
1114
1185
  started = true;
@@ -1116,7 +1187,7 @@ function createInstallerRun(value, options = {}) {
1116
1187
  const welcome = face.welcome();
1117
1188
  if (tty) lines(welcome);
1118
1189
  else if (welcome.length) {
1119
- const warm = options.operation === "install" ? face.identity.installWarm : face.identity.warm;
1190
+ const warm = operation === "install" ? face.identity.installWarm : face.identity.warm;
1120
1191
  lines([`${face.identity.name} - Mutatis Mutandis`, warm.replaceAll("\u2014", "-").replaceAll("\u2026", "...")]);
1121
1192
  }
1122
1193
  };
@@ -1132,13 +1203,20 @@ function createInstallerRun(value, options = {}) {
1132
1203
  get errors() {
1133
1204
  return [...errors];
1134
1205
  },
1206
+ get operation() {
1207
+ return operation;
1208
+ },
1209
+ set operation(value2) {
1210
+ operation = value2;
1211
+ },
1135
1212
  start,
1136
1213
  phase(id, facts = {}) {
1137
1214
  if (finished) throw new Error("installer run already finished");
1138
1215
  if (!Object.hasOwn(PHASES, id)) throw new Error("installer run: unknown phase");
1139
1216
  start();
1140
1217
  const state = facts.state ?? "ok";
1141
- 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];
1142
1220
  if (state === "running") {
1143
1221
  if (face.progress(title, facts.measure ?? null)) return;
1144
1222
  if (activeTitle === title) spinner.say(title, facts.measure ?? null);
@@ -1148,6 +1226,10 @@ function createInstallerRun(value, options = {}) {
1148
1226
  }
1149
1227
  return;
1150
1228
  }
1229
+ if (fold && id !== "check") {
1230
+ if (facts.detail) run2.relay(facts.detail);
1231
+ return;
1232
+ }
1151
1233
  if (!face.continues(id, state)) durable(title, facts.seconds ?? facts.measure ?? null, state);
1152
1234
  if (facts.detail) run2.relay(facts.detail);
1153
1235
  },
@@ -1286,7 +1368,7 @@ function createInstallerRun(value, options = {}) {
1286
1368
 
1287
1369
  // src/autoupdate.ts
1288
1370
  import { spawnSync as spawnSync2 } from "node:child_process";
1289
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "node:fs";
1371
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "node:fs";
1290
1372
  import { tmpdir as tmpdir3 } from "node:os";
1291
1373
  import { join as join5 } from "node:path";
1292
1374
  function schedulePlatform(override) {
@@ -1398,11 +1480,11 @@ function querySchedule(config, options = {}) {
1398
1480
  }
1399
1481
  if (platform === "darwin") {
1400
1482
  const plist = join5(home, "Library", "LaunchAgents", `${scheduleLabel(config)}.plist`);
1401
- if (!existsSync5(plist)) return { supported: true, enabled: false };
1483
+ if (!existsSync6(plist)) return { supported: true, enabled: false };
1402
1484
  return { supported: true, enabled: true, cadence: "hourly" };
1403
1485
  }
1404
1486
  const timer = join5(home, ".config", "systemd", "user", `${scheduleLabel(config)}.timer`);
1405
- if (!existsSync5(timer)) return { supported: true, enabled: false };
1487
+ if (!existsSync6(timer)) return { supported: true, enabled: false };
1406
1488
  const state = { supported: true, enabled: true, cadence: "hourly" };
1407
1489
  const shown = exec("systemctl", ["--user", "show", `${scheduleLabel(config)}.service`, "-p", "ExecMainStartTimestamp", "--value"]);
1408
1490
  const stamp = (shown.stdout ?? "").trim();
@@ -1694,7 +1776,7 @@ function page(title, body) {
1694
1776
  }
1695
1777
 
1696
1778
  // src/index.ts
1697
- var LAUNCHER_VERSION = true ? "0.1.14" : readVersionFromPackage();
1779
+ var LAUNCHER_VERSION = true ? "0.1.15" : readVersionFromPackage();
1698
1780
  function defaultPrint(message) {
1699
1781
  process.stdout.write(`${message}
1700
1782
  `);
@@ -1783,7 +1865,7 @@ async function runFile(file, args, printErr) {
1783
1865
  return 1;
1784
1866
  }
1785
1867
  const abs = resolve2(process.cwd(), file);
1786
- if (!existsSync6(abs)) {
1868
+ if (!existsSync7(abs)) {
1787
1869
  printErr(`cannot run ${file}: no such file.`);
1788
1870
  return 1;
1789
1871
  }
@@ -1915,7 +1997,7 @@ function resolveEntry(entry2) {
1915
1997
  function needsShell(command2) {
1916
1998
  if (process.platform !== "win32") return false;
1917
1999
  if (/\.(cmd|bat)$/i.test(command2)) return true;
1918
- return !existsSync6(command2);
2000
+ return !existsSync7(command2);
1919
2001
  }
1920
2002
  function quoteForShell(arg) {
1921
2003
  return /[\s"&|<>^%]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
@@ -1995,8 +2077,27 @@ async function installOrUpdate(config, dir, options, print, update) {
1995
2077
  let version = readState(dir)?.version ?? "unknown";
1996
2078
  installer.start();
1997
2079
  try {
2080
+ const current = readState(dir);
2081
+ const payloadPresent = existsSync7(payloadDir(dir));
1998
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
+ }
1999
2099
  installer.phase("sign-in", { state: "running" });
2100
+ started = Date.now();
2000
2101
  const accessToken = await fetchAccessTokenOrLogin(config, dir, options, print, installer);
2001
2102
  installer.phase("sign-in", { seconds: (Date.now() - started) / 1e3 });
2002
2103
  installer.phase("resolve", { state: "running" });
@@ -2004,8 +2105,7 @@ async function installOrUpdate(config, dir, options, print, update) {
2004
2105
  const manifest = await fetchVerifiedManifest(config, accessToken, fetchImpl);
2005
2106
  version = manifest.version;
2006
2107
  installer.phase("resolve", { seconds: (Date.now() - started) / 1e3 });
2007
- const current = readState(dir);
2008
- const unchanged = current?.version === version && existsSync6(payloadDir(dir));
2108
+ const unchanged = current?.version === version && existsSync7(payloadDir(dir));
2009
2109
  if (unchanged && current.acquired) {
2010
2110
  installer.surface({ id: config.product, from: version, to: version, state: "current" });
2011
2111
  return await finishLastMile(config, dir, options, installer, version, true, acquisitionRepairCommand(dir, version));
@@ -2020,8 +2120,8 @@ async function installOrUpdate(config, dir, options, print, update) {
2020
2120
  state: "running",
2021
2121
  measure: total ? `${Math.floor(done / total * 100)}% \xB7 ${done}/${total} B` : `${done} B`
2022
2122
  });
2023
- if (!cached) await downloadAndUnpack(config, candidateDir, manifest, accessToken, { fetchImpl, onProgress });
2024
- const acquisition = existsSync6(join6(candidateDir, "payload", "payload.json")) ? readAcquisition(join6(candidateDir, "payload")) : null;
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;
2025
2125
  if (acquisition) {
2026
2126
  rememberCandidate(dir, candidate, manifest);
2027
2127
  installer.phase("download", { seconds: (Date.now() - started) / 1e3 });
@@ -2090,7 +2190,7 @@ function payloadFileAsCommand(entry2, payload) {
2090
2190
  if (!first || first === "$self") return null;
2091
2191
  if (first.includes("/") || first.includes("\\")) return null;
2092
2192
  const candidate = join6(payload, first);
2093
- if (!existsSync6(candidate)) return null;
2193
+ if (!existsSync7(candidate)) return null;
2094
2194
  if (NEVER_A_PROGRAM.test(first)) return first;
2095
2195
  if (process.platform === "win32") return null;
2096
2196
  try {
@@ -2207,7 +2307,7 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2207
2307
  const pollProgress = () => {
2208
2308
  if (progressError) return;
2209
2309
  try {
2210
- if (!existsSync6(progressFile)) return;
2310
+ if (!existsSync7(progressFile)) return;
2211
2311
  const bytes = readFileSync7(progressFile);
2212
2312
  receive(progressDecoder.write(bytes.subarray(progressOffset)));
2213
2313
  progressOffset = bytes.length;
@@ -2241,13 +2341,13 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2241
2341
  return { ...result, ok: false, code: result.code || 1, error: "installer outcome: invalid child result" };
2242
2342
  }
2243
2343
  } finally {
2244
- if (existsSync6(progressFile)) unlinkSync2(progressFile);
2245
- if (existsSync6(outcomeFile)) unlinkSync2(outcomeFile);
2344
+ if (existsSync7(progressFile)) unlinkSync2(progressFile);
2345
+ if (existsSync7(outcomeFile)) unlinkSync2(outcomeFile);
2246
2346
  rmdirSync(outcomeDir);
2247
2347
  }
2248
2348
  }
2249
2349
  function doForward(config, dir, options, command2, argv2, print, unlock) {
2250
- if (command2 && existsSync6(command2)) {
2350
+ if (command2 && existsSync7(command2)) {
2251
2351
  print(`${command2} is a file, not a command \u2014 did you mean \`--run ${command2}\`?`);
2252
2352
  return 2;
2253
2353
  }
@@ -2327,7 +2427,7 @@ function doDoctor(config, dir, options, print, unlock) {
2327
2427
  function doLauncherDoctor(config, dir, options, print) {
2328
2428
  const tokens = readTokens(dir);
2329
2429
  const state = readState(dir);
2330
- const payloadPresent = existsSync6(payloadDir(dir));
2430
+ const payloadPresent = existsSync7(payloadDir(dir));
2331
2431
  print(`product: ${config.product}`);
2332
2432
  print(`host: ${config.host}`);
2333
2433
  print(`login: ${config.loginKind}`);
@@ -435,6 +435,17 @@ async function fetchVerifiedManifest(config, accessToken, fetchImpl = fetch) {
435
435
  }
436
436
  return manifest;
437
437
  }
438
+ async function fetchReleaseVersion(config, fetchImpl = fetch) {
439
+ try {
440
+ const response = await fetchImpl(`${config.host}/release/version`);
441
+ if (!response.ok) return null;
442
+ const json = await response.json();
443
+ const version = typeof json?.version === "string" ? json.version.trim() : "";
444
+ return version || null;
445
+ } catch {
446
+ return null;
447
+ }
448
+ }
438
449
  async function fetchFileBytes(config, accessToken, path, fetchImpl, onProgress, expectedSize) {
439
450
  const encoded = path.split("/").map(encodeURIComponent).join("/");
440
451
  const response = await fetchImpl(`${config.host}/release/${encoded}`, {
@@ -446,7 +457,36 @@ async function fetchFileBytes(config, accessToken, path, fetchImpl, onProgress,
446
457
  }
447
458
  return readDownload(response, onProgress, expectedSize);
448
459
  }
460
+ var VERSION_CACHE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
461
+ function versionCacheRoot(dir, version) {
462
+ return VERSION_CACHE_RE.test(version) ? (0, import_node_path3.join)(dir, "payload-cache", version) : "";
463
+ }
464
+ function cachedPayloadForVersion(dir, version) {
465
+ const root = versionCacheRoot(dir, version);
466
+ if (!root) return null;
467
+ const payload = (0, import_node_path3.join)(root, "payload");
468
+ return (0, import_node_fs4.existsSync)(payload) ? payload : null;
469
+ }
470
+ function rememberPayloadVersion(dir, version, payload) {
471
+ try {
472
+ const root = versionCacheRoot(dir, version);
473
+ if (!root) return;
474
+ const dest = (0, import_node_path3.join)(root, "payload");
475
+ if ((0, import_node_fs4.existsSync)(dest)) return;
476
+ (0, import_node_fs4.mkdirSync)(root, { recursive: true });
477
+ (0, import_node_fs4.cpSync)(payload, dest, { recursive: true });
478
+ } catch {
479
+ }
480
+ }
449
481
  async function downloadAndUnpack(config, dir, manifest, accessToken, options = {}) {
482
+ const target = (0, import_node_path3.join)(dir, "payload");
483
+ const cached = options.cacheDir ? cachedPayloadForVersion(options.cacheDir, manifest.version) : null;
484
+ if (cached) {
485
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
486
+ (0, import_node_fs4.rmSync)(target, { force: true, recursive: true });
487
+ (0, import_node_fs4.cpSync)(cached, target, { recursive: true });
488
+ return manifest.version;
489
+ }
450
490
  const fetchImpl = options.fetchImpl ?? fetch;
451
491
  const staging = (0, import_node_path3.join)((0, import_node_os2.tmpdir)(), `launcher-${config.product}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
452
492
  (0, import_node_fs4.mkdirSync)(staging, { recursive: true });
@@ -463,7 +503,6 @@ async function downloadAndUnpack(config, dir, manifest, accessToken, options = {
463
503
  (0, import_node_fs4.mkdirSync)((0, import_node_path3.dirname)(dest), { recursive: true });
464
504
  (0, import_node_fs4.writeFileSync)(dest, bytes);
465
505
  }
466
- const target = (0, import_node_path3.join)(dir, "payload");
467
506
  (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
468
507
  (0, import_node_fs4.rmSync)(target, { force: true, recursive: true });
469
508
  (0, import_node_fs4.renameSync)(staging, target);
@@ -471,6 +510,7 @@ async function downloadAndUnpack(config, dir, manifest, accessToken, options = {
471
510
  (0, import_node_fs4.rmSync)(staging, { force: true, recursive: true });
472
511
  throw error;
473
512
  }
513
+ if (options.cacheDir) rememberPayloadVersion(options.cacheDir, manifest.version, target);
474
514
  return manifest.version;
475
515
  }
476
516
 
@@ -863,7 +903,26 @@ var RELAY_INDENT = " ".repeat(TITLE_COLUMN - 1);
863
903
  // ../face/src/spinner.ts
864
904
  var import_node_fs7 = require("node:fs");
865
905
  var import_node_worker_threads = require("node:worker_threads");
866
- var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
906
+
907
+ // ../face/src/encoding.ts
908
+ var UNICODE_SPINNER_FRAMES = Object.freeze(["\u25D2", "\u25D0", "\u25D3", "\u25D1"]);
909
+ var ASCII_SPINNER_FRAMES = Object.freeze(["-", "\\", "|", "/"]);
910
+ function consoleRendersUnicode(env = process.env, platform = process.platform) {
911
+ if (env.MM_FACE_ASCII === "1" || env.NO_UNICODE === "1") return false;
912
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
913
+ if (locale === "C" || locale === "POSIX" || /^C\./u.test(locale) || /^POSIX\./u.test(locale)) return false;
914
+ if (/cp437|ibm437|\b437\b/iu.test(locale)) return false;
915
+ if (/utf-?8/iu.test(locale)) return true;
916
+ if (platform === "win32") {
917
+ return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode" || env.CHCP === "65001" || env.ConEmuANSI === "ON";
918
+ }
919
+ return true;
920
+ }
921
+ function spinnerFramesFor(env = process.env, platform = process.platform) {
922
+ return consoleRendersUnicode(env, platform) ? UNICODE_SPINNER_FRAMES : ASCII_SPINNER_FRAMES;
923
+ }
924
+
925
+ // ../face/src/spinner.ts
867
926
  var WORKER_SOURCE = `
868
927
  const { parentPort, workerData } = require('node:worker_threads');
869
928
  const { writeSync, appendFileSync } = require('node:fs');
@@ -888,8 +947,10 @@ parentPort.on('message', (next) => {
888
947
  });
889
948
  setInterval(draw, workerData.intervalMs);
890
949
  `;
891
- function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath }) {
950
+ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath, env = process.env, platform = process.platform }) {
892
951
  animate = animate && !face.nested && !face.emitsProgress;
952
+ const glyphs = spinnerFramesFor(env, platform);
953
+ const ascii = glyphs[0] === ASCII_SPINNER_FRAMES[0];
893
954
  let timer = null;
894
955
  let worker = null;
895
956
  let control = null;
@@ -907,7 +968,8 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
907
968
  };
908
969
  const render = (text, measure) => {
909
970
  const line = face.step(text, measure ?? "__elapsed__", "note").split("\n")[0];
910
- frames = line ? FRAMES.map((glyph) => `\r\x1B[2K${line.replace(GLYPH.dot, glyph)}`) : [];
971
+ const prepared = line && ascii ? line.replaceAll(GLYPH.bar, "|") : line;
972
+ frames = prepared ? glyphs.map((glyph) => `\r\x1B[2K${prepared.replace(GLYPH.dot, glyph)}`) : [];
911
973
  };
912
974
  const draw = () => {
913
975
  if (animate && !suspended && frames.length) write(frames[frame++ % frames.length].replace("__elapsed__", `${Math.floor((Date.now() - started) / 1e3)}s`));
@@ -984,7 +1046,6 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
984
1046
  }
985
1047
  };
986
1048
  }
987
- var SPINNER_FRAMES = Object.freeze([...FRAMES]);
988
1049
 
989
1050
  // ../face/src/conformance.ts
990
1051
  var ALLOWED = new Set(Object.values(GLYPH));
@@ -1088,6 +1149,14 @@ var PHASES = {
1088
1149
  doctor: ["Checking health", "Checked health"],
1089
1150
  rollback: ["Restoring the previous version", "Restored the previous version"]
1090
1151
  };
1152
+ var UPDATE_CHECK_PHASES = /* @__PURE__ */ new Set([
1153
+ "preflight",
1154
+ "resolve",
1155
+ "verify-release",
1156
+ "download",
1157
+ "sign-in",
1158
+ "check"
1159
+ ]);
1091
1160
  function createInstallerRun(value, options = {}) {
1092
1161
  const declaration = validateInstallerProduct(value);
1093
1162
  const env = options.env ?? process.env;
@@ -1123,6 +1192,7 @@ function createInstallerRun(value, options = {}) {
1123
1192
  };
1124
1193
  const spinner = createSpinner(face, {
1125
1194
  animate: tty && env.TERM !== "dumb" && (options.animate ?? Boolean(process.stderr.isTTY)),
1195
+ env,
1126
1196
  ...options.write ? { stream: { write: (text) => {
1127
1197
  emit(String(text), "spinner");
1128
1198
  return true;
@@ -1141,6 +1211,7 @@ function createInstallerRun(value, options = {}) {
1141
1211
  };
1142
1212
  let started = false;
1143
1213
  let finished = false;
1214
+ let operation = options.operation;
1144
1215
  const start = () => {
1145
1216
  if (started || finished) return;
1146
1217
  started = true;
@@ -1148,7 +1219,7 @@ function createInstallerRun(value, options = {}) {
1148
1219
  const welcome = face.welcome();
1149
1220
  if (tty) lines(welcome);
1150
1221
  else if (welcome.length) {
1151
- const warm = options.operation === "install" ? face.identity.installWarm : face.identity.warm;
1222
+ const warm = operation === "install" ? face.identity.installWarm : face.identity.warm;
1152
1223
  lines([`${face.identity.name} - Mutatis Mutandis`, warm.replaceAll("\u2014", "-").replaceAll("\u2026", "...")]);
1153
1224
  }
1154
1225
  };
@@ -1164,13 +1235,20 @@ function createInstallerRun(value, options = {}) {
1164
1235
  get errors() {
1165
1236
  return [...errors];
1166
1237
  },
1238
+ get operation() {
1239
+ return operation;
1240
+ },
1241
+ set operation(value2) {
1242
+ operation = value2;
1243
+ },
1167
1244
  start,
1168
1245
  phase(id, facts = {}) {
1169
1246
  if (finished) throw new Error("installer run already finished");
1170
1247
  if (!Object.hasOwn(PHASES, id)) throw new Error("installer run: unknown phase");
1171
1248
  start();
1172
1249
  const state = facts.state ?? "ok";
1173
- const title = PHASES[id][state === "ok" ? 1 : 0];
1250
+ const fold = operation === "update" && UPDATE_CHECK_PHASES.has(id) && state !== "fail";
1251
+ const title = PHASES[fold ? "check" : id][state === "ok" ? 1 : 0];
1174
1252
  if (state === "running") {
1175
1253
  if (face.progress(title, facts.measure ?? null)) return;
1176
1254
  if (activeTitle === title) spinner.say(title, facts.measure ?? null);
@@ -1180,6 +1258,10 @@ function createInstallerRun(value, options = {}) {
1180
1258
  }
1181
1259
  return;
1182
1260
  }
1261
+ if (fold && id !== "check") {
1262
+ if (facts.detail) run2.relay(facts.detail);
1263
+ return;
1264
+ }
1183
1265
  if (!face.continues(id, state)) durable(title, facts.seconds ?? facts.measure ?? null, state);
1184
1266
  if (facts.detail) run2.relay(facts.detail);
1185
1267
  },
@@ -1726,7 +1808,7 @@ function page(title, body) {
1726
1808
  }
1727
1809
 
1728
1810
  // src/index.ts
1729
- var LAUNCHER_VERSION = true ? "0.1.14" : readVersionFromPackage();
1811
+ var LAUNCHER_VERSION = true ? "0.1.15" : readVersionFromPackage();
1730
1812
  function defaultPrint(message) {
1731
1813
  process.stdout.write(`${message}
1732
1814
  `);
@@ -2027,8 +2109,27 @@ async function installOrUpdate(config, dir, options, print, update) {
2027
2109
  let version = readState(dir)?.version ?? "unknown";
2028
2110
  installer.start();
2029
2111
  try {
2112
+ const current = readState(dir);
2113
+ const payloadPresent = (0, import_node_fs11.existsSync)(payloadDir(dir));
2030
2114
  let started = Date.now();
2115
+ installer.phase("resolve", { state: "running" });
2116
+ const pointer = await fetchReleaseVersion(config, fetchImpl);
2117
+ if (pointer && current?.version === pointer && payloadPresent) {
2118
+ version = pointer;
2119
+ installer.phase("resolve", { seconds: (Date.now() - started) / 1e3 });
2120
+ installer.surface({ id: config.product, from: version, to: version, state: "current" });
2121
+ return await finishLastMile(
2122
+ config,
2123
+ dir,
2124
+ options,
2125
+ installer,
2126
+ version,
2127
+ true,
2128
+ current.acquired ? acquisitionRepairCommand(dir, version) : void 0
2129
+ );
2130
+ }
2031
2131
  installer.phase("sign-in", { state: "running" });
2132
+ started = Date.now();
2032
2133
  const accessToken = await fetchAccessTokenOrLogin(config, dir, options, print, installer);
2033
2134
  installer.phase("sign-in", { seconds: (Date.now() - started) / 1e3 });
2034
2135
  installer.phase("resolve", { state: "running" });
@@ -2036,7 +2137,6 @@ async function installOrUpdate(config, dir, options, print, update) {
2036
2137
  const manifest = await fetchVerifiedManifest(config, accessToken, fetchImpl);
2037
2138
  version = manifest.version;
2038
2139
  installer.phase("resolve", { seconds: (Date.now() - started) / 1e3 });
2039
- const current = readState(dir);
2040
2140
  const unchanged = current?.version === version && (0, import_node_fs11.existsSync)(payloadDir(dir));
2041
2141
  if (unchanged && current.acquired) {
2042
2142
  installer.surface({ id: config.product, from: version, to: version, state: "current" });
@@ -2052,7 +2152,7 @@ async function installOrUpdate(config, dir, options, print, update) {
2052
2152
  state: "running",
2053
2153
  measure: total ? `${Math.floor(done / total * 100)}% \xB7 ${done}/${total} B` : `${done} B`
2054
2154
  });
2055
- if (!cached) await downloadAndUnpack(config, candidateDir, manifest, accessToken, { fetchImpl, onProgress });
2155
+ if (!cached) await downloadAndUnpack(config, candidateDir, manifest, accessToken, { fetchImpl, onProgress, cacheDir: dir });
2056
2156
  const acquisition = (0, import_node_fs11.existsSync)((0, import_node_path6.join)(candidateDir, "payload", "payload.json")) ? readAcquisition((0, import_node_path6.join)(candidateDir, "payload")) : null;
2057
2157
  if (acquisition) {
2058
2158
  rememberCandidate(dir, candidate, manifest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-launcher",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",