@catalyst-cloud/cli 0.14.5 → 0.14.7

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 (63) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -3
  3. package/dist/args.js +51 -13
  4. package/dist/cli.js +58 -10
  5. package/dist/config.js +16 -3
  6. package/dist/daemon-authority-lifetime.js +171 -0
  7. package/dist/daemon-authority.js +216 -0
  8. package/dist/daemon-cache.js +348 -0
  9. package/dist/daemon-store.js +370 -0
  10. package/dist/daemon-supervisor.js +460 -0
  11. package/dist/env/onboard-draft-store.js +243 -0
  12. package/dist/env/onboard-draft.js +33 -0
  13. package/dist/env/selected-import-writer.js +163 -0
  14. package/dist/env/settings-authority.js +203 -0
  15. package/dist/events-cloud-tail.js +134 -0
  16. package/dist/events-cloud.js +500 -0
  17. package/dist/events.js +13 -4
  18. package/dist/legacy.js +145 -32
  19. package/dist/oauth.js +125 -33
  20. package/dist/onboard-accounts.js +259 -0
  21. package/dist/onboard-automation-management.js +15 -0
  22. package/dist/onboard-bootstrap.js +162 -0
  23. package/dist/onboard-capabilities.js +15 -7
  24. package/dist/onboard-capacity.js +226 -0
  25. package/dist/onboard-existing.js +106 -32
  26. package/dist/onboard-file-snapshot.js +45 -0
  27. package/dist/onboard-login-candidate.js +261 -0
  28. package/dist/onboard-projects.js +350 -0
  29. package/dist/onboard-repositories.js +147 -37
  30. package/dist/onboard-runtime.js +104 -9
  31. package/dist/onboard-settings-context.js +141 -0
  32. package/dist/onboard-settings-import.js +127 -0
  33. package/dist/onboard-settings.js +234 -0
  34. package/dist/onboard-ui.js +99 -11
  35. package/dist/onboard-workflow.js +316 -0
  36. package/dist/onboard.js +359 -27
  37. package/dist/owned-fetch.js +182 -0
  38. package/dist/query.js +77 -27
  39. package/dist/ready.js +143 -35
  40. package/dist/replica.js +11 -4
  41. package/dist/sdk.js +33 -0
  42. package/dist/watch.js +2 -2
  43. package/package.json +3 -2
  44. package/skills/catalyst-github/SKILL.md +2 -2
  45. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  46. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  47. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  48. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  49. package/skills/catalyst-linear/SKILL.md +3 -3
  50. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  51. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  52. package/skills/catalyst-onboard/SKILL.md +5 -4
  53. package/skills/catalyst-onboard/references/local-sync.md +3 -1
  54. package/skills/run-this-project/SKILL.md +1 -1
  55. package/skills/unstick/SKILL.md +1 -1
  56. package/skills/what-needs-me/SKILL.md +1 -1
  57. package/skills/what-this-repo-needs/SKILL.md +1 -1
  58. package/skills/whats-happening/SKILL.md +2 -2
  59. package/skills/whats-happening/references/status-reply.md +1 -1
  60. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  61. package/vendor/settings/index.d.ts +4 -0
  62. package/vendor/settings/index.js +2937 -0
  63. package/vendor/settings/provenance.json +148 -0
package/dist/legacy.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // The list is fixed on purpose. A prefix match would one day catch a current job (the housekeeping
8
8
  // job, the sandbox credit guard, jobs a person made); this matches names the old runtime installed,
9
9
  // read at the source commit below, and nothing else. Shared data folders are always kept. The historical --data flag cannot remove them.
10
- import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
10
+ import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync, } from "node:fs";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { join } from "node:path";
13
13
  import { flagBool, positionals } from "./args.js";
