@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.
- package/CHANGELOG.md +9 -0
- package/README.md +5 -3
- package/dist/args.js +51 -13
- package/dist/cli.js +58 -10
- package/dist/config.js +16 -3
- 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 +243 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +203 -0
- package/dist/events-cloud-tail.js +134 -0
- package/dist/events-cloud.js +500 -0
- package/dist/events.js +13 -4
- package/dist/legacy.js +145 -32
- package/dist/oauth.js +125 -33
- 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-login-candidate.js +261 -0
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +147 -37
- package/dist/onboard-runtime.js +104 -9
- 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 +99 -11
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard.js +359 -27
- package/dist/owned-fetch.js +182 -0
- package/dist/query.js +77 -27
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +2 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- 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 +3 -3
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +5 -4
- package/skills/catalyst-onboard/references/local-sync.md +3 -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 +2 -2
- 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/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 = [
|
|
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 = [
|
|
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 = [
|
|
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"
|
|
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" &&
|
|
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({
|
|
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({
|
|
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) ||
|
|
125
|
+
.filter((label) => OLD_LAUNCHD_LABELS.includes(label) ||
|
|
126
|
+
label.startsWith(OLD_ROLE_PREFIX))
|
|
100
127
|
.sort()
|
|
101
|
-
.map((label) => ({
|
|
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) => ({
|
|
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) => ({
|
|
112
|
-
|
|
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 [
|
|
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 {
|
|
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
|
|
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"
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
ctx.stdout(
|
|
167
|
-
|
|
168
|
-
|
|
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 (${
|
|
173
|
-
...
|
|
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({
|
|
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({
|
|
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 ${
|
|
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({
|
|
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
|
|
221
|
-
|
|
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) &&
|
|
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;
|
|
@@ -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
|
|
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 = () => {
|
|
141
|
-
|
|
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, {
|
|
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" ||
|
|
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 {
|
|
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 =
|
|
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 = () => {
|
|
206
|
-
|
|
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()
|
|
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(() => {
|
|
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, {
|
|
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" ||
|
|
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" &&
|
|
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, {
|
|
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" ||
|
|
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"
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
}
|