@mutmutco/installer-launcher 1.3.1 → 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:
package/dist/launcher.js CHANGED
@@ -2011,7 +2011,8 @@ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetc
2011
2011
  client_id: clientId
2012
2012
  }).toString()
2013
2013
  });
2014
- 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`);
2015
2016
  const tokens = await response.json();
2016
2017
  if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
2017
2018
  return {
@@ -2026,13 +2027,15 @@ function page(title, body) {
2026
2027
  }
2027
2028
 
2028
2029
  // src/tokens.ts
2029
- async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2030
+ async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
2031
+ signal?.throwIfAborted();
2030
2032
  const stored = readTokens(dir);
2031
2033
  if (!stored) return null;
2032
2034
  if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
2033
2035
  if (!stored.refreshToken) return null;
2034
2036
  if (config.loginKind === "github") {
2035
2037
  const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
2038
+ signal?.throwIfAborted();
2036
2039
  if (!refreshed2) {
2037
2040
  clearTokens(dir);
2038
2041
  return null;
@@ -2049,6 +2052,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2049
2052
  return null;
2050
2053
  }
2051
2054
  const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
2055
+ signal?.throwIfAborted();
2052
2056
  if (!refreshed) {
2053
2057
  clearTokens(dir);
2054
2058
  return null;
@@ -2259,7 +2263,7 @@ async function refreshLauncher(target, options = {}) {
2259
2263
  }
2260
2264
 
2261
2265
  // src/index.ts
2262
- var LAUNCHER_VERSION = true ? "1.3.1" : readVersionFromPackage();
2266
+ var LAUNCHER_VERSION = true ? "1.4.0" : readVersionFromPackage();
2263
2267
  function defaultPrint(message) {
2264
2268
  process.stdout.write(`${message}
2265
2269
  `);
@@ -2044,7 +2044,8 @@ async function refreshGoogleToken(host, clientId, refreshToken, fetchImpl = fetc
2044
2044
  client_id: clientId
2045
2045
  }).toString()
2046
2046
  });
2047
- 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`);
2048
2049
  const tokens = await response.json();
2049
2050
  if (typeof tokens.access_token !== "string" || typeof tokens.expires_in !== "number") return null;
2050
2051
  return {
@@ -2059,13 +2060,15 @@ function page(title, body) {
2059
2060
  }
2060
2061
 
2061
2062
  // src/tokens.ts
2062
- async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2063
+ async function ensureFreshToken(config, dir, fetchImpl = fetch, signal) {
2064
+ signal?.throwIfAborted();
2063
2065
  const stored = readTokens(dir);
2064
2066
  if (!stored) return null;
2065
2067
  if (stored.expiresAt - Date.now() > 6e4) return stored.accessToken;
2066
2068
  if (!stored.refreshToken) return null;
2067
2069
  if (config.loginKind === "github") {
2068
2070
  const refreshed2 = await refreshAccessToken(config, stored.refreshToken, fetchImpl);
2071
+ signal?.throwIfAborted();
2069
2072
  if (!refreshed2) {
2070
2073
  clearTokens(dir);
2071
2074
  return null;
@@ -2082,6 +2085,7 @@ async function ensureFreshToken(config, dir, fetchImpl = fetch) {
2082
2085
  return null;
2083
2086
  }
2084
2087
  const refreshed = await refreshGoogleToken(config.host, stored.clientId, stored.refreshToken, fetchImpl);
2088
+ signal?.throwIfAborted();
2085
2089
  if (!refreshed) {
2086
2090
  clearTokens(dir);
2087
2091
  return null;
@@ -2292,7 +2296,7 @@ async function refreshLauncher(target, options = {}) {
2292
2296
  }
2293
2297
 
2294
2298
  // src/index.ts
2295
- var LAUNCHER_VERSION = true ? "1.3.1" : readVersionFromPackage();
2299
+ var LAUNCHER_VERSION = true ? "1.4.0" : readVersionFromPackage();
2296
2300
  function defaultPrint(message) {
2297
2301
  process.stdout.write(`${message}
2298
2302
  `);
@@ -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.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",