@@ -44,17 +44,34 @@ export const OLD_LAUNCHD_LABELS = [
44
44
  const OLD_ROLE_PREFIX = "com.catalyst.role.";
45
45
  /** systemd user units (~/.config/systemd/user) the old runtime's workstation pieces used on Linux. System
46
46
  * units under /etc/systemd/system are a host's, current, and never looked at. */
47
- export const OLD_USER_UNITS = ["catalyst.service", "catalyst-monitor.service"];
47
+ export const OLD_USER_UNITS = [
48
+ "catalyst.service",
49
+ "catalyst-monitor.service",
50
+ ];
48
51
  /** Command files the old runtime put on the person's PATH. `~/.local/bin/catalyst` is NOT one: that
49
52
  * name belongs to the current CLI wherever npm put it. */
50
- export const OLD_BINS = [".local/bin/catalyst-events", ".local/bin/catalyst-filter", ".local/bin/catalyst-hud", ".local/bin/catalyst-monitor", ".local/bin/catalyst-myown"];
53
+ export const OLD_BINS = [
54
+ ".local/bin/catalyst-events",
55
+ ".local/bin/catalyst-filter",
56
+ ".local/bin/catalyst-hud",
57
+ ".local/bin/catalyst-monitor",
58
+ ".local/bin/catalyst-myown",
59
+ ];
51
60
  /** Shared roots also hold current skills, logs, hosts and seats. Never remove these directories. */
52
- export const OLD_DATA_DIRS = [".catalyst", ".config/catalyst", ".local/state/catalyst", ".local/state/catalyst-fleet-runner", ".local/state/catalyst-ledger"];
61
+ export const OLD_DATA_DIRS = [
62
+ ".catalyst",
63
+ ".config/catalyst",
64
+ ".local/state/catalyst",
65
+ ".local/state/catalyst-fleet-runner",
66
+ ".local/state/catalyst-ledger",
67
+ ];
53
68
  const RECOMMEND = "Remove them: the cloud runtime replaced them, and left in place they can start old jobs or shadow current commands.";
54
69
  function readJson(path) {
55
70
  try {
56
71
  const v = JSON.parse(readFileSync(path, "utf8"));
57
- return v !== null && typeof v === "object" ? v : null;
72
+ return v !== null && typeof v === "object"
73
+ ? v
74
+ : null;
58
75
  }
59
76
  catch {
60
77
  return null;
@@ -67,7 +84,8 @@ function findPlugins(home) {
67
84
  const old = Object.entries(marketplaces)
68
85
  .filter(([, v]) => {
69
86
  const src = v?.source;
70
- return typeof src?.repo === "string" && src.repo.toLowerCase() === OLD_MARKETPLACE_REPO;
87
+ return (typeof src?.repo === "string" &&
88
+ src.repo.toLowerCase() === OLD_MARKETPLACE_REPO);
71
89
  })
72
90
  .map(([name]) => name);
73
91
  if (old.length === 0)
@@ -77,10 +95,18 @@ function findPlugins(home) {
77
95
  for (const key of Object.keys(installed)) {
78
96
  const at = key.lastIndexOf("@");
79
97
  if (at > 0 && old.includes(key.slice(at + 1)))
80
- items.push({ kind: "plugin", name: key, path: join(dir, "installed_plugins.json") });
98
+ items.push({
99
+ kind: "plugin",
100
+ name: key,
101
+ path: join(dir, "installed_plugins.json"),
102
+ });
81
103
  }
82
104
  for (const name of old)
83
- items.push({ kind: "marketplace", name, path: join(dir, "known_marketplaces.json") });
105
+ items.push({
106
+ kind: "marketplace",
107
+ name,
108
+ path: join(dir, "known_marketplaces.json"),
109
+ });
84
110
  return items;
85
111
  }
86
112
  function findJobs(home, platform) {
@@ -96,35 +122,63 @@ function findJobs(home, platform) {
96
122
  return files
97
123
  .filter((f) => f.endsWith(".plist"))
98
124
  .map((f) => f.slice(0, -".plist".length))
99
- .filter((label) => OLD_LAUNCHD_LABELS.includes(label) || label.startsWith(OLD_ROLE_PREFIX))
125
+ .filter((label) => OLD_LAUNCHD_LABELS.includes(label) ||
126
+ label.startsWith(OLD_ROLE_PREFIX))
100
127
  .sort()
101
- .map((label) => ({ kind: "job", name: label, path: join(dir, `${label}.plist`) }));
128
+ .map((label) => ({
129
+ kind: "job",
130
+ name: label,
131
+ path: join(dir, `${label}.plist`),
132
+ }));
102
133
  }
103
134
  if (platform === "linux") {
104
135
  const dir = join(home, ".config", "systemd", "user");
105
- return OLD_USER_UNITS.filter((u) => existsSync(join(dir, u))).map((u) => ({ kind: "job", name: u, path: join(dir, u) }));
136
+ return OLD_USER_UNITS.filter((u) => existsSync(join(dir, u))).map((u) => ({
137
+ kind: "job",
138
+ name: u,
139
+ path: join(dir, u),
140
+ }));
106
141
  }
107
142
  return [];
108
143
  }
109
144
  const tilde = (rel) => `~/${rel}`;
110
145
  function findFiles(home) {
111
- const bins = OLD_BINS.filter((rel) => existsSync(join(home, rel))).map((rel) => ({ kind: "bin", name: tilde(rel), path: join(home, rel) }));
112
- const data = OLD_DATA_DIRS.filter((rel) => existsSync(join(home, rel))).map((rel) => ({ kind: "data", name: tilde(rel), path: join(home, rel), data: true }));
146
+ const bins = OLD_BINS.filter((rel) => existsSync(join(home, rel))).map((rel) => ({
147
+ kind: "bin",
148
+ name: tilde(rel),
149
+ path: join(home, rel),
150
+ }));
151
+ const data = OLD_DATA_DIRS.filter((rel) => existsSync(join(home, rel))).map((rel) => ({
152
+ kind: "data",
153
+ name: tilde(rel),
154
+ path: join(home, rel),
155
+ data: true,
156
+ }));
113
157
  return [...bins, ...data];
114
158
  }
115
159
  /** Every old piece on this machine, in removal order: plugins, their marketplace, jobs, bins, data. Never writes. */
116
160
  export function findLegacy(home, platform) {
117
- return [...findPlugins(home), ...findJobs(home, platform), ...findFiles(home)];
161
+ return [
162
+ ...findPlugins(home),
163
+ ...findJobs(home, platform),
164
+ ...findFiles(home),
165
+ ];
118
166
  }
119
167
  const defaultRun = (cmd, args) => {
120
168
  const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 60_000 });
121
- return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
169
+ return {
170
+ status: r.status ?? 1,
171
+ stdout: r.stdout ?? "",
172
+ stderr: r.error ? r.error.message : (r.stderr ?? ""),
173
+ };
122
174
  };
123
175
  /** Remove one item through the tool that owns it. Returns null on success, else the reason it is still there. */
124
176
  function remove(item, run, platform, uid) {
125
177
  const tool = (cmd, args, what) => {
126
178
  const r = run(cmd, args);
127
- return r.status === 0 ? null : `${what} failed: ${(r.stderr || r.stdout).trim().split("\n")[0] || `exit ${r.status}`}`;
179
+ return r.status === 0
180
+ ? null
181
+ : `${what} failed: ${(r.stderr || r.stdout).trim().split("\n")[0] || `exit ${r.status}`}`;
128
182
  };
129
183
  try {
130
184
  if (item.kind === "plugin")
@@ -132,7 +186,9 @@ function remove(item, run, platform, uid) {
132
186
  if (item.kind === "marketplace")
133
187
  return tool("claude", ["plugin", "marketplace", "remove", item.name], "claude plugin marketplace remove");
134
188
  if (item.kind === "job") {
135
- const why = platform === "darwin" ? tool("launchctl", ["bootout", `gui/${uid}/${item.name}`], "launchctl bootout") : tool("systemctl", ["--user", "disable", "--now", item.name], "systemctl --user disable --now");
189
+ const why = platform === "darwin"
190
+ ? tool("launchctl", ["bootout", `gui/${uid}/${item.name}`], "launchctl bootout")
191
+ : tool("systemctl", ["--user", "disable", "--now", item.name], "systemctl --user disable --now");
136
192
  if (why !== null)
137
193
  return why;
138
194
  unlinkSync(item.path);
@@ -159,21 +215,51 @@ export async function cmdLegacy(args, ctx, deps = {}) {
159
215
  const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
160
216
  const run = deps.run ?? defaultRun;
161
217
  const found = findLegacy(ctx.home, platform);
162
- const emit = (body, lines) => { if (args.json)
163
- ctx.stdout(JSON.stringify(body));
164
- else
165
- for (const l of lines)
166
- ctx.stdout(l); };
167
- if (found.length === 0) {
168
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found: [], removed: [], remaining: [] }, ["no leftovers of the old local Catalyst runtime on this machine"]);
218
+ const legacy = found.filter((item) => !item.data);
219
+ const shared = found.filter((item) => item.data);
220
+ const emit = (body, lines) => {
221
+ if (args.json)
222
+ ctx.stdout(JSON.stringify(body));
223
+ else
224
+ for (const l of lines)
225
+ ctx.stdout(l);
226
+ };
227
+ if (legacy.length === 0) {
228
+ emit({
229
+ sourceCommit: LEGACY_SOURCE_COMMIT,
230
+ found: [],
231
+ removed: [],
232
+ remaining: [],
233
+ kept: shared,
234
+ }, [
235
+ "no leftovers of the old local Catalyst runtime on this machine",
236
+ ...(shared.length
237
+ ? ["shared current Catalyst data folders are present and kept"]
238
+ : []),
239
+ ]);
169
240
  return 0;
170
241
  }
171
242
  const listLines = [
172
- `Leftovers of the old local Catalyst runtime on this machine (${found.length}):`,
173
- ...found.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path}` : f.data ? " (kept: shared current state)" : ""}`),
243
+ `Leftovers of the old local Catalyst runtime on this machine (${legacy.length}):`,
244
+ ...legacy.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path}` : ""}`),
245
+ ...(shared.length
246
+ ? [
247
+ "Shared current data folders (kept):",
248
+ ...shared.map((f) => ` ${f.kind}: ${f.name} (kept: shared current state)`),
249
+ ]
250
+ : []),
174
251
  ];
175
252
  if (!wantRemove) {
176
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found }, [...listLines, RECOMMEND, "Run: catalyst legacy --remove (all data folders are kept)"]);
253
+ emit({
254
+ sourceCommit: LEGACY_SOURCE_COMMIT,
255
+ found,
256
+ removed: [],
257
+ remaining: found,
258
+ }, [
259
+ ...listLines,
260
+ RECOMMEND,
261
+ "Run: catalyst legacy --remove (all data folders are kept)",
262
+ ]);
177
263
  return 1;
178
264
  }
