@mutmutco/installer-launcher 1.2.5 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ launcher status [--json] read-only: launcher, payload
21
21
  launcher doctor launcher report (no network), then the payload's doctor
22
22
  launcher autoupdate on|off|status hourly per-user schedule (hidden via wscript on Windows)
23
23
  launcher <verb> [args…] forwarded untouched when payload.json declares it
24
+ launcher [-p …|--model …] no verb: starts the product's session; leading flags the launcher does not own pass through
24
25
  launcher --run <file> [args…] run a payload ESM file with the launcher's own runtime
25
26
  launcher --version `<bin> <payload version> (launcher <version>)`
26
27
  ```
@@ -244,7 +245,7 @@ atomically only after convergence succeeds. On failure or interrupted startup it
244
245
  consumer rollback before allowing another install or forwarding. Failed rollback stays pending.
245
246
  A crash after shared commit completes the receipt instead of reverting the committed product.
246
247
  Host registration is not atomic: failures can leave host references to retained candidates.
247
- Candidates are deliberately retained until product-owned reference evidence can permit cleanup.
248
+ After a committed promotion the launcher removes candidate folders `state.json` no longer references (the active one is kept; symlinks and junctions are skipped; a failed delete is logged). A failed activation keeps its candidate.
248
249
  An orphan lock-recovery guard fails closed and needs operator diagnosis.
249
250
 
250
251
  For already-shipped old launchers, copy the exact file exported by
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 existsSync5, 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, readdirSync, lstatSync, rmSync as rmSync4 } 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
@@ -617,6 +617,28 @@ async function recoverAcquisition(dir, run2, env) {
617
617
  else if (existsSync5(statePath(dir))) unlinkSync(statePath(dir));
618
618
  unlinkSync(pendingPath(dir));
619
619
  }
620
+ function reapCandidates(dir) {
621
+ const keep = readState(dir)?.acquired?.candidate;
622
+ if (!keep) return;
623
+ const base = join4(dir, "candidates");
624
+ let names;
625
+ try {
626
+ names = readdirSync(base);
627
+ } catch {
628
+ return;
629
+ }
630
+ for (const name of names) {
631
+ if (name === keep || !/^[a-zA-Z0-9_-]+$/.test(name)) continue;
632
+ try {
633
+ const path = join4(base, name);
634
+ const info = lstatSync(path);
635
+ if (!info.isDirectory() || info.isSymbolicLink()) continue;
636
+ rmSync4(path, { recursive: true, force: true });
637
+ } catch (error) {
638
+ console.error(`launcher: could not remove old candidate ${name}: ${error.message}`);
639
+ }
640
+ }
641
+ }
620
642
  async function installAcquisition(dir, candidate, version, acquisition, options) {
621
643
  if (!acquisition.platforms.includes(`${process.platform}-${process.arch}`)) throw new Error("this product does not support this platform");
622
644
  const root = candidateRoot(dir, candidate);
@@ -673,13 +695,14 @@ async function installAcquisition(dir, candidate, version, acquisition, options)
673
695
  throw error;
674
696
  }
675
697
  unlinkSync(pendingPath(dir));
698
+ reapCandidates(dir);
676
699
  }
677
700
 
678
701
  // src/index.ts
679
702
  import { spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
680
- import { existsSync as existsSync8, readFileSync as readFileSync8, statSync, mkdtempSync as mkdtempSync2, unlinkSync as unlinkSync2, rmdirSync, mkdirSync as mkdirSync6, renameSync as renameSync5, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
703
+ import { existsSync as existsSync8, readFileSync as readFileSync8, statSync as statSync2, mkdtempSync as mkdtempSync2, unlinkSync as unlinkSync2, rmdirSync, mkdirSync as mkdirSync6, renameSync as renameSync5, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
681
704
  import { tmpdir as tmpdir4 } from "node:os";
682
- import { dirname as dirname4, join as join7, resolve as resolve2 } from "node:path";
705
+ import { dirname as dirname4, join as join7, resolve as resolve3 } from "node:path";
683
706
  import { isSea } from "node:sea";
684
707
  import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "node:url";
685
708
 
@@ -1554,7 +1577,7 @@ var rowTitle = (row) => `${row.surface}${row.aspect ? ` ${row.aspect}` : ""}${ro
1554
1577
 
1555
1578
  // src/autoupdate.ts
1556
1579
  import { spawnSync as spawnSync2 } from "node:child_process";
1557
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "node:fs";
1580
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "node:fs";
1558
1581
  import { tmpdir as tmpdir3 } from "node:os";
1559
1582
  import { join as join5 } from "node:path";
1560
1583
  function schedulePlatform(override) {
@@ -1649,20 +1672,20 @@ function disableSchedule(config, options = {}) {
1649
1672
  if (result.code !== 0 && !/cannot find|does not exist/i.test(`${result.stdout} ${result.stderr}`)) {
1650
1673
  throw new Error(`Could not turn auto-update off: ${firstLine(result.stdout || result.stderr) || `exit ${result.code}`}`);
1651
1674
  }
1652
- rmSync4(join5(options.stateDir ?? defaultProductDir(config.product), HIDDEN_SCRIPT), { force: true });
1675
+ rmSync5(join5(options.stateDir ?? defaultProductDir(config.product), HIDDEN_SCRIPT), { force: true });
1653
1676
  return;
1654
1677
  }
1655
1678
  if (platform === "darwin") {
1656
1679
  const label2 = scheduleLabel(config);
1657
1680
  exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${label2}`]);
1658
- rmSync4(join5(home, "Library", "LaunchAgents", `${label2}.plist`), { force: true });
1681
+ rmSync5(join5(home, "Library", "LaunchAgents", `${label2}.plist`), { force: true });
1659
1682
  return;
1660
1683
  }
1661
1684
  const label = scheduleLabel(config);
1662
1685
  const dir = join5(home, ".config", "systemd", "user");
1663
1686
  exec("systemctl", ["--user", "disable", "--now", `${label}.timer`]);
1664
- rmSync4(join5(dir, `${label}.service`), { force: true });
1665
- rmSync4(join5(dir, `${label}.timer`), { force: true });
1687
+ rmSync5(join5(dir, `${label}.service`), { force: true });
1688
+ rmSync5(join5(dir, `${label}.timer`), { force: true });
1666
1689
  }
1667
1690
  function querySchedule(config, options = {}) {
1668
1691
  const platform = schedulePlatform(options.platform);
@@ -1744,7 +1767,7 @@ function firstLine(text) {
1744
1767
 
1745
1768
  // src/login-github.ts
1746
1769
  import { spawn, spawnSync as spawnSync3 } from "node:child_process";
1747
- var realSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
1770
+ var realSleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
1748
1771
  function openBrowser(url) {
1749
1772
  if (process.env.LAUNCHER_NO_OPEN === "1") return;
1750
1773
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
@@ -1898,7 +1921,7 @@ async function loginGoogle(options) {
1898
1921
  const server = options.host.replace(/\/+$/, "");
1899
1922
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
1900
1923
  const listener = createServer();
1901
- await new Promise((resolve3) => listener.listen(0, "127.0.0.1", resolve3));
1924
+ await new Promise((resolve4) => listener.listen(0, "127.0.0.1", resolve4));
1902
1925
  const port = listener.address().port;
1903
1926
  const redirectUri = `http://127.0.0.1:${port}/callback`;
1904
1927
  try {
@@ -1925,7 +1948,7 @@ async function loginGoogle(options) {
1925
1948
  code_challenge: challenge,
1926
1949
  code_challenge_method: "S256"
1927
1950
  }).toString();
1928
- const code = await new Promise((resolve3, reject) => {
1951
+ const code = await new Promise((resolve4, reject) => {
1929
1952
  const timer = setTimeout(() => reject(new Error("sign-in timed out \u2014 run login again.")), timeoutMs);
1930
1953
  listener.on("request", (req, res) => {
1931
1954
  const url = new URL(req.url ?? "/", redirectUri);
@@ -1943,7 +1966,7 @@ async function loginGoogle(options) {
1943
1966
  }
1944
1967
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page("Signed in.", "You can close this tab and go back to the terminal."));
1945
1968
  clearTimeout(timer);
1946
- resolve3(received);
1969
+ resolve4(received);
1947
1970
  });
1948
1971
  const print = options.print ?? ((line) => process.stderr.write(`${line}
1949
1972
  `));
@@ -2041,9 +2064,9 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2041
2064
 
2042
2065
  // src/refresh.ts
2043
2066
  import { execFile } from "node:child_process";
2044
- import { chmodSync, existsSync as existsSync7, lstatSync, readdirSync, readFileSync as readFileSync7, renameSync as renameSync4, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
2067
+ import { accessSync, chmodSync, constants, existsSync as existsSync7, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync7, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, statSync, writeFileSync as writeFileSync7 } from "node:fs";
2045
2068
  import { homedir } from "node:os";
2046
- import { basename, dirname as dirname3, join as join6 } from "node:path";
2069
+ import { basename, dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
2047
2070
  var LAUNCHER_RECORD_FILE = "launcher.json";
2048
2071
  var RECORD_TIMEOUT_MS = 1e4;
2049
2072
  function platformKey(platform, arch) {
@@ -2102,15 +2125,54 @@ function recordedPath(productDir) {
2102
2125
  function installedPath(target, productDir, platform) {
2103
2126
  return platform === "win32" ? join6(productDir, "bin", `${target.bin}.exe`) : join6(homedir(), ".local", "bin", target.bin);
2104
2127
  }
2128
+ function onPath(bin, platform, env) {
2129
+ const win = platform === "win32";
2130
+ const read = (name) => env[name] ?? env[Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase()) ?? ""];
2131
+ const exts = win ? ["", ...(read("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
2132
+ const found = [];
2133
+ for (const dir of (read("PATH") ?? "").split(win ? ";" : ":").filter(Boolean)) {
2134
+ for (const ext of exts) {
2135
+ const candidate = join6(dir.replace(/^"|"$/gu, ""), `${bin}${ext}`);
2136
+ try {
2137
+ if (!statSync(candidate).isFile()) continue;
2138
+ if (!win) accessSync(candidate, constants.X_OK);
2139
+ found.push(candidate);
2140
+ } catch {
2141
+ }
2142
+ }
2143
+ }
2144
+ return found;
2145
+ }
2146
+ function findPathShadow(bin, file, options = {}) {
2147
+ const platform = options.platform ?? process.platform;
2148
+ const same = (a, b) => {
2149
+ const real = (path) => {
2150
+ try {
2151
+ return realpathSync3(path);
2152
+ } catch {
2153
+ return resolve2(path);
2154
+ }
2155
+ };
2156
+ const [x, y] = [real(a), real(b)];
2157
+ return platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
2158
+ };
2159
+ const hits = onPath(bin, platform, options.env ?? process.env);
2160
+ const other = hits.find((hit) => !same(hit, file));
2161
+ if (!other) return null;
2162
+ if (hits.findIndex((hit) => same(hit, file)) === 0) {
2163
+ return { state: "shadowing", other, line: `Warning: ${file} shadows another ${bin} on PATH: ${other}.` };
2164
+ }
2165
+ return { state: "shadowed", other, line: `Warning: ${file} is shadowed by another ${bin} earlier on PATH: ${other}.` };
2166
+ }
2105
2167
  function parseLauncherVersion(stdout, bin) {
2106
2168
  const text = String(stdout).trim();
2107
2169
  if (!text.startsWith(`${bin} `)) return null;
2108
2170
  return text.match(/launcher (\d+\.\d+\.\d+[^\s)]*)/u)?.[1] ?? null;
2109
2171
  }
2110
2172
  function runVersion(path, bin) {
2111
- return new Promise((resolve3) => {
2173
+ return new Promise((resolve4) => {
2112
2174
  execFile(path, ["--version"], { timeout: 3e4, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
2113
- resolve3(error ? null : parseLauncherVersion(stdout, bin));
2175
+ resolve4(error ? null : parseLauncherVersion(stdout, bin));
2114
2176
  });
2115
2177
  });
2116
2178
  }
@@ -2118,14 +2180,14 @@ function removeStale(file) {
2118
2180
  const prefix = `${basename(file)}.old-`;
2119
2181
  let names;
2120
2182
  try {
2121
- names = readdirSync(dirname3(file));
2183
+ names = readdirSync2(dirname3(file));
2122
2184
  } catch {
2123
2185
  return;
2124
2186
  }
2125
2187
  for (const name of names) {
2126
2188
  if (!name.startsWith(prefix)) continue;
2127
2189
  try {
2128
- rmSync5(join6(dirname3(file), name), { force: true });
2190
+ rmSync6(join6(dirname3(file), name), { force: true });
2129
2191
  } catch {
2130
2192
  }
2131
2193
  }
@@ -2138,11 +2200,12 @@ async function refreshLauncher(target, options = {}) {
2138
2200
  const productDir = options.productDir ?? defaultProductDir(target.product);
2139
2201
  const recorded = recordedPath(productDir);
2140
2202
  const file = options.path ?? (recorded && existsSync7(recorded) ? recorded : installedPath(target, productDir, platform));
2141
- if (!existsSync7(file) || lstatSync(file).isSymbolicLink()) return null;
2203
+ if (!existsSync7(file) || lstatSync2(file).isSymbolicLink()) return null;
2142
2204
  if (!options.dryRun) removeStale(file);
2143
2205
  const from = await (options.readVersion ?? ((path) => runVersion(path, target.bin)))(file);
2144
2206
  if (!from) return null;
2145
- const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail, durationMs: Date.now() - started });
2207
+ const shadow = findPathShadow(target.bin, file, { platform, ...options.env ? { env: options.env } : {} });
2208
+ const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail: [detail, shadow?.line].filter(Boolean).join(" "), durationMs: Date.now() - started });
2146
2209
  const record = await fetchRecord(target, options);
2147
2210
  if (record === "bad-signature") return arm("fail", from, "The launcher record failed its signature check.");
2148
2211
  const entry2 = record?.files.find((candidate) => candidate.path === key);
@@ -2186,7 +2249,7 @@ async function refreshLauncher(target, options = {}) {
2186
2249
  if (moved && !existsSync7(file)) renameSync4(moved, file);
2187
2250
  } catch {
2188
2251
  }
2189
- rmSync5(next, { force: true });
2252
+ rmSync6(next, { force: true });
2190
2253
  return arm("fail", from, "The launcher could not be replaced.");
2191
2254
  }
2192
2255
  return arm("ok", record.version, "");
@@ -2196,7 +2259,7 @@ async function refreshLauncher(target, options = {}) {
2196
2259
  }
2197
2260
 
2198
2261
  // src/index.ts
2199
- var LAUNCHER_VERSION = true ? "1.2.5" : readVersionFromPackage();
2262
+ var LAUNCHER_VERSION = true ? "1.3.1" : readVersionFromPackage();
2200
2263
  function defaultPrint(message) {
2201
2264
  process.stdout.write(`${message}
2202
2265
  `);
@@ -2246,7 +2309,13 @@ function parseArgv(argv2) {
2246
2309
  parsed.run = { file: argv2[i + 1], args: argv2.slice(i + 2) };
2247
2310
  return parsed;
2248
2311
  }
2249
- i = skip(i);
2312
+ const next = flag(i);
2313
+ if (next < 0) {
2314
+ parsed.unknownFlag ??= argv2[i];
2315
+ parsed.forward = argv2.slice(i);
2316
+ return parsed;
2317
+ }
2318
+ i = next;
2250
2319
  }
2251
2320
  parsed.command = argv2[i];
2252
2321
  if (parsed.command === void 0) return parsed;
@@ -2302,12 +2371,19 @@ async function run(rawOptions = {}) {
2302
2371
  const dir = resolveProductDir(config.product, rawOptions.dir ?? parsed.flags.dir);
2303
2372
  const ui = screensFor(faceProduct(config), config.binName, render, rawOptions);
2304
2373
  if ((parsed.command === "install" || parsed.command === "update") && !parsed.flags.version) recordSelf(dir);
2374
+ const leadingForeign = parsed.forward !== void 0 && parsed.unknownFlag === parsed.forward[0] && !parsed.flags.help && !parsed.flags.version;
2375
+ if (leadingForeign && entryFor(dir).target) {
2376
+ return doForward(config, dir, rawOptions, void 0, parsed.forward, ui, () => void 0);
2377
+ }
2305
2378
  if (parsed.unknownFlag) return ui.refuse(`Unknown option: ${parsed.unknownFlag}.`, `${ui.bin} --help`, 2);
2306
2379
  if (parsed.flags.version) {
2307
2380
  print(renderVersion(ui.bin, readState(dir)?.version ?? "not installed", LAUNCHER_VERSION));
2308
2381
  return 0;
2309
2382
  }
2310
2383
  const command2 = parsed.command;
2384
+ if (!parsed.flags.help && command2 === void 0 && entryFor(dir).target) {
2385
+ return doForward(config, dir, rawOptions, void 0, [], ui, () => void 0);
2386
+ }
2311
2387
  if (parsed.flags.help || command2 === void 0) {
2312
2388
  ui.show(usage(config, dir, render));
2313
2389
  return parsed.flags.help ? 0 : 2;
@@ -2397,7 +2473,7 @@ async function runFile(file, args, printErr) {
2397
2473
  printErr("launcher --run needs a file to run.");
2398
2474
  return 1;
2399
2475
  }
2400
- const abs = resolve2(process.cwd(), file);
2476
+ const abs = resolve3(process.cwd(), file);
2401
2477
  if (!existsSync8(abs)) {
2402
2478
  printErr(`cannot run ${file}: no such file.`);
2403
2479
  return 1;
@@ -2705,7 +2781,7 @@ async function installOrUpdateRun(config, dir, options, print, update, flags2, m
2705
2781
  }
2706
2782
  const target = join7(dir, "payload");
2707
2783
  mkdirSync6(dir, { recursive: true });
2708
- rmSync6(target, { recursive: true, force: true });
2784
+ rmSync7(target, { recursive: true, force: true });
2709
2785
  renameSync5(join7(candidateDir, "payload"), target);
2710
2786
  writeState(dir, { version, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
2711
2787
  installer.phase("download", { seconds: (Date.now() - started) / 1e3 });
@@ -2753,7 +2829,7 @@ function payloadFileAsCommand(entry2, payload) {
2753
2829
  if (NEVER_A_PROGRAM.test(first)) return first;
2754
2830
  if (process.platform === "win32") return null;
2755
2831
  try {
2756
- return (statSync(candidate).mode & 73) === 0 ? first : null;
2832
+ return (statSync2(candidate).mode & 73) === 0 ? first : null;
2757
2833
  } catch {
2758
2834
  return null;
2759
2835
  }
@@ -2828,7 +2904,7 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2828
2904
  let progressError;
2829
2905
  const redact = (text) => secrets.reduce((safe, value) => safe.replaceAll(value, "[redacted]"), text);
2830
2906
  try {
2831
- const result = await new Promise((resolve3) => {
2907
+ const result = await new Promise((resolve4) => {
2832
2908
  const child = spawn2(shell ? [command2, ...args].map(quoteForShell).join(" ") : command2, shell ? [] : args, {
2833
2909
  cwd,
2834
2910
  shell,
@@ -2885,8 +2961,8 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2885
2961
  channel.setEncoding("utf8");
2886
2962
  channel.on("data", receive);
2887
2963
  }
2888
- child.on("error", (error) => resolve3({ ok: false, error: error.message }));
2889
- child.on("close", (code) => resolve3({
2964
+ child.on("error", (error) => resolve4({ ok: false, error: error.message }));
2965
+ child.on("close", (code) => resolve4({
2890
2966
  ok: code === 0,
2891
2967
  ...code !== null ? { code } : {},
2892
2968
  ...code !== 0 ? { error: `exit code ${code}` } : {}
@@ -2911,16 +2987,19 @@ function armState(arm) {
2911
2987
  if (arm.verdict === "skip") return "kept";
2912
2988
  return arm.from === arm.to ? "current" : "updated";
2913
2989
  }
2990
+ function entryFor(dir) {
2991
+ const acquired = readState(dir)?.acquired;
2992
+ return { acquired, target: acquired ? [acquired.node, acquired.entry] : readPayloadRun(dir) };
2993
+ }
2914
2994
  function doForward(config, dir, options, command2, rest, ui, unlock) {
2915
- if (existsSync8(command2)) {
2995
+ if (command2 !== void 0 && existsSync8(command2)) {
2916
2996
  return ui.refuse(`${command2} is a file, not a command.`, `${ui.bin} --run ${command2}`, 2);
2917
2997
  }
2918
- const acquired = readState(dir)?.acquired;
2919
- const target = acquired ? [acquired.node, acquired.entry] : readPayloadRun(dir);
2998
+ const { acquired, target } = entryFor(dir);
2920
2999
  const verbs = readPayloadVerbs(dir);
2921
- const declared = verbs === "*" || Array.isArray(verbs) && verbs.includes(command2);
3000
+ const declared = command2 === void 0 || verbs === "*" || Array.isArray(verbs) && verbs.includes(command2);
2922
3001
  if (!target || !declared) return ui.refuse(`Unknown command: ${command2}.`, `${ui.bin} --help`, 2);
2923
- const forwarded = [...resolveEntry(target), command2, ...rest];
3002
+ const forwarded = [...resolveEntry(target), ...command2 === void 0 ? [] : [command2], ...rest];
2924
3003
  const cwd = payloadDir(dir);
2925
3004
  const inherited = payloadEnv(dir);
2926
3005
  const env = acquired ? runtimeEnvironment(acquired.node, inherited ?? process.env) : inherited;
@@ -3019,6 +3098,8 @@ async function doStatus(config, dir, options, ui, print, json) {
3019
3098
  function doDoctor(config, dir, options, ui) {
3020
3099
  const local = localFacts(config, dir, options);
3021
3100
  const verdict = verdictFor(ui, local, false, `${ui.bin} status`);
3101
+ const shadowFile = options.shadow?.file ?? (isSea() ? process.execPath : void 0);
3102
+ const shadow = shadowFile ? findPathShadow(config.binName, shadowFile, options.shadow ?? {}) : null;
3022
3103
  ui.show(renderReport(ui.product, {
3023
3104
  rows: [
3024
3105
  { surface: "launcher", state: LAUNCHER_VERSION, mark: "ok" },
@@ -3027,7 +3108,7 @@ function doDoctor(config, dir, options, ui) {
3027
3108
  autoUpdateRow(local.schedule)
3028
3109
  ],
3029
3110
  // Paths belong in the receipt, never a row.
3030
- verdict: { ...verdict, facts: [`Folder: ${dir}`, `Release host: ${config.host}`] }
3111
+ verdict: { ...verdict, facts: [`Folder: ${dir}`, `Release host: ${config.host}`, ...shadow ? [shadow.line] : []] }
3031
3112
  }, ui.render));
3032
3113
  const own = verdict.state === "fail" ? 1 : 0;
3033
3114
  const acquired = readState(dir)?.acquired;
@@ -650,6 +650,28 @@ async function recoverAcquisition(dir, run2, env) {
650
650
  else if ((0, import_node_fs5.existsSync)(statePath(dir))) (0, import_node_fs5.unlinkSync)(statePath(dir));
651
651
  (0, import_node_fs5.unlinkSync)(pendingPath(dir));
652
652
  }
653
+ function reapCandidates(dir) {
654
+ const keep = readState(dir)?.acquired?.candidate;
655
+ if (!keep) return;
656
+ const base = (0, import_node_path4.join)(dir, "candidates");
657
+ let names;
658
+ try {
659
+ names = (0, import_node_fs5.readdirSync)(base);
660
+ } catch {
661
+ return;
662
+ }
663
+ for (const name of names) {
664
+ if (name === keep || !/^[a-zA-Z0-9_-]+$/.test(name)) continue;
665
+ try {
666
+ const path = (0, import_node_path4.join)(base, name);
667
+ const info = (0, import_node_fs5.lstatSync)(path);
668
+ if (!info.isDirectory() || info.isSymbolicLink()) continue;
669
+ (0, import_node_fs5.rmSync)(path, { recursive: true, force: true });
670
+ } catch (error) {
671
+ console.error(`launcher: could not remove old candidate ${name}: ${error.message}`);
672
+ }
673
+ }
674
+ }
653
675
  async function installAcquisition(dir, candidate, version, acquisition, options) {
654
676
  if (!acquisition.platforms.includes(`${process.platform}-${process.arch}`)) throw new Error("this product does not support this platform");
655
677
  const root = candidateRoot(dir, candidate);
@@ -706,6 +728,7 @@ async function installAcquisition(dir, candidate, version, acquisition, options)
706
728
  throw error;
707
729
  }
708
730
  (0, import_node_fs5.unlinkSync)(pendingPath(dir));
731
+ reapCandidates(dir);
709
732
  }
710
733
 
711
734
  // src/index.ts
@@ -1777,7 +1800,7 @@ function firstLine(text) {
1777
1800
 
1778
1801
  // src/login-github.ts
1779
1802
  var import_node_child_process3 = require("node:child_process");
1780
- var realSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
1803
+ var realSleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
1781
1804
  function openBrowser(url) {
1782
1805
  if (process.env.LAUNCHER_NO_OPEN === "1") return;
1783
1806
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
@@ -1931,7 +1954,7 @@ async function loginGoogle(options) {
1931
1954
  const server = options.host.replace(/\/+$/, "");
1932
1955
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
1933
1956
  const listener = (0, import_node_http.createServer)();
1934
- await new Promise((resolve3) => listener.listen(0, "127.0.0.1", resolve3));
1957
+ await new Promise((resolve4) => listener.listen(0, "127.0.0.1", resolve4));
1935
1958
  const port = listener.address().port;
1936
1959
  const redirectUri = `http://127.0.0.1:${port}/callback`;
1937
1960
  try {
@@ -1958,7 +1981,7 @@ async function loginGoogle(options) {
1958
1981
  code_challenge: challenge,
1959
1982
  code_challenge_method: "S256"
1960
1983
  }).toString();
1961
- const code = await new Promise((resolve3, reject) => {
1984
+ const code = await new Promise((resolve4, reject) => {
1962
1985
  const timer = setTimeout(() => reject(new Error("sign-in timed out \u2014 run login again.")), timeoutMs);
1963
1986
  listener.on("request", (req, res) => {
1964
1987
  const url = new URL(req.url ?? "/", redirectUri);
@@ -1976,7 +1999,7 @@ async function loginGoogle(options) {
1976
1999
  }
1977
2000
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page("Signed in.", "You can close this tab and go back to the terminal."));
1978
2001
  clearTimeout(timer);
1979
- resolve3(received);
2002
+ resolve4(received);
1980
2003
  });
1981
2004
  const print = options.print ?? ((line) => process.stderr.write(`${line}
1982
2005
  `));
@@ -2135,15 +2158,54 @@ function recordedPath(productDir) {
2135
2158
  function installedPath(target, productDir, platform) {
2136
2159
  return platform === "win32" ? (0, import_node_path6.join)(productDir, "bin", `${target.bin}.exe`) : (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".local", "bin", target.bin);
2137
2160
  }
2161
+ function onPath(bin, platform, env) {
2162
+ const win = platform === "win32";
2163
+ const read = (name) => env[name] ?? env[Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase()) ?? ""];
2164
+ const exts = win ? ["", ...(read("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
2165
+ const found = [];
2166
+ for (const dir of (read("PATH") ?? "").split(win ? ";" : ":").filter(Boolean)) {
2167
+ for (const ext of exts) {
2168
+ const candidate = (0, import_node_path6.join)(dir.replace(/^"|"$/gu, ""), `${bin}${ext}`);
2169
+ try {
2170
+ if (!(0, import_node_fs11.statSync)(candidate).isFile()) continue;
2171
+ if (!win) (0, import_node_fs11.accessSync)(candidate, import_node_fs11.constants.X_OK);
2172
+ found.push(candidate);
2173
+ } catch {
2174
+ }
2175
+ }
2176
+ }
2177
+ return found;
2178
+ }
2179
+ function findPathShadow(bin, file, options = {}) {
2180
+ const platform = options.platform ?? process.platform;
2181
+ const same = (a, b) => {
2182
+ const real = (path) => {
2183
+ try {
2184
+ return (0, import_node_fs11.realpathSync)(path);
2185
+ } catch {
2186
+ return (0, import_node_path6.resolve)(path);
2187
+ }
2188
+ };
2189
+ const [x, y] = [real(a), real(b)];
2190
+ return platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
2191
+ };
2192
+ const hits = onPath(bin, platform, options.env ?? process.env);
2193
+ const other = hits.find((hit) => !same(hit, file));
2194
+ if (!other) return null;
2195
+ if (hits.findIndex((hit) => same(hit, file)) === 0) {
2196
+ return { state: "shadowing", other, line: `Warning: ${file} shadows another ${bin} on PATH: ${other}.` };
2197
+ }
2198
+ return { state: "shadowed", other, line: `Warning: ${file} is shadowed by another ${bin} earlier on PATH: ${other}.` };
2199
+ }
2138
2200
  function parseLauncherVersion(stdout, bin) {
2139
2201
  const text = String(stdout).trim();
2140
2202
  if (!text.startsWith(`${bin} `)) return null;
2141
2203
  return text.match(/launcher (\d+\.\d+\.\d+[^\s)]*)/u)?.[1] ?? null;
2142
2204
  }
2143
2205
  function runVersion(path, bin) {
2144
- return new Promise((resolve3) => {
2206
+ return new Promise((resolve4) => {
2145
2207
  (0, import_node_child_process4.execFile)(path, ["--version"], { timeout: 3e4, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
2146
- resolve3(error ? null : parseLauncherVersion(stdout, bin));
2208
+ resolve4(error ? null : parseLauncherVersion(stdout, bin));
2147
2209
  });
2148
2210
  });
2149
2211
  }
@@ -2175,7 +2237,8 @@ async function refreshLauncher(target, options = {}) {
2175
2237
  if (!options.dryRun) removeStale(file);
2176
2238
  const from = await (options.readVersion ?? ((path) => runVersion(path, target.bin)))(file);
2177
2239
  if (!from) return null;
2178
- const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail, durationMs: Date.now() - started });
2240
+ const shadow = findPathShadow(target.bin, file, { platform, ...options.env ? { env: options.env } : {} });
2241
+ const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail: [detail, shadow?.line].filter(Boolean).join(" "), durationMs: Date.now() - started });
2179
2242
  const record = await fetchRecord(target, options);
2180
2243
  if (record === "bad-signature") return arm("fail", from, "The launcher record failed its signature check.");
2181
2244
  const entry2 = record?.files.find((candidate) => candidate.path === key);
@@ -2229,7 +2292,7 @@ async function refreshLauncher(target, options = {}) {
2229
2292
  }
2230
2293
 
2231
2294
  // src/index.ts
2232
- var LAUNCHER_VERSION = true ? "1.2.5" : readVersionFromPackage();
2295
+ var LAUNCHER_VERSION = true ? "1.3.1" : readVersionFromPackage();
2233
2296
  function defaultPrint(message) {
2234
2297
  process.stdout.write(`${message}
2235
2298
  `);
@@ -2279,7 +2342,13 @@ function parseArgv(argv2) {
2279
2342
  parsed.run = { file: argv2[i + 1], args: argv2.slice(i + 2) };
2280
2343
  return parsed;
2281
2344
  }
2282
- i = skip(i);
2345
+ const next = flag(i);
2346
+ if (next < 0) {
2347
+ parsed.unknownFlag ??= argv2[i];
2348
+ parsed.forward = argv2.slice(i);
2349
+ return parsed;
2350
+ }
2351
+ i = next;
2283
2352
  }
2284
2353
  parsed.command = argv2[i];
2285
2354
  if (parsed.command === void 0) return parsed;
@@ -2335,12 +2404,19 @@ async function run(rawOptions = {}) {
2335
2404
  const dir = resolveProductDir(config.product, rawOptions.dir ?? parsed.flags.dir);
2336
2405
  const ui = screensFor(faceProduct(config), config.binName, render, rawOptions);
2337
2406
  if ((parsed.command === "install" || parsed.command === "update") && !parsed.flags.version) recordSelf(dir);
2407
+ const leadingForeign = parsed.forward !== void 0 && parsed.unknownFlag === parsed.forward[0] && !parsed.flags.help && !parsed.flags.version;
2408
+ if (leadingForeign && entryFor(dir).target) {
2409
+ return doForward(config, dir, rawOptions, void 0, parsed.forward, ui, () => void 0);
2410
+ }
2338
2411
  if (parsed.unknownFlag) return ui.refuse(`Unknown option: ${parsed.unknownFlag}.`, `${ui.bin} --help`, 2);
2339
2412
  if (parsed.flags.version) {
2340
2413
  print(renderVersion(ui.bin, readState(dir)?.version ?? "not installed", LAUNCHER_VERSION));
2341
2414
  return 0;
2342
2415
  }
2343
2416
  const command2 = parsed.command;
2417
+ if (!parsed.flags.help && command2 === void 0 && entryFor(dir).target) {
2418
+ return doForward(config, dir, rawOptions, void 0, [], ui, () => void 0);
2419
+ }
2344
2420
  if (parsed.flags.help || command2 === void 0) {
2345
2421
  ui.show(usage(config, dir, render));
2346
2422
  return parsed.flags.help ? 0 : 2;
@@ -2861,7 +2937,7 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2861
2937
  let progressError;
2862
2938
  const redact = (text) => secrets.reduce((safe, value) => safe.replaceAll(value, "[redacted]"), text);
2863
2939
  try {
2864
- const result = await new Promise((resolve3) => {
2940
+ const result = await new Promise((resolve4) => {
2865
2941
  const child = (0, import_node_child_process5.spawn)(shell ? [command2, ...args].map(quoteForShell).join(" ") : command2, shell ? [] : args, {
2866
2942
  cwd,
2867
2943
  shell,
@@ -2918,8 +2994,8 @@ async function runInstallEntry(entry2, cwd, env, installer, tokens = null) {
2918
2994
  channel.setEncoding("utf8");
2919
2995
  channel.on("data", receive);
2920
2996
  }
2921
- child.on("error", (error) => resolve3({ ok: false, error: error.message }));
2922
- child.on("close", (code) => resolve3({
2997
+ child.on("error", (error) => resolve4({ ok: false, error: error.message }));
2998
+ child.on("close", (code) => resolve4({
2923
2999
  ok: code === 0,
2924
3000
  ...code !== null ? { code } : {},
2925
3001
  ...code !== 0 ? { error: `exit code ${code}` } : {}
@@ -2944,16 +3020,19 @@ function armState(arm) {
2944
3020
  if (arm.verdict === "skip") return "kept";
2945
3021
  return arm.from === arm.to ? "current" : "updated";
2946
3022
  }
3023
+ function entryFor(dir) {
3024
+ const acquired = readState(dir)?.acquired;
3025
+ return { acquired, target: acquired ? [acquired.node, acquired.entry] : readPayloadRun(dir) };
3026
+ }
2947
3027
  function doForward(config, dir, options, command2, rest, ui, unlock) {
2948
- if ((0, import_node_fs12.existsSync)(command2)) {
3028
+ if (command2 !== void 0 && (0, import_node_fs12.existsSync)(command2)) {
2949
3029
  return ui.refuse(`${command2} is a file, not a command.`, `${ui.bin} --run ${command2}`, 2);
2950
3030
  }
2951
- const acquired = readState(dir)?.acquired;
2952
- const target = acquired ? [acquired.node, acquired.entry] : readPayloadRun(dir);
3031
+ const { acquired, target } = entryFor(dir);
2953
3032
  const verbs = readPayloadVerbs(dir);
2954
- const declared = verbs === "*" || Array.isArray(verbs) && verbs.includes(command2);
3033
+ const declared = command2 === void 0 || verbs === "*" || Array.isArray(verbs) && verbs.includes(command2);
2955
3034
  if (!target || !declared) return ui.refuse(`Unknown command: ${command2}.`, `${ui.bin} --help`, 2);
2956
- const forwarded = [...resolveEntry(target), command2, ...rest];
3035
+ const forwarded = [...resolveEntry(target), ...command2 === void 0 ? [] : [command2], ...rest];
2957
3036
  const cwd = payloadDir(dir);
2958
3037
  const inherited = payloadEnv(dir);
2959
3038
  const env = acquired ? runtimeEnvironment(acquired.node, inherited ?? process.env) : inherited;
@@ -3052,6 +3131,8 @@ async function doStatus(config, dir, options, ui, print, json) {
3052
3131
  function doDoctor(config, dir, options, ui) {
3053
3132
  const local = localFacts(config, dir, options);
3054
3133
  const verdict = verdictFor(ui, local, false, `${ui.bin} status`);
3134
+ const shadowFile = options.shadow?.file ?? ((0, import_node_sea2.isSea)() ? process.execPath : void 0);
3135
+ const shadow = shadowFile ? findPathShadow(config.binName, shadowFile, options.shadow ?? {}) : null;
3055
3136
  ui.show(renderReport(ui.product, {
3056
3137
  rows: [
3057
3138
  { surface: "launcher", state: LAUNCHER_VERSION, mark: "ok" },
@@ -3060,7 +3141,7 @@ function doDoctor(config, dir, options, ui) {
3060
3141
  autoUpdateRow(local.schedule)
3061
3142
  ],
3062
3143
  // Paths belong in the receipt, never a row.
3063
- verdict: { ...verdict, facts: [`Folder: ${dir}`, `Release host: ${config.host}`] }
3144
+ verdict: { ...verdict, facts: [`Folder: ${dir}`, `Release host: ${config.host}`, ...shadow ? [shadow.line] : []] }
3064
3145
  }, ui.render));
3065
3146
  const own = verdict.state === "fail" ? 1 : 0;
3066
3147
  const acquired = readState(dir)?.acquired;
package/dist/refresh.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // src/refresh.ts
2
2
  import { execFile } from "node:child_process";
3
- import { chmodSync, existsSync as existsSync2, lstatSync, readdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
3
+ import { accessSync, chmodSync, constants, existsSync as existsSync2, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync as realpathSync2, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { basename, dirname as dirname2, join as join3 } from "node:path";
5
+ import { basename, dirname as dirname2, join as join3, resolve as resolve2 } from "node:path";
6
6
 
7
7
  // src/acquisition.ts
8
8
  import { createHash as createHash2, randomUUID } from "node:crypto";
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, realpathSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, realpathSync, readdirSync, lstatSync, rmSync } from "node:fs";
10
10
  import { join as join2, resolve, relative, isAbsolute, dirname } from "node:path";
11
11
 
12
12
  // src/download.ts
@@ -235,15 +235,54 @@ function recordedPath(productDir) {
235
235
  function installedPath(target, productDir, platform) {
236
236
  return platform === "win32" ? join3(productDir, "bin", `${target.bin}.exe`) : join3(homedir(), ".local", "bin", target.bin);
237
237
  }
238
+ function onPath(bin, platform, env) {
239
+ const win = platform === "win32";
240
+ const read = (name) => env[name] ?? env[Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase()) ?? ""];
241
+ const exts = win ? ["", ...(read("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
242
+ const found = [];
243
+ for (const dir of (read("PATH") ?? "").split(win ? ";" : ":").filter(Boolean)) {
244
+ for (const ext of exts) {
245
+ const candidate = join3(dir.replace(/^"|"$/gu, ""), `${bin}${ext}`);
246
+ try {
247
+ if (!statSync(candidate).isFile()) continue;
248
+ if (!win) accessSync(candidate, constants.X_OK);
249
+ found.push(candidate);
250
+ } catch {
251
+ }
252
+ }
253
+ }
254
+ return found;
255
+ }
256
+ function findPathShadow(bin, file, options = {}) {
257
+ const platform = options.platform ?? process.platform;
258
+ const same = (a, b) => {
259
+ const real = (path) => {
260
+ try {
261
+ return realpathSync2(path);
262
+ } catch {
263
+ return resolve2(path);
264
+ }
265
+ };
266
+ const [x, y] = [real(a), real(b)];
267
+ return platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
268
+ };
269
+ const hits = onPath(bin, platform, options.env ?? process.env);
270
+ const other = hits.find((hit) => !same(hit, file));
271
+ if (!other) return null;
272
+ if (hits.findIndex((hit) => same(hit, file)) === 0) {
273
+ return { state: "shadowing", other, line: `Warning: ${file} shadows another ${bin} on PATH: ${other}.` };
274
+ }
275
+ return { state: "shadowed", other, line: `Warning: ${file} is shadowed by another ${bin} earlier on PATH: ${other}.` };
276
+ }
238
277
  function parseLauncherVersion(stdout, bin) {
239
278
  const text = String(stdout).trim();
240
279
  if (!text.startsWith(`${bin} `)) return null;
241
280
  return text.match(/launcher (\d+\.\d+\.\d+[^\s)]*)/u)?.[1] ?? null;
242
281
  }
243
282
  function runVersion(path, bin) {
244
- return new Promise((resolve2) => {
283
+ return new Promise((resolve3) => {
245
284
  execFile(path, ["--version"], { timeout: 3e4, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
246
- resolve2(error ? null : parseLauncherVersion(stdout, bin));
285
+ resolve3(error ? null : parseLauncherVersion(stdout, bin));
247
286
  });
248
287
  });
249
288
  }
@@ -251,14 +290,14 @@ function removeStale(file) {
251
290
  const prefix = `${basename(file)}.old-`;
252
291
  let names;
253
292
  try {
254
- names = readdirSync(dirname2(file));
293
+ names = readdirSync2(dirname2(file));
255
294
  } catch {
256
295
  return;
257
296
  }
258
297
  for (const name of names) {
259
298
  if (!name.startsWith(prefix)) continue;
260
299
  try {
261
- rmSync(join3(dirname2(file), name), { force: true });
300
+ rmSync2(join3(dirname2(file), name), { force: true });
262
301
  } catch {
263
302
  }
264
303
  }
@@ -271,11 +310,12 @@ async function refreshLauncher(target, options = {}) {
271
310
  const productDir = options.productDir ?? defaultProductDir(target.product);
272
311
  const recorded = recordedPath(productDir);
273
312
  const file = options.path ?? (recorded && existsSync2(recorded) ? recorded : installedPath(target, productDir, platform));
274
- if (!existsSync2(file) || lstatSync(file).isSymbolicLink()) return null;
313
+ if (!existsSync2(file) || lstatSync2(file).isSymbolicLink()) return null;
275
314
  if (!options.dryRun) removeStale(file);
276
315
  const from = await (options.readVersion ?? ((path) => runVersion(path, target.bin)))(file);
277
316
  if (!from) return null;
278
- const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail, durationMs: Date.now() - started });
317
+ const shadow = findPathShadow(target.bin, file, { platform, ...options.env ? { env: options.env } : {} });
318
+ const arm = (verdict, to, detail) => ({ surface: "launcher", from, to, verdict, detail: [detail, shadow?.line].filter(Boolean).join(" "), durationMs: Date.now() - started });
279
319
  const record = await fetchRecord(target, options);
280
320
  if (record === "bad-signature") return arm("fail", from, "The launcher record failed its signature check.");
281
321
  const entry = record?.files.find((candidate) => candidate.path === key);
@@ -319,7 +359,7 @@ async function refreshLauncher(target, options = {}) {
319
359
  if (moved && !existsSync2(file)) renameSync(moved, file);
320
360
  } catch {
321
361
  }
322
- rmSync(next, { force: true });
362
+ rmSync2(next, { force: true });
323
363
  return arm("fail", from, "The launcher could not be replaced.");
324
364
  }
325
365
  return arm("ok", record.version, "");
@@ -330,6 +370,7 @@ async function refreshLauncher(target, options = {}) {
330
370
  export {
331
371
  LAUNCHER_RECORD_FILE,
332
372
  compareVersions,
373
+ findPathShadow,
333
374
  parseLauncherVersion,
334
375
  readLauncherRecord,
335
376
  refreshLauncher
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-launcher",
3
- "version": "1.2.5",
3
+ "version": "1.3.1",
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",