@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.
- package/CHANGELOG.md +14 -0
- package/README.md +7 -5
- package/dist/args.js +68 -18
- package/dist/browser.js +37 -3
- package/dist/cli.js +58 -12
- package/dist/config.js +17 -5
- package/dist/consent-browser.js +58 -0
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +250 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +205 -0
- package/dist/events-cloud-tail.js +146 -0
- package/dist/events-cloud.js +619 -0
- package/dist/events.js +186 -31
- package/dist/oauth.js +129 -34
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-github.js +14 -3
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +146 -37
- package/dist/onboard-runtime.js +108 -13
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +85 -59
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +422 -39
- package/dist/owned-fetch.js +182 -0
- package/dist/pagination.js +13 -1
- package/dist/query.js +104 -31
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/repository-id.js +5 -0
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +4 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/local-replica.md +78 -0
- package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +5 -3
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +6 -4
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +8 -4
- package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +4 -2
- package/skills/whats-happening/references/local-replica.md +51 -0
- package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
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
|
|
37
|
-
const matches =
|
|
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}
|
|
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,
|
|
84
|
-
const requested = flagString(args,
|
|
85
|
-
if (requested
|
|
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
|
-
|
|
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
|
|
121
|
+
function eventFilter(args) {
|
|
96
122
|
const type = flagString(args, "type");
|
|
97
123
|
const ticket = flagString(args, "ticket")?.toUpperCase();
|
|
98
|
-
return
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) &&
|
|
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 [
|
|
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 = () => {
|
|
39
|
-
|
|
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, {
|
|
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 = () => {
|
|
87
|
-
|
|
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 = {
|
|
93
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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 = () => {
|
|
141
|
-
|
|
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, {
|
|
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" ||
|
|
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 {
|
|
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 =
|
|
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 = () => {
|
|
206
|
-
|
|
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()
|
|
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(() => {
|
|
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, {
|
|
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" ||
|
|
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" &&
|
|
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, {
|
|
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" ||
|
|
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"
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
}
|