179
265
  // the one question, only on a terminal; --yes answers it without asking
@@ -181,16 +267,34 @@ export async function cmdLegacy(args, ctx, deps = {}) {
181
267
  if (!go) {
182
268
  const tty = (deps.isTty ?? stdinIsTty)();
183
269
  if (!tty) {
184
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: false }, [...listLines, RECOMMEND, "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (all data folders are kept)"]);
270
+ emit({
271
+ sourceCommit: LEGACY_SOURCE_COMMIT,
272
+ found,
273
+ removed: [],
274
+ remaining: found,
275
+ asked: false,
276
+ }, [
277
+ ...listLines,
278
+ RECOMMEND,
279
+ "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (all data folders are kept)",
280
+ ]);
185
281
  return 1;
186
282
  }
187
283
  for (const l of listLines)
188
284
  ctx.stdout(l);
189
285
  ctx.stdout(RECOMMEND);
190
- const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${found.length} item${found.length === 1 ? "" : "s"}, keeping all data folders? [y/N] `)).trim().toLowerCase();
286
+ const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${legacy.length} item${legacy.length === 1 ? "" : "s"}, keeping all data folders? [y/N] `))
287
+ .trim()
288
+ .toLowerCase();
191
289
  go = answer === "y" || answer === "yes";
192
290
  if (!go) {
193
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: true }, ["nothing removed; run catalyst legacy --remove again when ready"]);
291
+ emit({
292
+ sourceCommit: LEGACY_SOURCE_COMMIT,
293
+ found,
294
+ removed: [],
295
+ remaining: found,
296
+ asked: true,
297
+ }, ["nothing removed; run catalyst legacy --remove again when ready"]);
194
298
  return 1;
