@kurtbruns/kestrel 1.13.0 → 1.15.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +2 -2
  3. package/bin/kestrel.mjs +21 -2
  4. package/cli/dev.mjs +6 -2
  5. package/cli/init.mjs +2 -1
  6. package/cli/reset.mjs +1 -1
  7. package/cli/seed.mjs +3 -3
  8. package/dist/public/dashboard/app.js +290 -285
  9. package/dist/public/dashboard/app.js.map +4 -4
  10. package/dist/public/dashboard/index.html +2 -2
  11. package/dist/public/dashboard/styles.css +74 -40
  12. package/dist/worker/index.js +2723 -1192
  13. package/docs/README.md +3 -2
  14. package/docs/get-started/02-deploy.md +18 -21
  15. package/docs/get-started/03-access.md +10 -8
  16. package/docs/get-started/06-go-live.md +2 -1
  17. package/docs/guides/01-allow-claude.md +68 -0
  18. package/docs/guides/02-automate.md +88 -0
  19. package/docs/guides/09-as-your-list-grows.md +2 -2
  20. package/docs/publishing/01-your-list.md +3 -3
  21. package/docs/publishing/03-name-your-publication.md +1 -1
  22. package/docs/publishing/04-write-a-post.md +15 -52
  23. package/docs/publishing/06-troubleshooting.md +20 -9
  24. package/docs/publishing/08-connect-claude.md +60 -0
  25. package/docs/publishing/09-share-your-subscribe-link.md +1 -1
  26. package/docs/publishing/10-check-your-setup.md +9 -16
  27. package/docs/publishing/11-email-template.md +1 -1
  28. package/docs/publishing/README.md +2 -2
  29. package/docs/reference/01-configuration.md +132 -85
  30. package/docs/reference/02-sending-domain-dns.md +2 -2
  31. package/migrations/0013_who_acted_client.sql +20 -0
  32. package/migrations/0014_send_acts.sql +25 -0
  33. package/package.json +2 -1
  34. package/packages/kestrel/lib/api.mjs +462 -0
  35. package/packages/kestrel/lib/credentials.mjs +192 -0
  36. package/{cli → packages/kestrel/lib}/dev-api.mjs +21 -11
  37. package/{cli → packages/kestrel/lib}/dev-server.mjs +2 -2
  38. package/packages/kestrel/lib/handoff.mjs +118 -0
  39. package/packages/kestrel/lib/login.mjs +155 -0
  40. package/packages/kestrel/lib/oauth.mjs +368 -0
  41. package/packages/kestrel/lib/openapi.mjs +569 -0
  42. package/template/README.md +2 -0
  43. package/template/claude/CLAUDE.md +8 -5
  44. package/docs/guides/01-connect-claude.md +0 -101
  45. package/docs/publishing/07-working-with-claude.md +0 -29
