@mutmutco/installer-launcher 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,6 +57,50 @@ Only `login` (and the login half of `install`) opens a browser:
57
57
  The product dir defaults to `~/.<product>/` on mac/linux and `%LOCALAPPDATA%\<product>` on
58
58
  Windows, and holds `tokens.json` (0600), `state.json`, and `payload/`.
59
59
 
60
+ ## Noninteractive session API
61
+
62
+ `@mutmutco/installer-launcher/session` is a plain ESM entry with TypeScript definitions;
63
+ importing it does not start the CLI or load a baked product config.
64
+
65
+ ```ts
66
+ import { getAccessToken, LoginRequiredError } from '@mutmutco/installer-launcher/session';
67
+
68
+ try {
69
+ const accessToken = await getAccessToken({
70
+ product: 'example-product',
71
+ host: 'https://gate.example.com',
72
+ loginKind: 'github', // explicit; use 'google' for a Google product gate
73
+ signal: AbortSignal.timeout(40_000), // shared with the caller's entire attempt
74
+ });
75
+ // Use accessToken for authenticated requests; never log it.
76
+ } catch (error) {
77
+ if (error instanceof LoginRequiredError) {
78
+ // Ask the caller to sign in using its normal product login flow.
79
+ } else {
80
+ throw error; // transport, timeout, cancellation, storage or lock failure
81
+ }
82
+ }
83
+ ```
84
+
85
+ All three identity options are required; no host/product/protocol is inferred. The API
86
+ reuses the machine's gate session and returns only its access token. Tokens within 60 seconds
87
+ of expiry are refreshed and persisted using the launcher's private store. It never opens a
88
+ browser, invokes `gh`, starts device authentication, or exposes the token-file format.
89
+ Missing/unusable sessions and permanent refresh rejection (GitHub gate 403; Google OAuth 400/401/403, e.g. expired or revoked grants) throw `LoginRequiredError`; a release
90
+ resource's `ForbiddenError` instead means revoked release access and is not this error.
91
+ Network and non-403 HTTP failures propagate without erasing the session.
92
+
93
+ Optional `dir` overrides `LAUNCHER_DIR`, then the usual platform product directory applies.
94
+ Optional `fetch` injects a transport for tests and must honor the supplied AbortSignal.
95
+ `timeoutMs` defaults to 10,000 and must be a positive integer at most 40,000. Its signal is
96
+ composed with `signal` and any transport input signal; cancellation is checked before
97
+ persisting or clearing refreshed tokens. For a 40-second attempt / 120-second overall staging
98
+ budget, pass the caller's attempt signal and keep the auth timeout inside that budget.
99
+
100
+ Calls take the existing installation lock and fail rather than queue behind another owner.
101
+ This protects callers using that lock, not all concurrent token writers: existing login paths
102
+ can release it before authentication. Do not assume cross-process refresh coalescing.
103
+
60
104
  ## Config
61
105
 
62
106
  `config/product.template.json` per product:
@@ -245,7 +289,7 @@ atomically only after convergence succeeds. On failure or interrupted startup it
245
289
  consumer rollback before allowing another install or forwarding. Failed rollback stays pending.
246
290
  A crash after shared commit completes the receipt instead of reverting the committed product.
247
291
  Host registration is not atomic: failures can leave host references to retained candidates.
248
- Candidates are deliberately retained until product-owned reference evidence can permit cleanup.
292
+ 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.
249
293
  An orphan lock-recovery guard fails closed and needs operator diagnosis.
250
294
 
251
295
  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,11 +695,12 @@ 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 as statSync2, 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
705
  import { dirname as dirname4, join as join7, resolve as resolve3 } from "node:path";
683
706
  import { isSea } from "node:sea";
@@ -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);
@@ -1988,7 +2011,8 @@ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetc
1988
2011
  client_id: clientId
1989
2012
  }).toString()
1990
2013
  });