195
299
  }
196
300
  }
@@ -217,7 +321,16 @@ export async function cmdLegacy(args, ctx, deps = {}) {
217
321
  // re-check from the same list, so the report says what the machine holds now, not what was attempted
218
322
  const remaining = findLegacy(ctx.home, platform).filter((f) => !f.data);
219
323
  const keptNote = kept.length > 0 ? "; all shared data folders were kept" : "";
220
- lines.push(remaining.length === 0 ? `re-checked: nothing of the old runtime remains${keptNote}` : `re-checked: ${remaining.length} item${remaining.length === 1 ? "" : "s"} of the old runtime remain${remaining.length === 1 ? "s" : ""}${keptNote}: ${remaining.map((r) => `${r.kind} ${r.name}`).join(", ")}`);
221
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed, remaining: [...remaining, ...kept], kept, failed }, lines);
324
+ lines.push(remaining.length === 0
325
+ ? `re-checked: nothing of the old runtime remains${keptNote}`
326
+ : `re-checked: ${remaining.length} item${remaining.length === 1 ? "" : "s"} of the old runtime remain${remaining.length === 1 ? "s" : ""}${keptNote}: ${remaining.map((r) => `${r.kind} ${r.name}`).join(", ")}`);
327
+ emit({
328
+ sourceCommit: LEGACY_SOURCE_COMMIT,
329
+ found,
330
+ removed,
331
+ remaining: [...remaining, ...kept],
332
+ kept,
333
+ failed,
334
+ }, lines);
222
335
  return remaining.length === 0 ? 0 : 1;
