@catalyst-cloud/cli 0.14.6 → 0.14.8

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 (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
package/dist/events.js CHANGED
@@ -1,10 +1,24 @@
1
1
  import { flagInt, flagString, positionals } from "./args.js";
2
- import { apiBase, requireConfig } from "./config.js";
2
+ import { normalizeBaseUrl, requireConfig } from "./config.js";
3
3
  import { CliError, UsageError } from "./errors.js";
4
4
  import { authStrategyFor } from "./oauth.js";
5
5
  import { eventCacheStatus } from "./event-status.js";
6
+ import { cloudEventHead, queryCloudEvents, unreachableError, unreachableKind } from "./events-cloud.js";
7
+ import { eventMatches, tailCloudEvents, waitForCloudEvent } from "./events-cloud-tail.js";
6
8
  const eventsModule = "@catalyst-cloud/sdk/events";
7
9
  export const loadEventsSdk = () => import(eventsModule);
10
+ /** Consecutive failed attempts after which `wait-for` reports an outage rather than waiting on. */
11
+ const OUTAGE_ATTEMPTS = 5;
12
+ /** How long the cloud may be unreachable before `tail` says so on stderr. */
13
+ const TAIL_WARN_AFTER_MS = 60_000;
14
+ function processInterrupts(handler) {
15
+ process.once("SIGINT", handler);
16
+ process.once("SIGTERM", handler);
17
+ return () => {
18
+ process.off("SIGINT", handler);
19
+ process.off("SIGTERM", handler);
20
+ };
21
+ }
8
22
  export async function createEventSync(ctx, deps = {}) {
9
23
  const cfg = requireConfig(ctx);
10
24
  const sdk = await (deps.loadSdk ?? loadEventsSdk)();
@@ -12,7 +26,8 @@ export async function createEventSync(ctx, deps = {}) {
12
26
  if (auth.kind === "cookie")
13
27
  throw new CliError("event sync requires a token or OAuth session", "events-auth");
14
28
  return new sdk.CatalystEventSync({
15
- baseUrl: apiBase(cfg),
29
+ // Events SDK adds /api/v1/events/backbone itself; unlike the replica it takes an origin.
30
+ baseUrl: normalizeBaseUrl(cfg.baseUrl),
16
31
  auth,
17
32
  tenantId: cfg.account,
18
33
  fetch: ctx.fetch,
@@ -24,17 +39,31 @@ export async function cmdEvents(args, ctx, deps = {}) {
24
39
  throw new UsageError("events needs a subcommand: tail | wait-for | query | status");
25
40
  if (!["tail", "wait-for", "query", "status"].includes(sub))
26
41
  throw new UsageError(`unknown events subcommand: ${sub}`);
42
+ // CTC-4554 — the cloud is the default source; the local event file is the opt-in.
43
+ if (args.flags["from-cache"] !== true) {
44
+ if (flagString(args, "directory") !== undefined)
45
+ throw new UsageError("--directory names the local event cache; add --from-cache to read it");
46
+ return cmdCloudEvents(sub, args, ctx, deps);
47
+ }
27
48
  const cfg = requireConfig(ctx);
28
49
  const sdk = await (deps.loadSdk ?? loadEventsSdk)();
29
50
  const directory = flagString(args, "directory") ??
30
51
  sdk.defaultEventCacheDirectory(cfg.account);
31
52
  if (sub === "status") {
32
53
  const status = await eventCacheStatus(ctx, directory, args.flags.probe === true);
33
- ctx.stdout(args.json ? JSON.stringify(status) : `events: ${status.verdict} at ${directory}${status.reasons.length ? ` (${status.reasons.join("; ")})` : ` (cursor ${status.cursor}, cloud head ${status.head})`}`);
34
- return status.verdict === "current" ? 0 : status.verdict === "stale" ? 1 : status.verdict === "absent" ? 3 : 2;
54
+ ctx.stdout(args.json
55
+ ? JSON.stringify({ source: "cache", ...status })
56
+ : `events: local cache ${status.verdict} at ${directory}${status.reasons.length ? ` (${status.reasons.join("; ")})` : ` (cursor ${status.cursor}, cloud head ${status.head})`}`);
57
+ return status.verdict === "current"
58
+ ? 0
59
+ : status.verdict === "stale"
60
+ ? 1
61
+ : status.verdict === "absent"
62
+ ? 3
63
+ : 2;
35
64
  }
36
- const after = startingCursor(args, sub === "query");
37
- const matches = matcher(args);
65
+ const after = startingCursor(args);
66
+ const matches = eventMatches(eventFilter(args));
38
67
  if (sub === "query") {
39
68
  const events = (await sdk.readCachedEvents({ tenantId: cfg.account, directory, after }))
40
69
  .filter(matches)
@@ -71,7 +100,7 @@ export async function cmdEvents(args, ctx, deps = {}) {
71
100
  if (controller.signal.aborted)
72
101
  return sub === "wait-for" ? 1 : 0;
73
102
  if (error.code === "ENOENT")
74
- throw new CliError(`event cache is absent at ${directory} — start it with: catalyst replica start --detach`, "events-absent", 3);
103
+ throw new CliError(`event cache is absent at ${directory}: it is written only where local sync runs. Start the local writer with: catalyst replica start --detach, or drop --from-cache to read from the cloud`, "events-absent", 3);
75
104
  throw error;
76
105
  }
77
106
  finally {
@@ -80,33 +109,159 @@ export async function cmdEvents(args, ctx, deps = {}) {
80
109
  deps.signal?.removeEventListener("abort", abort);
81
110
  }
82
111
  }
83
- function startingCursor(args, history) {
84
- const requested = flagString(args, "after");
85
- if (requested !== undefined) {
86
- const parsed = Number(requested);
87
- if (!Number.isSafeInteger(parsed) || parsed < 0)
88
- throw new UsageError("--after must be a non-negative event sequence");
89
- return parsed;
90
- }
91
- if (history)
112
+ function startingCursor(args, name = "after") {
113
+ const requested = flagString(args, name);
114
+ if (requested === undefined)
92
115
  return undefined;
93
- return undefined;
116
+ const parsed = Number(requested);
117
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
118
+ throw new UsageError(`--${name} must be a non-negative event sequence`);
119
+ return parsed;
94
120
  }
95
- function matcher(args) {
121
+ function eventFilter(args) {
96
122
  const type = flagString(args, "type");
97
123
  const ticket = flagString(args, "ticket")?.toUpperCase();
98
- return (event) => (!type || event.type === type) &&
99
- (!ticket || payloadReferences(event.payload, ticket));
124
+ return { ...(type ? { type } : {}), ...(ticket ? { ticket } : {}) };
125
+ }
126
+ /** A sink over `ctx.stdout` for contexts without a stream (tests): one call per line. */
127
+ function lineSink(ctx) {
128
+ return {
129
+ write(chunk, callback) {
130
+ try {
131
+ ctx.stdout(chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk);
132
+ callback();
133
+ }
134
+ catch (error) {
135
+ callback(error);
136
+ }
137
+ return true;
138
+ },
139
+ on: () => undefined,
140
+ off: () => undefined,
141
+ };
100
142
  }
101
- function payloadReferences(value, ticket) {
102
- if (typeof value === "string")
103
- return value.toUpperCase() === ticket;
104
- if (Array.isArray(value))
105
- return value.some((item) => payloadReferences(item, ticket));
106
- if (!value || typeof value !== "object")
107
- return false;
108
- return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) &&
109
- typeof item === "string"
110
- ? item.toUpperCase() === ticket
111
- : payloadReferences(item, ticket));
143
+ const QUERY_MAX_LIMIT = 200;
144
+ const LOCAL_SYNC_HINT = "several agents on one machine are cheaper sharing one local cache: opt in to local sync (catalyst onboard --local-sync), then read it with --from-cache";
145
+ /** CTC-4554 — `events tail | wait-for | query | status` against the cloud, with no local file. */
146
+ async function cmdCloudEvents(sub, args, ctx, deps) {
147
+ requireConfig(ctx);
148
+ const filter = eventFilter(args);
149
+ if (sub === "status") {
150
+ let head;
151
+ try {
152
+ head = await cloudEventHead(ctx);
153
+ }
154
+ catch (error) {
155
+ if (error instanceof CliError)
156
+ throw error;
157
+ const failure = unreachableError(error);
158
+ if (args.json)
159
+ ctx.stdout(JSON.stringify({ source: "cloud", reachable: false, error: unreachableKind(error) }));
160
+ else
161
+ ctx.stderr(`catalyst: ${failure.message}`);
162
+ return failure.exitCode;
163
+ }
164
+ // A cloud stream has no local cursor to fall behind. Filtered reads come from the event index,
165
+ // which trails the head while it fills; that lag is measured, not assumed.
166
+ let indexedToSeq = null;
167
+ try {
168
+ indexedToSeq = (await queryCloudEvents(ctx, { limit: 1, order: "desc" })).coverage.indexedToSeq;
169
+ }
170
+ catch {
171
+ indexedToSeq = null;
172
+ }
173
+ const indexLag = indexedToSeq === null ? null : Math.max(0, head - indexedToSeq);
174
+ const status = { source: "cloud", reachable: true, head, behind: null, indexedToSeq, indexLag, note: LOCAL_SYNC_HINT };
175
+ ctx.stdout(args.json
176
+ ? JSON.stringify(status)
177
+ : `events: cloud stream at head ${head}; ${indexLag === null ? "the filtered index could not be read" : `filtered reads (--ticket, --type) trail it by ${indexLag}`} (every read goes to the cloud; ${LOCAL_SYNC_HINT})`);
178
+ return 0;
179
+ }
180
+ if (sub === "query") {
181
+ const limit = flagInt(args, "limit", 50);
182
+ if (!Number.isInteger(limit) || limit < 1 || limit > QUERY_MAX_LIMIT)
183
+ throw new UsageError(`--limit must be between 1 and ${QUERY_MAX_LIMIT} for a cloud query`);
184
+ const order = flagString(args, "order") ?? "desc";
185
+ if (order !== "asc" && order !== "desc")
186
+ throw new UsageError("--order must be asc or desc");
187
+ const page = await queryCloudEvents(ctx, {
188
+ ...filter,
189
+ limit,
190
+ order,
191
+ afterSeq: startingCursor(args, "after"),
192
+ beforeSeq: startingCursor(args, "before"),
193
+ });
194
+ for (const event of page.events)
195
+ ctx.stdout(JSON.stringify(event));
196
+ if (page.next)
197
+ ctx.stderr(`more: re-run with --${page.next.param === "beforeSeq" ? "before" : "after"} ${page.next.value}`);
198
+ if (page.events.length === 0) {
199
+ const { indexedFromSeq: from, indexedToSeq: to } = page.coverage;
200
+ ctx.stderr(from === null || to === null
201
+ ? "no match; the cloud index holds no events yet"
202
+ : `no match; the cloud index holds sequences ${from} to ${to}, so an earlier event may exist unindexed`);
203
+ }
204
+ return 0;
205
+ }
206
+ const out = deps.out ?? ctx.stdoutStream ?? lineSink(ctx);
207
+ const controller = new AbortController();
208
+ const abort = () => controller.abort(deps.signal?.reason);
209
+ if (deps.signal?.aborted)
210
+ abort();
211
+ else
212
+ deps.signal?.addEventListener("abort", abort, { once: true });
213
+ let interrupted = false;
214
+ const removeInterrupt = (deps.onInterrupt ?? processInterrupts)(() => {
215
+ interrupted = true;
216
+ controller.abort(new Error("interrupted"));
217
+ });
218
+ let outage;
219
+ let warned = false;
220
+ const seconds = (sinceMs) => Math.round((ctx.now().getTime() - sinceMs) / 1_000);
221
+ const cloud = {
222
+ ...deps.cloud,
223
+ onTrouble: (state) => {
224
+ deps.cloud?.onTrouble?.(state);
225
+ if (sub === "wait-for" && state.failures >= OUTAGE_ATTEMPTS && !outage) {
226
+ outage = state;
227
+ controller.abort(new Error("cloud unreachable"));
228
+ }
229
+ if (sub === "tail" && !warned && ctx.now().getTime() - state.sinceMs > TAIL_WARN_AFTER_MS) {
230
+ warned = true;
231
+ ctx.stderr(`events: the cloud has been unreachable for ${seconds(state.sinceMs)} s (${state.failures} attempts); still retrying`);
232
+ }
233
+ },
234
+ onHealthy: () => {
235
+ deps.cloud?.onHealthy?.();
236
+ if (warned)
237
+ ctx.stderr("events: the cloud is reachable again");
238
+ warned = false;
239
+ },
240
+ };
241
+ try {
242
+ const after = startingCursor(args);
243
+ if (sub === "wait-for") {
244
+ const timeout = flagInt(args, "timeout", 300);
245
+ const result = await waitForCloudEvent(ctx, { after, filter, timeoutMs: timeout * 1_000, out, signal: controller.signal }, cloud);
246
+ if (interrupted)
247
+ return 130;
248
+ if (outage) {
249
+ ctx.stderr(`events: the cloud event service was unreachable for ${outage.failures} attempts over ${seconds(outage.sinceMs)} s; this is an outage, not a timeout`);
250
+ return 4;
251
+ }
252
+ if (result.outcome === "matched" || result.outcome === "closed")
253
+ return 0;
254
+ if (result.outcome === "timeout")
255
+ ctx.stderr(`events: no matching event within ${timeout} s`);
256
+ return 1;
257
+ }
258
+ const result = await tailCloudEvents(ctx, { after, filter, out, signal: controller.signal }, cloud);
259
+ if (result.outcome !== "closed" && result.cursor !== undefined)
260
+ ctx.stderr(`events: stopped at sequence ${result.cursor}; resume with --after ${result.cursor}`);
261
+ return interrupted ? 130 : 0;
262
+ }
263
+ finally {
264
+ removeInterrupt();
265
+ deps.signal?.removeEventListener("abort", abort);
266
+ }
112
267
  }
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;
@@ -110,17 +135,22 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
110
135
  ctx.stdout(`Or open this link, which fills the code in: ${auth.verification_uri_complete}`);
111
136
  if (round === 1 ? (deps.isTty ?? (() => false))() : browserOpened) {
112
137
  try {
113
- (deps.openBrowser ?? (() => { }))(auth.verification_uri_complete ?? auth.verification_uri);
138
+ if (!deps.openBrowser)
139
+ throw new Error("browser-unavailable");
140
+ await deps.openBrowser(auth.verification_uri_complete ?? auth.verification_uri);
114
141
  browserOpened = true;
115
142
  ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
116
143
  }
117
144
  catch {
118
145
  // a browser that will not open is not a failure; the code and URL still work
146
+ ctx.stdout("Open the link above in a browser.");
119
147
  }
120
148
  }
121
149
  ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
122
150
  const poll = () => pollDeviceCode(ctx, discovery, auth, deps.sleep, deps.signal);
123
- const tokens = deps.waitForApproval ? await deps.waitForApproval(poll) : await poll();
151
+ const tokens = deps.waitForApproval
152
+ ? await deps.waitForApproval(poll)
153
+ : await poll();
124
154
  cancelled();
125
155
  if (tokens !== "expired")
126
156
  return tokensToAuth(ctx, tokens.access_token, tokens.refresh_token);
@@ -137,8 +167,10 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
137
167
  async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
138
168
  const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
139
169
  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); };
170
+ const cancelled = () => {
171
+ if (external?.aborted)
172
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
173
+ };
142
174
  let intervalMs = Math.max(1, auth.interval) * 1_000;
143
175
  for (;;) {
144
176
  cancelled();
@@ -158,7 +190,15 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
158
190
  return "expired";
159
191
  let res;
160
192
  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);
193
+ res = await safeFetch(ctx, discovery.tokenUrl, {
194
+ method: "POST",
195
+ headers: formHeaders(),
196
+ body: form({
197
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
198
+ device_code: auth.device_code,
199
+ client_id: discovery.clientId,
200
+ }),
201
+ }, signal);
162
202
  cancelled();
163
203
  }
164
204
  catch (err) {
@@ -172,10 +212,14 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
172
212
  }
173
213
  if (res.ok) {
174
214
  const pair = (await parseJson(res, discovery.tokenUrl));
175
- if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
215
+ if (typeof pair.access_token !== "string" ||
216
+ typeof pair.refresh_token !== "string") {
176
217
  throw new CliError("the login token response was missing a token", "login-shape");
177
218
  }
178
- return { access_token: pair.access_token, refresh_token: pair.refresh_token };
219
+ return {
220
+ access_token: pair.access_token,
221
+ refresh_token: pair.refresh_token,
222
+ };
179
223
  }
180
224
  const err = await oauthError(res, discovery.tokenUrl);
181
225
  if (err === "authorization_pending")
@@ -192,7 +236,10 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
192
236
  // Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
193
237
  if (res.status === 408 || res.status === 429 || res.status >= 500) {
194
238
  const retryAfter = Number(res.headers.get("retry-after"));
195
- intervalMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : backoff(intervalMs);
239
+ intervalMs =
240
+ Number.isFinite(retryAfter) && retryAfter > 0
241
+ ? retryAfter * 1_000
242
+ : backoff(intervalMs);
196
243
  continue;
197
244
  }
198
245
  throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
@@ -202,18 +249,35 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
202
249
  function devicePollDelay(ms, signal, sleep) {
203
250
  return new Promise((resolve, reject) => {
204
251
  let timer;
205
- const clean = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); };
206
- const abort = () => { clean(); reject(new Error("device_poll_stopped")); };
252
+ const clean = () => {
253
+ clearTimeout(timer);
254
+ signal.removeEventListener("abort", abort);
255
+ };
256
+ const abort = () => {
257
+ clean();
258
+ reject(new Error("device_poll_stopped"));
259
+ };
207
260
  if (signal.aborted) {
208
261
  abort();
209
262
  return;
210
263
  }
211
264
  signal.addEventListener("abort", abort, { once: true });
212
265
  if (sleep) {
213
- Promise.resolve().then(() => sleep(ms)).then(() => { clean(); resolve(); }, error => { clean(); reject(error); });
266
+ Promise.resolve()
267
+ .then(() => sleep(ms))
268
+ .then(() => {
269
+ clean();
270
+ resolve();
271
+ }, (error) => {
272
+ clean();
273
+ reject(error);
274
+ });
214
275
  }
215
276
  else
216
- timer = setTimeout(() => { clean(); resolve(); }, ms);
277
+ timer = setTimeout(() => {
278
+ clean();
279
+ resolve();
280
+ }, ms);
217
281
  });
218
282
  }
219
283
  /** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
@@ -243,18 +307,27 @@ function persistRotation(ctx, cfg, sessionId, rotated) {
243
307
  writeConfig(ctx.home, target); // atomic; the rotated pair survives a crash
244
308
  }
245
309
  async function deviceAuthorize(ctx, discovery) {
246
- const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, { method: "POST", headers: formHeaders(), body: form({ client_id: discovery.clientId }) });
310
+ const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, {
311
+ method: "POST",
312
+ headers: formHeaders(),
313
+ body: form({ client_id: discovery.clientId }),
314
+ });
247
315
  if (!res.ok)
248
316
  throw new CliError(`could not start login (${res.status}) at ${discovery.deviceAuthorizationUrl}`, "login-failed");
249
317
  const d = (await parseJson(res, discovery.deviceAuthorizationUrl));
250
- if (typeof d.device_code !== "string" || typeof d.user_code !== "string" || typeof d.verification_uri !== "string") {
318
+ if (typeof d.device_code !== "string" ||
319
+ typeof d.user_code !== "string" ||
320
+ typeof d.verification_uri !== "string") {
251
321
  throw new CliError("the login authorization response was not the expected shape", "login-shape");
252
322
  }
253
323
  return {
254
324
  device_code: d.device_code,
255
325
  user_code: d.user_code,
256
326
  verification_uri: d.verification_uri,
257
- ...(typeof d.verification_uri_complete === "string" && d.verification_uri_complete !== "" ? { verification_uri_complete: d.verification_uri_complete } : {}),
327
+ ...(typeof d.verification_uri_complete === "string" &&
328
+ d.verification_uri_complete !== ""
329
+ ? { verification_uri_complete: d.verification_uri_complete }
330
+ : {}),
258
331
  expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
259
332
  interval: typeof d.interval === "number" ? d.interval : 5,
260
333
  };
@@ -293,10 +366,19 @@ async function refreshAndPersist(ctx, cfg, deps) {
293
366
  for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
294
367
  if (attempt > 0)
295
368
  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 }) });
369
+ const res = await safeFetch(ctx, discovery.tokenUrl, {
370
+ method: "POST",
371
+ headers: formHeaders(),
372
+ body: form({
373
+ grant_type: "refresh_token",
374
+ refresh_token: current.refreshToken,
375
+ client_id: discovery.clientId,
376
+ }),
377
+ });
297
378
  if (res.ok) {
298
379
  const pair = (await parseJson(res, discovery.tokenUrl));
299
- if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string")
380
+ if (typeof pair.access_token !== "string" ||
381
+ typeof pair.refresh_token !== "string")
300
382
  throw new CliError("the refresh response was missing a token", "session-refresh-shape");
301
383
  const rotated = tokensToAuth(ctx, pair.access_token, pair.refresh_token, current.sessionId);
302
384
  cfg.auth = rotated; // this process keeps using its own refreshed token
@@ -326,9 +408,17 @@ export function authStrategyFor(ctx, cfg) {
326
408
  // ── helpers ──────────────────────────────────────────────────────────────────────────────────────
327
409
  function tokensToAuth(ctx, accessToken, refreshToken, fallbackSid) {
328
410
  const claims = decodeJwtClaims(accessToken);
329
- const exp = typeof claims.exp === "number" ? claims.exp * 1_000 : ctx.now().getTime() + 15 * 60_000;
411
+ const exp = typeof claims.exp === "number"
412
+ ? claims.exp * 1_000
413
+ : ctx.now().getTime() + 15 * 60_000;
330
414
  const sid = typeof claims.sid === "string" ? claims.sid : (fallbackSid ?? "");
331
- return { kind: "oauth", accessToken, refreshToken, expiresAt: new Date(exp).toISOString(), sessionId: sid };
415
+ return {
416
+ kind: "oauth",
417
+ accessToken,
418
+ refreshToken,
419
+ expiresAt: new Date(exp).toISOString(),
420
+ sessionId: sid,
421
+ };
332
422
  }
333
423
  function decodeJwtClaims(token) {
334
424
  const parts = token.split(".");
@@ -342,13 +432,18 @@ function decodeJwtClaims(token) {
342
432
  }
343
433
  }
344
434
  function formHeaders() {
345
- return { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
435
+ return {
436
+ "content-type": "application/x-www-form-urlencoded",
437
+ accept: "application/json",
438
+ };
346
439
  }
347
440
  function form(fields) {
348
441
  return new URLSearchParams(fields).toString();
349
442
  }
350
443
  async function safeFetch(ctx, url, init, signal) {
351
- const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
444
+ const sig = signal
445
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
446
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
352
447
  try {
353
448
  return await ctx.fetch(url, { ...init, signal: sig });
354
449
  }