@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 +45 -1
- package/dist/launcher.js +43 -16
- package/dist/launcher.sea.cjs +30 -3
- package/dist/refresh.js +6 -6
- package/dist/session.d.ts +19 -0
- package/dist/session.js +236 -0
- package/package.json +6 -2
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1665
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 });
|
package/dist/launcher.sea.cjs
CHANGED
|
@@ -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 (
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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>;
|
package/dist/session.js
ADDED
|
@@ -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
|
+
"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",
|