@mutmutco/installer-launcher 1.3.1 → 1.4.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 +44 -0
- package/dist/launcher.js +10 -4
- package/dist/launcher.sea.cjs +10 -4
- 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:
|
package/dist/launcher.js
CHANGED
|
@@ -1295,6 +1295,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1295
1295
|
};
|
|
1296
1296
|
let started = false;
|
|
1297
1297
|
let finished = false;
|
|
1298
|
+
let currentSurfaces = 0;
|
|
1298
1299
|
let operation = options.operation;
|
|
1299
1300
|
const start = () => {
|
|
1300
1301
|
if (started || finished) return;
|
|
@@ -1363,6 +1364,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1363
1364
|
if (!status) throw new Error("installer run: unknown surface state");
|
|
1364
1365
|
const separator = tty ? "\xB7" : "-";
|
|
1365
1366
|
const activation = facts.state === "updated" && surface.activation ? ` ${separator} ${surface.activation}` : "";
|
|
1367
|
+
if (facts.state === "current" && facts.id !== LAUNCHER_SURFACE) currentSurfaces++;
|
|
1366
1368
|
const completed = ["updated", "current", "kept", "skipped"].includes(facts.state);
|
|
1367
1369
|
durable(`${facts.id}${versions} ${separator} ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
|
|
1368
1370
|
if (facts.detail) run2.relay(facts.detail);
|
|
@@ -1465,7 +1467,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1465
1467
|
spinner.stop();
|
|
1466
1468
|
finished = true;
|
|
1467
1469
|
if (options.quiet && facts.updated === 0 && facts.failed === 0 && !facts.operationFailed) return;
|
|
1468
|
-
const changed = !facts.version ? "No release target is available." : facts.dryRun ? `Would update ${facts.updated} of ${facts.total} surfaces to ${facts.version}.` : facts.installed ? `Installed ${facts.version} across ${facts.total} surfaces.` : `Updated ${facts.updated} of ${facts.total} surfaces to ${facts.version}.`;
|
|
1470
|
+
const changed = !facts.version ? "No release target is available." : facts.dryRun ? `Would update ${facts.updated} of ${facts.total} surfaces to ${facts.version}.` : facts.installed ? `Installed ${facts.version} across ${facts.total} surfaces.` : facts.updated === 0 && currentSurfaces > 0 ? `${currentSurfaces >= facts.total ? "All" : `${currentSurfaces} of`} ${facts.total} surfaces already at ${facts.version}.` : `Updated ${facts.updated} of ${facts.total} surfaces to ${facts.version}.`;
|
|
1469
1471
|
const ready = facts.failed === 0 && !facts.dryRun && !facts.deferred && !facts.operationFailed && Boolean(facts.version);
|
|
1470
1472
|
const deferred = Boolean(facts.deferred) && facts.failed === 0 && !facts.operationFailed;
|
|
1471
1473
|
const body = [
|
|
@@ -2011,7 +2013,8 @@ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetc
|
|
|
2011
2013
|
client_id: clientId
|
|
2012
2014
|
}).toString()
|
|
2013
2015
|
});
|
|
2014
|
-
if (
|
|
2016
|
+
if ([400, 401, 403].includes(response.status)) return null;
|
|
2017
|
+
if (!response.ok) throw new Error(`POST ${server}/oauth/token \u2192 ${response.status}: refresh failed`);
|
|
2015
2018
|
const tokens = await response.json();
|
|
2016
2019
|
if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
|
|
2017
2020
|
return {
|
|
@@ -2026,13 +2029,15 @@ function page(title, body) {
|
|
|
2026
2029
|
}
|
|
2027
2030
|
|
|
2028
2031
|
// src/tokens.ts
|
|
2029
|
-
async function ensureFreshToken(config, dir, fetchImpl = fetch) {
|
|
2032
|
+
async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
|
|
2033
|
+
signal?.throwIfAborted();
|
|
2030
2034
|
const stored = readTokens(dir);
|
|
2031
2035
|
if (!stored) return null;
|
|
2032
2036
|
if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
|
|
2033
2037
|
if (!stored.refreshToken) return null;
|
|
2034
2038
|
if (config.loginKind === "github") {
|
|
2035
2039
|
const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
|
|
2040
|
+
signal?.throwIfAborted();
|
|
2036
2041
|
if (!refreshed2) {
|
|
2037
2042
|
clearTokens(dir);
|
|
2038
2043
|
return null;
|
|
@@ -2049,6 +2054,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
|
|
|
2049
2054
|
return null;
|
|
2050
2055
|
}
|
|
2051
2056
|
const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
|
|
2057
|
+
signal?.throwIfAborted();
|
|
2052
2058
|
if (!refreshed) {
|
|
2053
2059
|
clearTokens(dir);
|
|
2054
2060
|
return null;
|
|
@@ -2259,7 +2265,7 @@ async function refreshLauncher(target, options = {}) {
|
|
|
2259
2265
|
}
|
|
2260
2266
|
|
|
2261
2267
|
// src/index.ts
|
|
2262
|
-
var LAUNCHER_VERSION = true ? "1.
|
|
2268
|
+
var LAUNCHER_VERSION = true ? "1.4.1" : readVersionFromPackage();
|
|
2263
2269
|
function defaultPrint(message) {
|
|
2264
2270
|
process.stdout.write(`${message}
|
|
2265
2271
|
`);
|
package/dist/launcher.sea.cjs
CHANGED
|
@@ -1328,6 +1328,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1328
1328
|
};
|
|
1329
1329
|
let started = false;
|
|
1330
1330
|
let finished = false;
|
|
1331
|
+
let currentSurfaces = 0;
|
|
1331
1332
|
let operation = options.operation;
|
|
1332
1333
|
const start = () => {
|
|
1333
1334
|
if (started || finished) return;
|
|
@@ -1396,6 +1397,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1396
1397
|
if (!status) throw new Error("installer run: unknown surface state");
|
|
1397
1398
|
const separator = tty ? "\xB7" : "-";
|
|
1398
1399
|
const activation = facts.state === "updated" && surface.activation ? ` ${separator} ${surface.activation}` : "";
|
|
1400
|
+
if (facts.state === "current" && facts.id !== LAUNCHER_SURFACE) currentSurfaces++;
|
|
1399
1401
|
const completed = ["updated", "current", "kept", "skipped"].includes(facts.state);
|
|
1400
1402
|
durable(`${facts.id}${versions} ${separator} ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
|
|
1401
1403
|
if (facts.detail) run2.relay(facts.detail);
|
|
@@ -1498,7 +1500,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1498
1500
|
spinner.stop();
|
|
1499
1501
|
finished = true;
|
|
1500
1502
|
if (options.quiet && facts.updated === 0 && facts.failed === 0 && !facts.operationFailed) return;
|
|
1501
|
-
const changed = !facts.version ? "No release target is available." : facts.dryRun ? `Would update ${facts.updated} of ${facts.total} surfaces to ${facts.version}.` : facts.installed ? `Installed ${facts.version} across ${facts.total} surfaces.` : `Updated ${facts.updated} of ${facts.total} surfaces to ${facts.version}.`;
|
|
1503
|
+
const changed = !facts.version ? "No release target is available." : facts.dryRun ? `Would update ${facts.updated} of ${facts.total} surfaces to ${facts.version}.` : facts.installed ? `Installed ${facts.version} across ${facts.total} surfaces.` : facts.updated === 0 && currentSurfaces > 0 ? `${currentSurfaces >= facts.total ? "All" : `${currentSurfaces} of`} ${facts.total} surfaces already at ${facts.version}.` : `Updated ${facts.updated} of ${facts.total} surfaces to ${facts.version}.`;
|
|
1502
1504
|
const ready = facts.failed === 0 && !facts.dryRun && !facts.deferred && !facts.operationFailed && Boolean(facts.version);
|
|
1503
1505
|
const deferred = Boolean(facts.deferred) && facts.failed === 0 && !facts.operationFailed;
|
|
1504
1506
|
const body = [
|
|
@@ -2044,7 +2046,8 @@ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetc
|
|
|
2044
2046
|
client_id: clientId
|
|
2045
2047
|
}).toString()
|
|
2046
2048
|
});
|
|
2047
|
-
if (
|
|
2049
|
+
if ([400, 401, 403].includes(response.status)) return null;
|
|
2050
|
+
if (!response.ok) throw new Error(`POST ${server}/oauth/token \u2192 ${response.status}: refresh failed`);
|
|
2048
2051
|
const tokens = await response.json();
|
|
2049
2052
|
if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
|
|
2050
2053
|
return {
|
|
@@ -2059,13 +2062,15 @@ function page(title, body) {
|
|
|
2059
2062
|
}
|
|
2060
2063
|
|
|
2061
2064
|
// src/tokens.ts
|
|
2062
|
-
async function ensureFreshToken(config, dir, fetchImpl = fetch) {
|
|
2065
|
+
async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
|
|
2066
|
+
signal?.throwIfAborted();
|
|
2063
2067
|
const stored = readTokens(dir);
|
|
2064
2068
|
if (!stored) return null;
|
|
2065
2069
|
if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
|
|
2066
2070
|
if (!stored.refreshToken) return null;
|
|
2067
2071
|
if (config.loginKind === "github") {
|
|
2068
2072
|
const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
|
|
2073
|
+
signal?.throwIfAborted();
|
|
2069
2074
|
if (!refreshed2) {
|
|
2070
2075
|
clearTokens(dir);
|
|
2071
2076
|
return null;
|
|
@@ -2082,6 +2087,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
|
|
|
2082
2087
|
return null;
|
|
2083
2088
|
}
|
|
2084
2089
|
const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
|
|
2090
|
+
signal?.throwIfAborted();
|
|
2085
2091
|
if (!refreshed) {
|
|
2086
2092
|
clearTokens(dir);
|
|
2087
2093
|
return null;
|
|
@@ -2292,7 +2298,7 @@ async function refreshLauncher(target, options = {}) {
|
|
|
2292
2298
|
}
|
|
2293
2299
|
|
|
2294
2300
|
// src/index.ts
|
|
2295
|
-
var LAUNCHER_VERSION = true ? "1.
|
|
2301
|
+
var LAUNCHER_VERSION = true ? "1.4.1" : readVersionFromPackage();
|
|
2296
2302
|
function defaultPrint(message) {
|
|
2297
2303
|
process.stdout.write(`${message}
|
|
2298
2304
|
`);
|
|
@@ -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.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",
|
|
@@ -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",
|