223
336
  }
package/dist/oauth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { discoveryCachePathFor, loadConfig, normalizeBaseUrl, writeConfig } from "./config.js";
1
+ import { discoveryCachePathFor, loadConfig, normalizeBaseUrl, writeConfig, } from "./config.js";
2
2
  import { CliError } from "./errors.js";
3
3
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  const DISCOVERY_TTL_MS = 60 * 60_000;
@@ -20,7 +20,9 @@ function readDiscoveryDisk(home) {
20
20
  return null;
21
21
  try {
22
22
  const parsed = JSON.parse(readFileSync(path, "utf8"));
23
- if (isDiscovery(parsed.doc) && typeof parsed.fetchedAt === "number" && typeof parsed.baseUrl === "string")
23
+ if (isDiscovery(parsed.doc) &&
24
+ typeof parsed.fetchedAt === "number" &&
25
+ typeof parsed.baseUrl === "string")
24
26
  return parsed;
25
27
  }
26
28
  catch {
@@ -32,16 +34,24 @@ function isDiscovery(v) {
32
34
  if (typeof v !== "object" || v === null)
33
35
  return false;
34
36
  const d = v;
35
- return ["clientId", "issuer", "deviceAuthorizationUrl", "tokenUrl", "jwksUrl"].every((k) => typeof d[k] === "string");
37
+ return [
38
+ "clientId",
39
+ "issuer",
40
+ "deviceAuthorizationUrl",
41
+ "tokenUrl",
42
+ "jwksUrl",
43
+ ].every((k) => typeof d[k] === "string");
36
44
  }
37
45
  export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
38
- const cancelled = () => { if (opts.signal?.aborted)
39
- throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
46
+ const cancelled = () => {
47
+ if (opts.signal?.aborted)
48
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
49
+ };
40
50
  cancelled();
41
51
  const base = normalizeBaseUrl(baseUrl);
42
52
  const nowMs = ctx.now().getTime();
43
53
  const fresh = (e) => e !== null && e.baseUrl === base && nowMs - e.fetchedAt < DISCOVERY_TTL_MS;
44
- if (!opts.refresh) {
54
+ if (!opts.refresh && opts.cache !== false) {
45
55
  if (fresh(memo))
46
56
  return memo.doc;
47
57
  const disk = readDiscoveryDisk(ctx.home);
@@ -51,7 +61,9 @@ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
51
61
  }
52
62
  }
53
63
  const url = `${base}/api/v1/auth/cli`;
54
- const res = await safeFetch(ctx, url, { headers: { accept: "application/json" } });
64
+ const res = await safeFetch(ctx, url, {
65
+ headers: { accept: "application/json" },
66
+ });
55
67
  if (!res.ok) {
56
68
  throw new CliError(`could not read the login discovery document (${res.status}) at ${url} — is this a Catalyst Cloud origin?`, "discovery-failed");
57
69
  }
@@ -60,6 +72,8 @@ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
60
72
  if (!isDiscovery(doc))
61
73
  throw new CliError(`the login discovery document at ${url} is not the expected shape`, "discovery-shape");
62
74
  const entry = { baseUrl: base, fetchedAt: nowMs, doc };
75
+ if (opts.cache === false)
76
+ return doc;
63
77
  memo = entry;
64
78
  try {
65
79
  writeFileSync(discoveryCachePathFor(ctx.home), JSON.stringify(entry, null, 2) + "\n");
@@ -83,17 +97,28 @@ export const MAX_DEVICE_CODES = 3;
83
97
  * expires, a fresh one is minted and printed, up to MAX_DEVICE_CODES in all.
84
98
  */
85
99
  export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
86
- const cancelled = () => { if (deps.signal?.aborted)
87
- throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
100
+ const cancelled = () => {
101
+ if (deps.signal?.aborted)
102
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
103
+ };
88
104
  cancelled();
89
105
  if (deps.signal) {
90
106
  const fetchImpl = ctx.fetch;
91
107
  const signal = deps.signal;
92
- ctx = { ...ctx, fetch: ((input, init) => fetchImpl(input, {
93
- ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
94
- })) };
108
+ ctx = {
109
+ ...ctx,
110
+ fetch: ((input, init) => fetchImpl(input, {
111
+ ...init,
112
+ signal: init?.signal
113
+ ? AbortSignal.any([signal, init.signal])
114
+ : signal,
115
+ })),
116
+ };
95
117
  }
96
- const discovery = await fetchDiscovery(ctx, baseUrl, { signal: deps.signal });
118
+ const discovery = await fetchDiscovery(ctx, baseUrl, {
119
+ signal: deps.signal,
120
+ cache: deps.discoveryCache,
121
+ });
97
122
  cancelled();
98
123
  // The browser rule is decided on the first code; a later code re-opens it only when the first did.
99
124
  let browserOpened = false;
@@ -120,7 +145,9 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
120
145
  }
121
146
  ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
122
147
  const poll = () => pollDeviceCode(ctx, discovery, auth, deps.sleep, deps.signal);
123
- const tokens = deps.waitForApproval ? await deps.waitForApproval(poll) : await poll();
148
+ const tokens = deps.waitForApproval
149
+ ? await deps.waitForApproval(poll)
150
+ : await poll();
124
151
  cancelled();
125
152
  if (tokens !== "expired")
126
153
  return tokensToAuth(ctx, tokens.access_token, tokens.refresh_token);
@@ -137,8 +164,10 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
137
164
  async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
138
165
  const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
139
166
  const signal = external ? AbortSignal.any([external, deadline]) : deadline;
140
- const cancelled = () => { if (external?.aborted)
141
- throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
167
+ const cancelled = () => {
168
+ if (external?.aborted)
169
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
170
+ };
142
171
  let intervalMs = Math.max(1, auth.interval) * 1_000;
143
172
  for (;;) {
144
173
  cancelled();
@@ -158,7 +187,15 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
158
187
  return "expired";
159
188
  let res;
160
189
  try {
161
- res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, signal);
190
+ res = await safeFetch(ctx, discovery.tokenUrl, {
191
+ method: "POST",
192
+ headers: formHeaders(),
193
+ body: form({
194
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
195
+ device_code: auth.device_code,
196
+ client_id: discovery.clientId,
197
+ }),
198
+ }, signal);
162
199
  cancelled();
163
200
  }
164
201
  catch (err) {
@@ -172,10 +209,14 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
172
209
  }
173
210
  if (res.ok) {
174
211
  const pair = (await parseJson(res, discovery.tokenUrl));
175
- if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
212
+ if (typeof pair.access_token !== "string" ||
213
+ typeof pair.refresh_token !== "string") {
176
214
  throw new CliError("the login token response was missing a token", "login-shape");
177
215
  }
178
- return { access_token: pair.access_token, refresh_token: pair.refresh_token };
216
+ return {
217
+ access_token: pair.access_token,
218
+ refresh_token: pair.refresh_token,
219
+ };
179
220
  }
180
221
  const err = await oauthError(res, discovery.tokenUrl);
181
222
  if (err === "authorization_pending")
@@ -192,7 +233,10 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
192
233
  // Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
193
234
  if (res.status === 408 || res.status === 429 || res.status >= 500) {
194
235
  const retryAfter = Number(res.headers.get("retry-after"));
195
- intervalMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : backoff(intervalMs);
236
+ intervalMs =
237
+ Number.isFinite(retryAfter) && retryAfter > 0
238
+ ? retryAfter * 1_000
239
+ : backoff(intervalMs);
196
240
  continue;
197
241
  }
198
242
  throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
@@ -202,18 +246,35 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
202
246
  function devicePollDelay(ms, signal, sleep) {
203
247
  return new Promise((resolve, reject) => {
204
248
  let timer;
205
- const clean = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); };
206
- const abort = () => { clean(); reject(new Error("device_poll_stopped")); };
249
+ const clean = () => {
250
+ clearTimeout(timer);
251
+ signal.removeEventListener("abort", abort);
252
+ };
253
+ const abort = () => {
254
+ clean();
255
+ reject(new Error("device_poll_stopped"));
256
+ };
207
257
  if (signal.aborted) {
208
258
  abort();
209
259
  return;
210
260
  }
211
261
  signal.addEventListener("abort", abort, { once: true });
212
262
  if (sleep) {
213
- Promise.resolve().then(() => sleep(ms)).then(() => { clean(); resolve(); }, error => { clean(); reject(error); });
263
+ Promise.resolve()
264
+ .then(() => sleep(ms))
265
+ .then(() => {
266
+ clean();
267
+ resolve();
268
+ }, (error) => {
269
+ clean();
270
+ reject(error);
271
+ });
214
272
  }
215
273
  else
216
- timer = setTimeout(() => { clean(); resolve(); }, ms);
274
+ timer = setTimeout(() => {
275
+ clean();
276
+ resolve();
277
+ }, ms);
217
278
  });
218
279
  }
219
280
  /** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
@@ -243,18 +304,27 @@ function persistRotation(ctx, cfg, sessionId, rotated) {
243
304
  writeConfig(ctx.home, target); // atomic; the rotated pair survives a crash
244
305
  }
245
306
  async function deviceAuthorize(ctx, discovery) {
246
- const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, { method: "POST", headers: formHeaders(), body: form({ client_id: discovery.clientId }) });
307
+ const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, {
308
+ method: "POST",
309
+ headers: formHeaders(),
310
+ body: form({ client_id: discovery.clientId }),
311
+ });
247
312
  if (!res.ok)
248
313
  throw new CliError(`could not start login (${res.status}) at ${discovery.deviceAuthorizationUrl}`, "login-failed");
249
314
  const d = (await parseJson(res, discovery.deviceAuthorizationUrl));
250
- if (typeof d.device_code !== "string" || typeof d.user_code !== "string" || typeof d.verification_uri !== "string") {
315
+ if (typeof d.device_code !== "string" ||
316
+ typeof d.user_code !== "string" ||
317
+ typeof d.verification_uri !== "string") {
251
318
  throw new CliError("the login authorization response was not the expected shape", "login-shape");
252
319
  }
253
320
  return {
254
321
  device_code: d.device_code,
255
322
  user_code: d.user_code,
256
323
  verification_uri: d.verification_uri,
257
- ...(typeof d.verification_uri_complete === "string" && d.verification_uri_complete !== "" ? { verification_uri_complete: d.verification_uri_complete } : {}),
324
+ ...(typeof d.verification_uri_complete === "string" &&
325
+ d.verification_uri_complete !== ""
326
+ ? { verification_uri_complete: d.verification_uri_complete }
327
+ : {}),
258
328
  expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
259
329
  interval: typeof d.interval === "number" ? d.interval : 5,
260
330
  };
@@ -293,10 +363,19 @@ async function refreshAndPersist(ctx, cfg, deps) {
293
363
  for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
294
364
  if (attempt > 0)
295
365
  await sleep(REFRESH_BASE_BACKOFF_MS * 2 ** (attempt - 1));
296
- const res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: discovery.clientId }) });
366
+ const res = await safeFetch(ctx, discovery.tokenUrl, {
367
+ method: "POST",
368
+ headers: formHeaders(),
369
+ body: form({
370
+ grant_type: "refresh_token",
371
+ refresh_token: current.refreshToken,
372
+ client_id: discovery.clientId,
373
+ }),
374
+ });
297
375
  if (res.ok) {
298
376
  const pair = (await parseJson(res, discovery.tokenUrl));
299
- if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string")
377
+ if (typeof pair.access_token !== "string" ||
378
+ typeof pair.refresh_token !== "string")
300
379
  throw new CliError("the refresh response was missing a token", "session-refresh-shape");
301
380
  const rotated = tokensToAuth(ctx, pair.access_token, pair.refresh_token, current.sessionId);
302
381
  cfg.auth = rotated; // this process keeps using its own refreshed token
@@ -326,9 +405,17 @@ export function authStrategyFor(ctx, cfg) {
326
405
  // ── helpers ──────────────────────────────────────────────────────────────────────────────────────
327
406
  function tokensToAuth(ctx, accessToken, refreshToken, fallbackSid) {
328
407
  const claims = decodeJwtClaims(accessToken);
329
- const exp = typeof claims.exp === "number" ? claims.exp * 1_000 : ctx.now().getTime() + 15 * 60_000;
408
+ const exp = typeof claims.exp === "number"
409
+ ? claims.exp * 1_000
410
+ : ctx.now().getTime() + 15 * 60_000;
330
411
  const sid = typeof claims.sid === "string" ? claims.sid : (fallbackSid ?? "");
331
- return { kind: "oauth", accessToken, refreshToken, expiresAt: new Date(exp).toISOString(), sessionId: sid };
412
+ return {
413
+ kind: "oauth",
414
+ accessToken,
415
+ refreshToken,
416
+ expiresAt: new Date(exp).toISOString(),
417
+ sessionId: sid,
418
+ };
332
419
  }
333
420
  function decodeJwtClaims(token) {
334
421
  const parts = token.split(".");
@@ -342,13 +429,18 @@ function decodeJwtClaims(token) {
342
429
  }
343
430
  }
344
431
  function formHeaders() {
345
- return { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
432
+ return {
433
+ "content-type": "application/x-www-form-urlencoded",
434
+ accept: "application/json",
435
+ };
346
436
  }
347
437
  function form(fields) {
348
438
  return new URLSearchParams(fields).toString();
349
439
  }
350
440
  async function safeFetch(ctx, url, init, signal) {
351
- const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
441
+ const sig = signal
442
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
443
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
352
444
  try {
353
445
  return await ctx.fetch(url, { ...init, signal: sig });
354
446
  }