@@ -0,0 +1,462 @@
1
+ /*
2
+ * `kestrel api`: call an instance's API as the person signed in, naming the program the
3
+ * request came through, the way Claude Code reaches Kestrel (SPEC §11, Who acted).
4
+ *
5
+ * Deployed, it sends the access token `kestrel login` stored, refreshing it from the
6
+ * refresh token when it is about to expire or Access has turned it away, and storing what
7
+ * comes back. Locally there is no Access: the target is this directory's dev server, and
8
+ * the credential is the dev server's human token, so a local run is a person via Claude
9
+ * Code just as a deployed one is. Every request carries `Kestrel-Client: claude-code`
10
+ * unless `--client` names another program, so a person's own script can name itself. It
11
+ * never names the editor: the server trusts the name it is sent (SPEC, Trusted, not guarded),
12
+ * and the editor's name lifts a step only a person in the editor may take (SPEC §7).
13
+ *
14
+ * It prints the answer's body as it came, so a script or Claude reads a refusal whole; a
15
+ * non-2xx answer also prints its status, and a refusal's code, message, field, and next move,
16
+ * on stderr and exits 1, as curl --fail-with-body.
17
+ *
18
+ * What the API is comes from the instance itself: `--list` and `help` read its OpenAPI
19
+ * document through the same sign-in, as `-f` and `-F` do to build a body and refuse a field
20
+ * the route doesn't take (packages/kestrel/lib/openapi.mjs). It is read fresh each time it is needed, not
21
+ * cached, so it can never describe a release the instance no longer runs.
22
+ */
23
+ import { readFileSync } from "node:fs";
24
+ import { basename } from "node:path";
25
+ import { readSignIn, readSignIns, withCredentialsLock, writeSignInHeld } from "./credentials.mjs";
26
+ import { devTarget } from "./dev-api.mjs";
27
+ import {
28
+ DEFAULT_CLIENT,
29
+ instanceOrigin,
30
+ isLoopback,
31
+ needsRefresh,
32
+ refreshTokens,
33
+ SignInError,
34
+ tokenFields,
35
+ } from "./oauth.mjs";
36
+ import {
37
+ buildFields,
38
+ describeRefusal,
39
+ describeRoute,
40
+ findOperation,
41
+ listRoutes,
42
+ } from "./openapi.mjs";
43
+
44
+ const USAGE = `Usage: kestrel api <method> <path> [options]
45
+ kestrel api --list [resource] the instance's routes, or one resource's
46
+ kestrel api help <method> <path> one route: its fields, refusals, and answer
47
+
48
+ -f, --raw-field <name=value> a body field, as a string; repeatable. On a GET, or for a name
49
+ the route takes only as one, a query parameter
50
+ -F, --field <name=value> a body field typed by the route's schema (true, 3, null, JSON),
51
+ or name=@file for a file's text. name.sub nests; name[] lists
52
+ --data <json|@file|-> the request body: inline, from a file, or from stdin
53
+ --type <media type> the body's type (default application/json)
54
+ --form <name=value> a multipart field, repeatable; name=@file attaches a file
55
+ --url <url> the instance (default: KESTREL_URL, else your one sign-in)
56
+ --local this directory's dev server, started by kestrel dev
57
+ --client <name> your own script's Kestrel-Client name (default ${DEFAULT_CLIENT});
58
+ never editor, which only the editor names itself
59
+
60
+ The routes, fields, and refusals come from the instance's own /api/openapi.json, so they
61
+ match the release it runs. A field the route doesn't take is refused before the request.`;
62
+
63
+ // The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11).
64
+ const EDITOR_CLIENT = "editor";
65
+
66
+ // The server's rule for a client name (shared/who_acted.ts), mirrored since the CLI is plain
67
+ // Node; a spec holds the two equal. Checking it here, after the trim fetch applies to a header
68
+ // value, means the name refused is the one the server would have read.
69
+ export const CLIENT_NAME_MAX = 32;
70
+ export const CLIENT_NAME_PATTERN = "^[a-z0-9]+(?:-[a-z0-9]+)*$";
71
+
72
+ const ONE_WAY = "Send the body one way: --data, --form, or -f and -F fields, not two.";
73
+
74
+ // Options that take a value, by the name they are stored under.
75
+ const VALUED = {
76
+ data: "data",
77
+ type: "type",
78
+ url: "url",
79
+ client: "client",
80
+ form: "forms",
81
+ "raw-field": "fields",
82
+ f: "fields",
83
+ field: "fields",
84
+ F: "fields",
85
+ };
86
+
87
+ /**
88
+ * Read `kestrel api`'s argv. A call is a method and a path, `--form`, `-f`, and `-F`
89
+ * repeatable; `--list [resource]`, `help`, and `help <resource>` list the routes, and
90
+ * `help <method> <path>` describes one. The body is sent one way: `--data`, `--form`, or
91
+ * fields, never two at once, since merging them would leave a reader guessing which won; a
92
+ * field that is a query parameter may go beside `--data` or `--form`.
93
+ */
94
+ export function parseApiArgs(argv) {
95
+ const opts = { forms: [], fields: [], local: false, list: false };
96
+ const positional = [];
97
+ for (let i = 0; i < argv.length; i++) {
98
+ const arg = argv[i];
99
+ const eq = /^--([^=]+)=(.*)$/s.exec(arg);
100
+ const short = /^-([A-Za-z])$/.exec(arg);
101
+ const name = eq ? eq[1] : short ? short[1] : arg.startsWith("--") ? arg.slice(2) : undefined;
102
+ if (name === undefined) {
103
+ positional.push(arg);
104
+ continue;
105
+ }
106
+ if (name === "local" || name === "list") {
107
+ // `--list=sends` names its resource as `--list sends` does; --local names nothing.
108
+ if (eq && name === "local") {
109
+ throw new SignInError(`--local takes no value; to name a server, use --url <url>.`);
110
+ }
111
+ if (eq) {
112
+ positional.push(eq[2]);
113
+ }
114
+ opts[name] = true;
115
+ continue;
116
+ }
117
+ const key = VALUED[name];
118
+ const flag = short ? `-${name}` : `--${name}`;
119
+ if (!key) {
120
+ throw new SignInError(`unknown option ${flag}\n\n${USAGE}`);
121
+ }
122
+ const value = eq ? eq[2] : argv[++i];
123
+ if (value === undefined) {
124
+ throw new SignInError(`${flag} needs a value\n\n${USAGE}`);
125
+ }
126
+ if (key === "fields") {
127
+ opts.fields.push({ typed: name === "F" || name === "field", text: value });
128
+ } else if (key === "forms") {
129
+ opts.forms.push(value);
130
+ } else {
131
+ opts[key] = value;
132
+ }
133
+ }
134
+ if (opts.client !== undefined) {
135
+ opts.client = opts.client.trim();
136
+ if (
137
+ opts.client.length > CLIENT_NAME_MAX ||
138
+ !new RegExp(CLIENT_NAME_PATTERN).test(opts.client)
139
+ ) {
140
+ throw new SignInError(
141
+ `--client takes one short lowercase word, its parts joined by hyphens (my-script), up to ${CLIENT_NAME_MAX} characters.`,
142
+ );
143
+ }
144
+ if (opts.client === EDITOR_CLIENT) {
145
+ throw new SignInError(
146
+ "--client editor is refused: only the editor names itself the editor, since some acts only a person in the editor may confirm. Name your own program, or leave --client out.",
147
+ );
148
+ }
149
+ }
150
+ const { list, ...rest } = opts;
151
+ const bodies = [opts.data !== undefined, opts.forms.length > 0, opts.fields.length > 0];
152
+ const help = positional[0] === "help";
153
+ if (list || help) {
154
+ const args = help ? positional.slice(1) : positional;
155
+ if (bodies.some(Boolean)) {
156
+ throw new SignInError("--list and help send nothing, so they take no body or fields.");
157
+ }
158
+ if (args.length === 2 && help) {
159
+ const [method, path] = args;
160
+ return { mode: "help", method: method.toUpperCase(), path, ...rest };
161
+ }
162
+ if (args.length > 1) {
163
+ throw new SignInError(USAGE);
164
+ }
165
+ return { mode: "list", resource: args[0], ...rest };
166
+ }
167
+ const [method, path, ...extra] = positional;
168
+ if (!method || !path || extra.length) {
169
+ throw new SignInError(USAGE);
170
+ }
171
+ if (!path.startsWith("/")) {
172
+ throw new SignInError(`The path starts with a slash, as in /api/posts.\n\n${USAGE}`);
173
+ }
174
+ // Fields may go with --data or --form only as query parameters, which apiCommand checks
175
+ // once it knows where each field goes.
176
+ if (bodies[0] && bodies[1]) {
177
+ throw new SignInError(ONE_WAY);
178
+ }
179
+ return { mode: "call", method: method.toUpperCase(), path, ...rest };
180
+ }
181
+
182
+ /**
183
+ * Which instance a call goes to: `--url`, then `KESTREL_URL`, then the one instance this
184
+ * person is signed in to. Several sign-ins and nothing naming one is a refusal, not a guess.
185
+ */
186
+ export function chooseInstance({ url, env = process.env, signIns }) {
187
+ if (url) {
188
+ return instanceOrigin(url);
189
+ }
190
+ if (env.KESTREL_URL) {
191
+ return instanceOrigin(env.KESTREL_URL);
192
+ }
193
+ const origins = Object.keys(signIns);
194
+ if (origins.length === 1) {
195
+ return origins[0];
196
+ }
197
+ if (origins.length === 0) {
198
+ throw new SignInError(
199
+ "You aren't signed in to an instance. Run `npx kestrel login <url>` first, or --local for this directory's dev server.",
200
+ );
201
+ }
202
+ throw new SignInError(
203
+ `You're signed in to ${origins.length} instances (${origins.join(", ")}). Name one with --url or KESTREL_URL.`,
204
+ );
205
+ }
206
+
207
+ /** The request body and its headers, from --data, --type, and --form. */
208
+ async function requestBody({ data, type, forms }) {
209
+ if (forms.length) {
210
+ const form = new FormData();
211
+ for (const field of forms) {
212
+ const at = field.indexOf("=");
213
+ if (at < 1) {
214
+ throw new SignInError(`--form takes name=value or name=@file, not "${field}".`);
215
+ }
216
+ const name = field.slice(0, at);
217
+ const value = field.slice(at + 1);
218
+ if (value.startsWith("@")) {
219
+ const file = value.slice(1);
220
+ form.append(name, new Blob([readFileSync(file)]), basename(file));
221
+ } else {
222
+ form.append(name, value);
223
+ }
224
+ }
225
+ return { body: form, headers: {} };
226
+ }
227
+ if (data === undefined) {
228
+ return { body: undefined, headers: {} };
229
+ }
230
+ let body;
231
+ if (data === "-") {
232
+ const chunks = [];
233
+ for await (const chunk of process.stdin) {
234
+ chunks.push(chunk);
235
+ }
236
+ body = Buffer.concat(chunks);
237
+ } else if (data.startsWith("@")) {
238
+ body = readFileSync(data.slice(1));
239
+ } else {
240
+ body = data;
241
+ }
242
+ return { body, headers: { "content-type": type ?? "application/json" } };
243
+ }
244
+
245
+ /**
246
+ * A usable access token for `origin`'s stored sign-in, refreshed first when it is about to
247
+ * expire, or when `force` says Access just turned it away. The refresh runs under the
248
+ * credentials lock, and a call that waited there uses the token the other stored, so one
249
+ * refresh token is never spent twice. A grant that has ended leaves the person to sign in
250
+ * again.
251
+ */
252
+ export async function accessToken(origin, { force = false, env = process.env } = {}) {
253
+ const ended = `Your sign-in to ${origin} has ended. Run \`npx kestrel login ${origin}\` again.`;
254
+ const entry = readSignIn(origin, env);
255
+ if (!entry) {
256
+ throw new SignInError(
257
+ `You aren't signed in to ${origin}. Run \`npx kestrel login ${origin}\`.`,
258
+ );
259
+ }
260
+ if (!force && !needsRefresh(entry)) {
261
+ return entry.access_token;
262
+ }
263
+ // The token Access just refused, when it did: a stored token equal to it is no use.
264
+ const refused = force ? entry.access_token : undefined;
265
+ return withCredentialsLock(async () => {
266
+ const now = readSignIn(origin, env);
267
+ if (!now) {
268
+ throw new SignInError(
269
+ `You aren't signed in to ${origin}. Run \`npx kestrel login ${origin}\`.`,
270
+ );
271
+ }
272
+ if (!needsRefresh(now) && now.access_token !== refused) {
273
+ return now.access_token;
274
+ }
275
+ if (!now.refresh_token) {
276
+ throw new SignInError(ended);
277
+ }
278
+ try {
279
+ const fields = tokenFields(await refreshTokens(now), now);
280
+ writeSignInHeld(origin, { ...now, ...fields }, env);
281
+ return fields.access_token;
282
+ } catch (err) {
283
+ if (err instanceof SignInError && err.oauthError === "invalid_grant") {
284
+ throw new SignInError(ended);
285
+ }
286
+ throw err;
287
+ }
288
+ }, env);
289
+ }
290
+
291
+ /**
292
+ * Call `origin`'s API as the signed-in person: one retry after a refresh when Access answers
293
+ * 401, since a token can be revoked or shortened before its stated expiry. Answers the
294
+ * Response.
295
+ */
296
+ export async function signedInFetch(origin, path, { method = "GET", body, headers = {}, client }) {
297
+ const send = async (token) =>
298
+ fetch(`${origin}${path}`, {
299
+ method,
300
+ body,
301
+ redirect: "manual",
302
+ headers: {
303
+ accept: "application/json",
304
+ ...headers,
305
+ "kestrel-client": client,
306
+ authorization: `Bearer ${token}`,
307
+ },
308
+ });
309
+ let res = await send(await accessToken(origin));
310
+ // Access's own refusal names its OAuth metadata; the app's 401 doesn't, and a fresh token
311
+ // wouldn't change it. A streamed body (stdin) can't be sent twice, but it is read whole.
312
+ if (res.status === 401 && /resource_metadata=/.test(res.headers.get("www-authenticate") ?? "")) {
313
+ res = await send(await accessToken(origin, { force: true }));
314
+ }
315
+ return res;
316
+ }
317
+
318
+ /**
319
+ * How a run reaches its instance: a function that sends one request there and answers the
320
+ * Response. A loopback address, given or in KESTREL_URL, is a dev server, which has no sign-in:
321
+ * its own human token is minted as the editor's is (SPEC §10). Anything else is the stored
322
+ * sign-in, refreshed as it needs.
323
+ */
324
+ async function openTarget(args, client) {
325
+ const named = args.url || process.env.KESTREL_URL || undefined;
326
+ if (args.local || (named && isLoopback(instanceOrigin(named)))) {
327
+ const { base, token } = await devTarget(
328
+ args.local ? undefined : instanceOrigin(named),
329
+ "api",
330
+ "human",
331
+ );
332
+ return (path, { method = "GET", body, headers = {} } = {}) =>
333
+ fetch(`${base}${path}`, {
334
+ method,
335
+ body,
336
+ headers: {
337
+ accept: "application/json",
338
+ ...headers,
339
+ "kestrel-client": client,
340
+ authorization: `Bearer ${token}`,
341
+ },
342
+ });
343
+ }
344
+ const origin = chooseInstance({ url: args.url, signIns: readSignIns() });
345
+ return (path, { method = "GET", body, headers = {} } = {}) =>
346
+ signedInFetch(origin, path, { method, body, headers, client });
347
+ }
348
+
349
+ /** The instance's OpenAPI document, read through `call`, or a refusal saying why there is none. */
350
+ export async function readOpenApi(call) {
351
+ const res = await call("/api/openapi.json");
352
+ const text = await res.text();
353
+ if (res.status === 404) {
354
+ throw new SignInError(
355
+ "This instance serves no /api/openapi.json, so its routes can't be listed or checked: it runs a release older than this CLI's. Send a body with --data instead.",
356
+ );
357
+ }
358
+ let doc;
359
+ try {
360
+ doc = JSON.parse(text);
361
+ } catch {
362
+ doc = undefined;
363
+ }
364
+ if (!res.ok || !doc?.paths) {
365
+ const said = typeof doc?.message === "string" ? `: ${doc.message}` : "";
366
+ throw new SignInError(
367
+ `Couldn't read the instance's /api/openapi.json (HTTP ${res.status})${said}`,
368
+ );
369
+ }
370
+ return doc;
371
+ }
372
+
373
+ /** A call's path with `query`'s pairs appended to whatever query string it already has. */
374
+ function withQuery(path, query) {
375
+ if (!query.length) {
376
+ return path;
377
+ }
378
+ const params = new URLSearchParams(query);
379
+ return `${path}${path.includes("?") ? "&" : "?"}${params}`;
380
+ }
381
+
382
+ /**
383
+ * `kestrel api`: list the routes, describe one, or call one. A call prints the answer's body
384
+ * on stdout as it came; on a non-2xx answer it also prints the status and, for a refusal, its
385
+ * code, message, field, and next move on stderr, and exits 1.
386
+ */
387
+ export async function apiCommand(argv) {
388
+ let res;
389
+ let doc;
390
+ let found;
391
+ let call;
392
+ try {
393
+ const args = parseApiArgs(argv);
394
+ const client = args.client ?? DEFAULT_CLIENT;
395
+ call = await openTarget(args, client);
396
+ if (args.mode === "list") {
397
+ console.log(listRoutes(await readOpenApi(call), args.resource));
398
+ return;
399
+ }
400
+ if (args.mode === "help") {
401
+ console.log(describeRoute(await readOpenApi(call), args.method, args.path));
402
+ return;
403
+ }
404
+ let path = args.path;
405
+ let { body, headers } = await requestBody(args);
406
+ if (args.fields.length) {
407
+ doc = await readOpenApi(call);
408
+ found = findOperation(doc, args.method, path);
409
+ if (!found) {
410
+ throw new SignInError(
411
+ `This instance has no route ${args.method} ${path.split("?")[0]}, so its fields can't be checked. \`kestrel api --list\` lists them.`,
412
+ );
413
+ }
414
+ const built = buildFields(doc, found, args.fields);
415
+ path = withQuery(path, built.query);
416
+ if (built.body && body !== undefined) {
417
+ throw new SignInError(ONE_WAY);
418
+ }
419
+ if (built.body) {
420
+ body = JSON.stringify(built.body);
421
+ headers = { "content-type": "application/json" };
422
+ }
423
+ }
424
+ res = await call(path, { method: args.method, body, headers });
425
+ } catch (err) {
426
+ console.error(`kestrel api: ${err instanceof Error ? err.message : String(err)}`);
427
+ process.exit(1);
428
+ }
429
+ const text = await res.text();
430
+ if (text) {
431
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
432
+ }
433
+ if (!res.ok) {
434
+ console.error(`kestrel api: HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`);
435
+ for (const line of (await refusalLines(text, { doc, found, call })) ?? []) {
436
+ console.error(` ${line}`);
437
+ }
438
+ process.exit(1);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * A refusal's readable lines, its next move from the instance's document (read now if the
444
+ * call didn't already), or undefined when the body isn't a refusal. A document that can't be
445
+ * read only leaves the move out: the refusal itself is already printed.
446
+ */
447
+ async function refusalLines(text, { doc, found, call }) {
448
+ let body;
449
+ try {
450
+ body = JSON.parse(text);
451
+ } catch {
452
+ return undefined;
453
+ }
454
+ if (typeof body?.error !== "string") {
455
+ return undefined;
456
+ }
457
+ let known = doc;
458
+ if (!known) {
459
+ known = await readOpenApi(call).catch(() => undefined);
460
+ }
461
+ return describeRefusal(body, { doc: known, op: found?.op });
462
+ }
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Where `kestrel login` keeps a person's sign-ins, and how `kestrel api` reads them back.
3
+ *
4
+ * A sign-in is the person's, not an instance repository's: it is made in their browser,
5
+ * through Cloudflare Access, as them, and it reaches every instance folder or none. So it
6
+ * lives in their user config directory, never in the folder a command runs in, where a
7
+ * commit or a shared worktree could carry it off: `$XDG_CONFIG_HOME/kestrel/credentials.json`,
8
+ * `~/.config/kestrel/credentials.json` when that is unset, or `%APPDATA%\kestrel\` on
9
+ * Windows. `KESTREL_CONFIG_DIR` names another directory, which the suite uses.
10
+ *
11
+ * One JSON file, keyed by the instance's origin, readable by its owner alone (0600, in a
12
+ * 0700 directory), written whole through a temporary file and a rename, so a reader never
13
+ * sees half of it. Each entry holds what the OAuth client needs to go on without the
14
+ * browser: the authorization server's endpoints, the client registered for this computer,
15
+ * the access token with its expiry, and the refresh token, which lasts as long as the
16
+ * Access application's grant session. A file that exists but isn't one this release can
17
+ * read is refused, never rewritten, since rewriting it would drop every sign-in it holds.
18
+ *
19
+ * A refresh token may be single-use, and Claude Code runs commands side by side, so a refresh
20
+ * happens under a lock file beside the credentials: whoever waited reads the token the other
21
+ * stored rather than spending the same refresh token twice. Every write to the file happens
22
+ * under that lock too, since a write reads the whole file and puts it back, and two at once
23
+ * would lose one entry's change.
24
+ */
25
+ import { randomBytes } from "node:crypto";
26
+ import {
27
+ chmodSync,
28
+ closeSync,
29
+ mkdirSync,
30
+ openSync,
31
+ readFileSync,
32
+ renameSync,
33
+ rmSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { join } from "node:path";
39
+
40
+ const VERSION = 1;
41
+
42
+ /** The directory the sign-ins live in, from the environment as described above. */
43
+ export function configDir(env = process.env, platform = process.platform) {
44
+ if (env.KESTREL_CONFIG_DIR) {
45
+ return env.KESTREL_CONFIG_DIR;
46
+ }
47
+ if (platform === "win32" && env.APPDATA) {
48
+ return join(env.APPDATA, "kestrel");
49
+ }
50
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "kestrel");
51
+ }
52
+
53
+ /** The credentials file's path. */
54
+ export function credentialsFile(env = process.env) {
55
+ return join(configDir(env), "credentials.json");
56
+ }
57
+
58
+ /** Every stored sign-in, keyed by origin; empty when there is no file yet. Throws, naming
59
+ * the file, when it exists and can't be read as this release's. */
60
+ export function readSignIns(env = process.env) {
61
+ const file = credentialsFile(env);
62
+ let text;
63
+ try {
64
+ text = readFileSync(file, "utf8");
65
+ } catch (err) {
66
+ if (err?.code === "ENOENT") {
67
+ return {};
68
+ }
69
+ throw new Error(`Couldn't read the sign-ins in ${file}: ${err?.message ?? err}`);
70
+ }
71
+ let data;
72
+ try {
73
+ data = JSON.parse(text);
74
+ } catch {
75
+ data = undefined;
76
+ }
77
+ if (data?.version === VERSION && data.instances && typeof data.instances === "object") {
78
+ return data.instances;
79
+ }
80
+ throw new Error(
81
+ `${file} isn't a sign-ins file this release of Kestrel can read, so it was left as it is. Move it aside, then run \`npx kestrel login <url>\` again.`,
82
+ );
83
+ }
84
+
85
+ /** The stored sign-in for one origin, or undefined. */
86
+ export function readSignIn(origin, env = process.env) {
87
+ return readSignIns(env)[origin];
88
+ }
89
+
90
+ /**
91
+ * Store (or, with undefined, forget) the sign-in for one origin, leaving the others. Takes
92
+ * the credentials lock, so call it outside `withCredentialsLock`; inside one, use
93
+ * `writeSignInHeld`.
94
+ */
95
+ export function writeSignIn(origin, entry, env = process.env) {
96
+ return withCredentialsLock(() => writeSignInHeld(origin, entry, env), env);
97
+ }
98
+
99
+ /** `writeSignIn` for a caller already inside `withCredentialsLock`, which taking it again would deadlock. */
100
+ export function writeSignInHeld(origin, entry, env = process.env) {
101
+ const instances = { ...readSignIns(env) };
102
+ if (entry === undefined) {
103
+ delete instances[origin];
104
+ } else {
105
+ instances[origin] = entry;
106
+ }
107
+ const dir = configDir(env);
108
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
109
+ const file = credentialsFile(env);
110
+ const tmp = `${file}.${process.pid}.tmp`;
111
+ writeFileSync(tmp, `${JSON.stringify({ version: VERSION, instances }, null, 2)}\n`, {
112
+ mode: 0o600,
113
+ });
114
+ // The mode on create is masked by umask, so set it outright before the file takes its name.
115
+ chmodSync(tmp, 0o600);
116
+ renameSync(tmp, file);
117
+ }
118
+
119
+ // How long a lock is waited for, and when one left behind by a crashed process is taken over.
120
+ /** How long a waiter waits for the lock: past the longest a live holder takes (the token request's timeout), under the staleness rule. */
121
+ export const LOCK_WAIT_MS = 25_000;
122
+ /** A lock older than this is taken over; the token request's timeout stays under it, so a live holder never outlasts it. */
123
+ export const LOCK_STALE_MS = 30_000;
124
+
125
+ /**
126
+ * Run `fn` holding the credentials lock, so only one process refreshes at a time. A lock
127
+ * older than any refresh takes is a crashed process's, and is taken over. The lock file holds
128
+ * its owner's token, and the release removes the file only while it still holds that token,
129
+ * so a holder that outlived the staleness rule never deletes the lock of whoever took over.
130
+ */
131
+ export async function withCredentialsLock(fn, env = process.env) {
132
+ mkdirSync(configDir(env), { recursive: true, mode: 0o700 });
133
+ const lock = `${credentialsFile(env)}.lock`;
134
+ const deadline = Date.now() + LOCK_WAIT_MS;
135
+ const token = `${process.pid}:${randomBytes(8).toString("hex")}`;
136
+ let held = false;
137
+ while (!held) {
138
+ try {
139
+ const fd = openSync(lock, "wx", 0o600);
140
+ try {
141
+ writeFileSync(fd, token);
142
+ } finally {
143
+ closeSync(fd);
144
+ }
145
+ held = true;
146
+ } catch (err) {
147
+ if (err?.code !== "EEXIST") {
148
+ throw err;
149
+ }
150
+ let age = 0;
151
+ let seen;
152
+ try {
153
+ seen = readFileSync(lock, "utf8");
154
+ age = Date.now() - statSync(lock).mtimeMs;
155
+ } catch {
156
+ continue; // released between the open and the read: try again
157
+ }
158
+ if (age > LOCK_STALE_MS) {
159
+ // Remove it only if it is still the lock we judged stale: another waiter may have
160
+ // taken it over since, and its fresh lock is not ours to delete.
161
+ let now;
162
+ try {
163
+ now = readFileSync(lock, "utf8");
164
+ } catch {
165
+ continue;
166
+ }
167
+ if (now === seen) {
168
+ rmSync(lock, { force: true });
169
+ }
170
+ } else if (Date.now() > deadline) {
171
+ throw new Error(
172
+ `Another kestrel command has held ${lock} for ${Math.round(LOCK_WAIT_MS / 1000)} seconds. It may still be running; try again in a minute. A lock left by a crashed command clears itself after ${Math.round(LOCK_STALE_MS / 1000)} seconds.`,
173
+ );
174
+ } else {
175
+ await new Promise((resolve) => setTimeout(resolve, 50));
176
+ }
177
+ }
178
+ }
179
+ try {
180
+ return await fn();
181
+ } finally {
182
+ let owner;
183
+ try {
184
+ owner = readFileSync(lock, "utf8");
185
+ } catch {
186
+ // already gone: nothing of ours to remove
187
+ }
188
+ if (owner === token) {
189
+ rmSync(lock, { force: true });
190
+ }
191
+ }
192
+ }