1991
- if (!response.ok) return null;
2014
+ if ([400, 401, 403].includes(response.status)) return null;
2015
+ if (!response.ok) throw new Error(`POST ${server}/oauth/token \u2192 ${response.status}: refresh failed`);
1992
2016
  const tokens = await response.json();
1993
2017
  if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
1994
2018
  return {
@@ -2003,13 +2027,15 @@ function page(title, body) {
2003
2027
  }
2004
2028
 
2005
2029
  // src/tokens.ts
2006
- async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2030
+ async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
2031
+ signal?.throwIfAborted();
2007
2032
  const stored = readTokens(dir);
2008
2033
  if (!stored) return null;
2009
2034
  if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
2010
2035
  if (!stored.refreshToken) return null;
2011
2036
  if (config.loginKind === "github") {
2012
2037
  const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
2038
+ signal?.throwIfAborted();
2013
2039
  if (!refreshed2) {
2014
2040
  clearTokens(dir);
2015
2041
  return null;
@@ -2026,6 +2052,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2026
2052
  return null;
2027
2053
  }
2028
2054
  const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
2055
+ signal?.throwIfAborted();
2029
2056
  if (!refreshed) {
2030
2057
  clearTokens(dir);
2031
2058
  return null;
@@ -2041,7 +2068,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2041
2068
 
2042
2069
  // src/refresh.ts
2043
2070
  import { execFile } from "node:child_process";
2044
- import { accessSync, chmodSync, constants, existsSync as existsSync7, lstatSync, readdirSync, readFileSync as readFileSync7, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync5, statSync, writeFileSync as writeFileSync7 } from "node:fs";
2071
+ 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
2072
  import { homedir } from "node:os";
2046
2073
  import { basename, dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
2047
2074
  var LAUNCHER_RECORD_FILE = "launcher.json";
@@ -2157,14 +2184,14 @@ function removeStale(file) {
2157
2184
  const prefix = `${basename(file)}.old-`;
2158
2185
  let names;
2159
2186
  try {
2160
- names = readdirSync(dirname3(file));
2187
+ names = readdirSync2(dirname3(file));
2161
2188
  } catch {
2162
2189
  return;
2163
2190
  }
2164
2191
  for (const name of names) {
2165
2192
  if (!name.startsWith(prefix)) continue;
2166
2193
  try {
2167
- rmSync5(join6(dirname3(file), name), { force: true });
2194
+ rmSync6(join6(dirname3(file), name), { force: true });
2168
2195
  } catch {
2169
2196
  }
2170
2197
  }
@@ -2177,7 +2204,7 @@ async function refreshLauncher(target, options = {}) {
2177
2204
  const productDir = options.productDir ?? defaultProductDir(target.product);
2178
2205
  const recorded = recordedPath(productDir);
2179
2206
  const file = options.path ?? (recorded && existsSync7(recorded) ? recorded : installedPath(target, productDir, platform));
2180
- if (!existsSync7(file) || lstatSync(file).isSymbolicLink()) return null;
2207
+ if (!existsSync7(file) || lstatSync2(file).isSymbolicLink()) return null;
2181
2208
  if (!options.dryRun) removeStale(file);
2182
2209
  const from = await (options.readVersion ?? ((path) => runVersion(path, target.bin)))(file);
2183
2210
  if (!from) return null;
@@ -2226,7 +2253,7 @@ async function refreshLauncher(target, options = {}) {
2226
2253
  if (moved && !existsSync7(file)) renameSync4(moved, file);
2227
2254
  } catch {
2228
2255
  }
2229
- rmSync5(next, { force: true });
2256
+ rmSync6(next, { force: true });
2230
2257
  return arm("fail", from, "The launcher could not be replaced.");
2231
2258
  }
2232
2259
  return arm("ok", record.version, "");
@@ -2236,7 +2263,7 @@ async function refreshLauncher(target, options = {}) {
2236
2263
  }
2237
2264
 
2238
2265
  // src/index.ts
2239
- var LAUNCHER_VERSION = true ? "1.3.0" : readVersionFromPackage();
2266
+ var LAUNCHER_VERSION = true ? "1.4.0" : readVersionFromPackage();
2240
2267
  function defaultPrint(message) {
2241
2268
  process.stdout.write(`${message}
2242
2269
  `);
@@ -2758,7 +2785,7 @@ async function installOrUpdateRun(config, dir, options, print, update, flags2, m
2758
2785
  }
2759
2786
  const target = join7(dir, "payload");
2760
2787
  mkdirSync6(dir, { recursive: true });
2761
- rmSync6(target, { recursive: true, force: true });
2788
+ rmSync7(target, { recursive: true, force: true });
2762
2789
  renameSync5(join7(candidateDir, "payload"), target);
2763
2790
  writeState(dir, { version, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
2764
2791
  installer.phase("download", { seconds: (Date.now() - started) / 1e3 });
@@ -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
@@ -2021,7 +2044,8 @@ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetc
2021
2044
  client_id: clientId
2022
2045
  }).toString()
2023
2046
  });
2024
- if (!response.ok) return null;
2047
+ if ([400, 401, 403].includes(response.status)) return null;
2048
+ if (!response.ok) throw new Error(`POST ${server}/oauth/token \u2192 ${response.status}: refresh failed`);
2025
2049
  const tokens = await response.json();
2026
2050
  if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
2027
2051
  return {
@@ -2036,13 +2060,15 @@ function page(title, body) {
2036
2060
  }
2037
2061
 
2038
2062
  // src/tokens.ts
2039
- async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2063
+ async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
2064
+ signal?.throwIfAborted();
2040
2065
  const stored = readTokens(dir);
2041
2066
  if (!stored) return null;
2042
2067
  if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
2043
2068
  if (!stored.refreshToken) return null;
2044
2069
  if (config.loginKind === "github") {
2045
2070
  const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
2071
+ signal?.throwIfAborted();
2046
2072
  if (!refreshed2) {
2047
2073
  clearTokens(dir);
2048
2074
  return null;
@@ -2059,6 +2085,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2059
2085
  return null;
2060
2086
  }
2061
2087
  const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
2088
+ signal?.throwIfAborted();
2062
2089
  if (!refreshed) {
2063
2090
  clearTokens(dir);
2064
2091
  return null;
@@ -2269,7 +2296,7 @@ async function refreshLauncher(target, options = {}) {
2269
2296
  }
2270
2297
 
2271
2298
  // src/index.ts
2272
- var LAUNCHER_VERSION = true ? "1.3.0" : readVersionFromPackage();
2299
+ var LAUNCHER_VERSION = true ? "1.4.0" : readVersionFromPackage();
2273
2300
  function defaultPrint(message) {
2274
2301
  process.stdout.write(`${message}
2275
2302
  `);
package/dist/refresh.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // src/refresh.ts
2
2
  import { execFile } from "node:child_process";
3
- import { accessSync, chmodSync, constants, existsSync as existsSync2, lstatSync, readdirSync, readFileSync as readFileSync2, realpathSync as realpathSync2, renameSync, rmSync, statSync, 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
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
@@ -290,14 +290,14 @@ function removeStale(file) {
290
290
  const prefix = `${basename(file)}.old-`;
291
291
  let names;
292
292
  try {
293
- names = readdirSync(dirname2(file));
293
+ names = readdirSync2(dirname2(file));
294
294
  } catch {
295
295
  return;
296
296
  }
297
297
  for (const name of names) {
298
298
  if (!name.startsWith(prefix)) continue;
299
299
  try {
300
- rmSync(join3(dirname2(file), name), { force: true });
300
+ rmSync2(join3(dirname2(file), name), { force: true });
301
301
  } catch {
302
302
  }
303
303
  }
@@ -310,7 +310,7 @@ async function refreshLauncher(target, options = {}) {
310
310
  const productDir = options.productDir ?? defaultProductDir(target.product);
311
311
  const recorded = recordedPath(productDir);
312
312
  const file = options.path ?? (recorded && existsSync2(recorded) ? recorded : installedPath(target, productDir, platform));
313
- if (!existsSync2(file) || lstatSync(file).isSymbolicLink()) return null;
313
+ if (!existsSync2(file) || lstatSync2(file).isSymbolicLink()) return null;
314
314
  if (!options.dryRun) removeStale(file);
315
315
  const from = await (options.readVersion ?? ((path) => runVersion(path, target.bin)))(file);
316
316
  if (!from) return null;
@@ -359,7 +359,7 @@ async function refreshLauncher(target, options = {}) {
359
359
  if (moved && !existsSync2(file)) renameSync(moved, file);
360
360
  } catch {
361
361
  }
362
- rmSync(next, { force: true });
362
+ rmSync2(next, { force: true });
363
363
  return arm("fail", from, "The launcher could not be replaced.");
364
364
  }
365
365
  return arm("ok", record.version, "");
@@ -0,0 +1,19 @@
1
+ export interface SessionOptions {
2
+ product: string;
3
+ host: string;
4
+ loginKind: 'github' | 'google';
5
+ /** Overrides LAUNCHER_DIR and the platform's default product directory. */
6
+ dir?: string;
7
+ /** Optional transport injection; defaults to the global fetch. */
8
+ fetch?: typeof fetch;
9
+ /** Cancellation for the caller's overall attempt. */
10
+ signal?: AbortSignal;
11
+ /** Auth budget in milliseconds: defaults to 10,000; positive and at most 40,000. */
12
+ timeoutMs?: number;
13
+ }
14
+ /** The caller must arrange sign-in; this API never starts authentication. */
15
+ export declare class LoginRequiredError extends Error {
16
+ constructor();
17
+ }
18
+ /** Returns an existing gate access token, refreshing and persisting it when necessary. */
19
+ export declare function getAccessToken(options: SessionOptions): Promise<string>;
@@ -0,0 +1,236 @@
1
+ // src/acquisition.ts
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, realpathSync, readdirSync, lstatSync, rmSync as rmSync2 } from "node:fs";
4
+ import { join as join2, resolve, relative, isAbsolute, dirname } from "node:path";
5
+
6
+ // src/store.ts
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, renameSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ function defaultProductDir(product) {
11
+ if (process.platform === "win32") {
12
+ const base = process.env.LOCALAPPDATA ?? join(tmpdir(), "launcher-fallback");
13
+ return join(base, product);
14
+ }
15
+ const home = process.env.HOME ?? tmpdir();
16
+ return join(home, `.${product}`);
17
+ }
18
+ function resolveProductDir(product, explicit) {
19
+ return explicit ?? process.env.LAUNCHER_DIR ?? defaultProductDir(product);
20
+ }
21
+ function tokensPath(dir) {
22
+ return join(dir, "tokens.json");
23
+ }
24
+ function readTokens(dir) {
25
+ try {
26
+ const data = JSON.parse(readFileSync(tokensPath(dir), "utf8"));
27
+ if (typeof data.accessToken !== "string" || !data.accessToken) return null;
28
+ if (typeof data.expiresAt !== "number" || !Number.isFinite(data.expiresAt)) return null;
29
+ const tokens = { accessToken: data.accessToken, expiresAt: data.expiresAt };
30
+ if (typeof data.refreshToken === "string" && data.refreshToken) tokens.refreshToken = data.refreshToken;
31
+ if (typeof data.clientId === "string" && data.clientId) tokens.clientId = data.clientId;
32
+ return tokens;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function writeTokens(dir, tokens) {
38
+ mkdirSync(dir, { recursive: true });
39
+ try {
40
+ writeFileSync(tokensPath(dir), `${JSON.stringify(tokens)}
41
+ `, { mode: 384 });
42
+ } catch {
43
+ writeFileSync(tokensPath(dir), `${JSON.stringify(tokens)}
44
+ `);
45
+ }
46
+ }
47
+ function clearTokens(dir) {
48
+ rmSync(tokensPath(dir), { force: true });
49
+ }
50
+
51
+ // src/config.ts
52
+ import { getAsset } from "node:sea";
53
+
54
+ // src/acquisition.ts
55
+ function lockInstallation(dir) {
56
+ mkdirSync2(dir, { recursive: true });
57
+ const path = join2(dir, "installation.lock");
58
+ const owner = `${process.pid}:${randomUUID()}`;
59
+ try {
60
+ writeFileSync2(path, owner, { flag: "wx", mode: 384 });
61
+ } catch (error) {
62
+ if (error.code !== "EEXIST") throw error;
63
+ const recovery = `${path}.recovery`;
64
+ try {
65
+ writeFileSync2(recovery, owner, { flag: "wx", mode: 384, flush: true });
66
+ } catch {
67
+ throw new Error("installation lock recovery is already pending");
68
+ }
69
+ try {
70
+ const previous = readFileSync2(path, "utf8");
71
+ const pid = Number(previous.split(":")[0]);
72
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("installation lock is malformed; recovery required");
73
+ try {
74
+ process.kill(pid, 0);
75
+ throw new Error("another installer is running");
76
+ } catch (probe) {
77
+ if (probe.code !== "ESRCH") throw probe;
78
+ }
79
+ unlinkSync(path);
80
+ writeFileSync2(path, owner, { flag: "wx", mode: 384, flush: true });
81
+ } finally {
82
+ unlinkSync(recovery);
83
+ }
84
+ }
85
+ let released = false;
86
+ return () => {
87
+ if (!released && readFileSync2(path, "utf8") === owner) unlinkSync(path);
88
+ released = true;
89
+ };
90
+ }
91
+
92
+ // src/login-github.ts
93
+ async function postJson(url, body, fetchImpl) {
94
+ return fetchImpl(url, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/json" },
97
+ body: JSON.stringify(body)
98
+ });
99
+ }
100
+ async function readBody(response) {
101
+ try {
102
+ return await response.text();
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+ function gateRefusal(method, url, status, body) {
108
+ const head = body.replace(/\s+/g, " ").trim().slice(0, 200);
109
+ return `the sign-in server refused the request \u2014 ${method} ${url} \u2192 ${status}${head ? `: ${head}` : ""}`;
110
+ }
111
+ async function refreshAccessToken(config, refreshToken, fetchImpl = fetch) {
112
+ const url = `${config.host}/gate/refresh`;
113
+ const response = await postJson(url, { refresh_token: refreshToken }, fetchImpl);
114
+ if (response.status === 403) return null;
115
+ if (!response.ok) {
116
+ throw new Error(gateRefusal("POST", url, response.status, await readBody(response)));
117
+ }
118
+ const data = await response.json();
119
+ if (typeof data.access_token !== "string" || typeof data.expires_in !== "number") {
120
+ throw new Error("the sign-in server returned a malformed refresh");
121
+ }
122
+ return {
123
+ accessToken: data.access_token,
124
+ expiresIn: data.expires_in,
125
+ ...typeof data.refresh_token === "string" ? { refreshToken: data.refresh_token } : {}
126
+ };
127
+ }
128
+
129
+ // src/login-google.ts
130
+ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetch) {
131
+ const server = host.replace(/\/+$/, "");
132
+ const response = await fetchImpl(`${server}/oauth/token`, {
133
+ method: "POST",
134
+ headers: { "content-type": "application/x-www-form-urlencoded" },
135
+ body: new URLSearchParams({
136
+ grant_type: "refresh_token",
137
+ refresh_token: refreshToken,
138
+ client_id: clientId
139
+ }).toString()
140
+ });
141
+ if ([400, 401, 403].includes(response.status)) return null;
142
+ if (!response.ok) throw new Error(`POST ${server}/oauth/token \u2192 ${response.status}: refresh failed`);
143
+ const tokens = await response.json();
144
+ if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
145
+ return {
146
+ accessToken: tokens.access_token,
147
+ refreshToken: typeof tokens.refresh_token === "string" ? tokens.refresh_token : refreshToken,
148
+ expiresIn: tokens.expires_in
149
+ };
150
+ }
151
+
152
+ // src/tokens.ts
153
+ async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
154
+ signal?.throwIfAborted();
155
+ const stored = readTokens(dir);
156
+ if (!stored) return null;
157
+ if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
158
+ if (!stored.refreshToken) return null;
159
+ if (config.loginKind === "github") {
160
+ const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
161
+ signal?.throwIfAborted();
162
+ if (!refreshed2) {
163
+ clearTokens(dir);
164
+ return null;
165
+ }
166
+ writeTokens(dir, {
167
+ accessToken: refreshed2.accessToken,
168
+ refreshToken: refreshed2.refreshToken ?? stored.refreshToken,
169
+ expiresAt: Date.now() + refreshed2.expiresIn * 1e3
170
+ });
171
+ return refreshed2.accessToken;
172
+ }
173
+ if (!stored.clientId) {
174
+ clearTokens(dir);
175
+ return null;
176
+ }
177
+ const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
178
+ signal?.throwIfAborted();
179
+ if (!refreshed) {
180
+ clearTokens(dir);
181
+ return null;
182
+ }
183
+ writeTokens(dir, {
184
+ accessToken: refreshed.accessToken,
185
+ refreshToken: refreshed.refreshToken,
186
+ expiresAt: Date.now() + refreshed.expiresIn * 1e3,
187
+ clientId: stored.clientId
188
+ });
189
+ return refreshed.accessToken;
190
+ }
191
+
192
+ // src/session.ts
193
+ var LoginRequiredError = class extends Error {
194
+ constructor() {
195
+ super("Product sign-in is required.");
196
+ this.name = "LoginRequiredError";
197
+ }
198
+ };
199
+ async function getAccessToken(options) {
200
+ if (!options.product || options.loginKind !== "github" && options.loginKind !== "google") {
201
+ throw new Error("Session options require product and loginKind (github or google).");
202
+ }
203
+ const host = new URL(options.host);
204
+ if (host.protocol !== "https:" && host.protocol !== "http:") {
205
+ throw new Error("Session host must be an http(s) URL.");
206
+ }
207
+ const timeoutMs = options.timeoutMs ?? 1e4;
208
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 4e4) {
209
+ throw new Error("Session timeoutMs must be a positive integer at most 40000.");
210
+ }
211
+ const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...options.signal ? [options.signal] : []]);
212
+ signal.throwIfAborted();
213
+ const transport = options.fetch ?? fetch;
214
+ const fetchImpl = (input, init) => {
215
+ const inherited = init?.signal ?? (input instanceof Request ? input.signal : void 0);
216
+ return transport(input, { ...init, signal: inherited ? AbortSignal.any([signal, inherited]) : signal });
217
+ };
218
+ const dir = resolveProductDir(options.product, options.dir);
219
+ const unlock = lockInstallation(dir);
220
+ try {
221
+ const token = await ensureFreshToken(
222
+ { host: options.host.replace(/\/+$/, ""), loginKind: options.loginKind },
223
+ dir,
224
+ fetchImpl,
225
+ signal
226
+ );
227
+ if (!token) throw new LoginRequiredError();
228
+ return token;
229
+ } finally {
230
+ unlock();
231
+ }
232
+ }
233
+ export {
234
+ LoginRequiredError,
235
+ getAccessToken
236
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-launcher",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Single-executable (SEA) launcher: sign-in, gated payload fetch, self-update. One copy ships per product.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -14,7 +14,11 @@
14
14
  "exports": {
15
15
  ".": "./dist/launcher.js",
16
16
  "./acquisition-required": "./dist/acquisition-required.mjs",
17
- "./refresh": "./dist/refresh.js"
17
+ "./refresh": "./dist/refresh.js",
18
+ "./session": {
19
+ "types": "./dist/session.d.ts",
20
+ "import": "./dist/session.js"
21
+ }
18
22
  },
19
23
  "files": [
20
24
  "dist",