@catalyst-cloud/cli 0.14.6 → 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 +5 -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/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/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
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
|
+
import { readExistingOnboardJson } from "./onboard-existing.js";
|
|
3
|
+
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
4
|
+
const listPath = "/api/v1/coding-accounts";
|
|
5
|
+
const validatePath = "/api/v1/coding-accounts/:slot/validate";
|
|
6
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
? value
|
|
8
|
+
: null;
|
|
9
|
+
const waiting = (reason) => ({
|
|
10
|
+
state: "waiting",
|
|
11
|
+
reason,
|
|
12
|
+
});
|
|
13
|
+
const slotPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
14
|
+
function binding(ctx, journal) {
|
|
15
|
+
const cfg = loadConfig(ctx.home);
|
|
16
|
+
if (!cfg?.user ||
|
|
17
|
+
cfg.account !== (journal.account ?? journal.tenant) ||
|
|
18
|
+
cfg.user.id !== journal.membershipId ||
|
|
19
|
+
!journal.baseUrl ||
|
|
20
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
21
|
+
return null;
|
|
22
|
+
return {
|
|
23
|
+
account: cfg.account,
|
|
24
|
+
person: cfg.user.id,
|
|
25
|
+
origin: normalizeBaseUrl(cfg.baseUrl),
|
|
26
|
+
role: cfg.user.role,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const equal = (a, b) => a?.account === b.account &&
|
|
30
|
+
a.person === b.person &&
|
|
31
|
+
a.origin === b.origin &&
|
|
32
|
+
a.role === b.role;
|
|
33
|
+
async function inventory(ctx, journal, signal) {
|
|
34
|
+
const identity = binding(ctx, journal);
|
|
35
|
+
if (!identity)
|
|
36
|
+
return { reason: "account_identity_unverified" };
|
|
37
|
+
const support = await verifyOnboardRoutes(ctx, journal, [{ method: "GET", path: listPath }], signal);
|
|
38
|
+
if ("reason" in support)
|
|
39
|
+
return support;
|
|
40
|
+
if (!equal(binding(ctx, journal), identity))
|
|
41
|
+
return { reason: "account_identity_unverified" };
|
|
42
|
+
const read = await readExistingOnboardJson(ctx, listPath, signal);
|
|
43
|
+
if (!equal(binding(ctx, journal), identity))
|
|
44
|
+
return { reason: "account_identity_unverified" };
|
|
45
|
+
if ("reason" in read)
|
|
46
|
+
return { reason: "account_inventory_unavailable" };
|
|
47
|
+
const body = object(read.body);
|
|
48
|
+
const now = ctx.now().getTime();
|
|
49
|
+
if (!Array.isArray(body?.accounts) ||
|
|
50
|
+
body.accounts.length > 1000 ||
|
|
51
|
+
typeof body.observedAtMs !== "number" ||
|
|
52
|
+
!Number.isSafeInteger(body.observedAtMs) ||
|
|
53
|
+
body.observedAtMs > now + 5000 ||
|
|
54
|
+
body.observedAtMs < now - 30000)
|
|
55
|
+
return { reason: "account_inventory_unverified" };
|
|
56
|
+
const slots = [], seen = new Set();
|
|
57
|
+
for (const value of body.accounts) {
|
|
58
|
+
const row = object(value);
|
|
59
|
+
if (!row ||
|
|
60
|
+
typeof row.accountSlot !== "string" ||
|
|
61
|
+
!slotPattern.test(row.accountSlot) ||
|
|
62
|
+
seen.has(row.accountSlot) ||
|
|
63
|
+
typeof row.provider !== "string" ||
|
|
64
|
+
!/^[a-z][a-z0-9_-]{0,63}$/.test(row.provider) ||
|
|
65
|
+
typeof row.declaredState !== "string" ||
|
|
66
|
+
!["active", "inactive"].includes(row.declaredState) ||
|
|
67
|
+
typeof row.ownedByMe !== "boolean" ||
|
|
68
|
+
typeof row.needsCredential !== "boolean" ||
|
|
69
|
+
typeof row.walled !== "boolean" ||
|
|
70
|
+
typeof row.quarantined !== "boolean" ||
|
|
71
|
+
![null, "renews", "canceled"].includes(row.renewalStatus) ||
|
|
72
|
+
!(row.revokedAtMs === null ||
|
|
73
|
+
(typeof row.revokedAtMs === "number" &&
|
|
74
|
+
Number.isSafeInteger(row.revokedAtMs))) ||
|
|
75
|
+
!(row.accessEndsAtMs === null ||
|
|
76
|
+
(typeof row.accessEndsAtMs === "number" &&
|
|
77
|
+
Number.isSafeInteger(row.accessEndsAtMs))))
|
|
78
|
+
return { reason: "account_inventory_unverified" };
|
|
79
|
+
seen.add(row.accountSlot);
|
|
80
|
+
// Project only metadata we need. Unknown server fields never enter output or receipts.
|
|
81
|
+
slots.push({
|
|
82
|
+
accountSlot: row.accountSlot,
|
|
83
|
+
provider: row.provider,
|
|
84
|
+
declaredState: row.declaredState,
|
|
85
|
+
ownedByMe: row.ownedByMe,
|
|
86
|
+
needsCredential: row.needsCredential,
|
|
87
|
+
walled: row.walled,
|
|
88
|
+
quarantined: row.quarantined,
|
|
89
|
+
renewalStatus: row.renewalStatus,
|
|
90
|
+
revokedAtMs: row.revokedAtMs,
|
|
91
|
+
accessEndsAtMs: row.accessEndsAtMs,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { slots, identity };
|
|
95
|
+
}
|
|
96
|
+
/** Verify one existing Claude slot per run. A stored login, cached poll or Codex shape is not
|
|
97
|
+
* fresh provider access. Enrollment and a phase's actual lease eligibility remain separate. */
|
|
98
|
+
export function onboardAccountsAdapter(input = {}) {
|
|
99
|
+
let proof = null;
|
|
100
|
+
let attempted = false;
|
|
101
|
+
const candidate = (slots, now) => slots
|
|
102
|
+
.filter((row) => row.provider === "claude" &&
|
|
103
|
+
row.declaredState === "active" &&
|
|
104
|
+
row.ownedByMe &&
|
|
105
|
+
!row.needsCredential &&
|
|
106
|
+
!row.walled &&
|
|
107
|
+
!row.quarantined &&
|
|
108
|
+
row.renewalStatus !== "canceled" &&
|
|
109
|
+
row.revokedAtMs === null &&
|
|
110
|
+
(row.accessEndsAtMs === null || row.accessEndsAtMs > now))
|
|
111
|
+
.sort((a, b) => a.accountSlot.localeCompare(b.accountSlot))[0];
|
|
112
|
+
const check = async (ctx, journal, signal) => {
|
|
113
|
+
const live = await inventory(ctx, journal, signal);
|
|
114
|
+
if ("reason" in live)
|
|
115
|
+
return waiting(live.reason);
|
|
116
|
+
const selected = candidate(live.slots, ctx.now().getTime());
|
|
117
|
+
if (proof &&
|
|
118
|
+
equal(live.identity, proof) &&
|
|
119
|
+
live.slots.some((row) => row.accountSlot === proof.slot &&
|
|
120
|
+
row.provider === proof.provider &&
|
|
121
|
+
row.declaredState === "active" &&
|
|
122
|
+
row.ownedByMe &&
|
|
123
|
+
!row.needsCredential &&
|
|
124
|
+
!row.walled &&
|
|
125
|
+
!row.quarantined &&
|
|
126
|
+
row.renewalStatus !== "canceled" &&
|
|
127
|
+
row.revokedAtMs === null &&
|
|
128
|
+
(row.accessEndsAtMs === null ||
|
|
129
|
+
row.accessEndsAtMs > ctx.now().getTime())) &&
|
|
130
|
+
proof.checkedAtMs <= ctx.now().getTime() + 5000 &&
|
|
131
|
+
proof.checkedAtMs >= ctx.now().getTime() - 30000)
|
|
132
|
+
return {
|
|
133
|
+
state: "done",
|
|
134
|
+
evidence: {
|
|
135
|
+
accountSlot: proof.slot,
|
|
136
|
+
provider: proof.provider,
|
|
137
|
+
checkedAt: new Date(proof.checkedAtMs).toISOString(),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
proof = null;
|
|
141
|
+
if (!live.slots.length)
|
|
142
|
+
return waiting("account_enrollment_required");
|
|
143
|
+
if (!selected)
|
|
144
|
+
return waiting(live.slots.some((row) => row.provider === "codex")
|
|
145
|
+
? "codex_provider_access_unverified"
|
|
146
|
+
: "account_provider_access_unverified");
|
|
147
|
+
if (attempted)
|
|
148
|
+
return waiting("account_provider_access_unverified");
|
|
149
|
+
const cfg = loadConfig(ctx.home);
|
|
150
|
+
if (!cfg?.user || !["owner", "admin"].includes(cfg.user.role))
|
|
151
|
+
return waiting("account_validation_admin_required");
|
|
152
|
+
return { state: "pending" };
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
check,
|
|
156
|
+
act: async (ctx, journal, external) => {
|
|
157
|
+
if (attempted)
|
|
158
|
+
return waiting("account_provider_access_unverified");
|
|
159
|
+
const live = await inventory(ctx, journal, external);
|
|
160
|
+
if ("reason" in live)
|
|
161
|
+
return waiting(live.reason);
|
|
162
|
+
const selected = candidate(live.slots, ctx.now().getTime());
|
|
163
|
+
if (!selected)
|
|
164
|
+
return waiting("account_provider_access_unverified");
|
|
165
|
+
const support = await verifyOnboardRoutes(ctx, journal, [{ method: "POST", path: validatePath }], external);
|
|
166
|
+
if ("reason" in support)
|
|
167
|
+
return waiting(support.reason);
|
|
168
|
+
const cfg = loadConfig(ctx.home);
|
|
169
|
+
if (!equal(binding(ctx, journal), live.identity) ||
|
|
170
|
+
!cfg?.user ||
|
|
171
|
+
!["owner", "admin"].includes(cfg.user.role))
|
|
172
|
+
return waiting("account_identity_unverified");
|
|
173
|
+
const expiry = cfg.auth
|
|
174
|
+
? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
|
|
175
|
+
: 0;
|
|
176
|
+
const bearer = cfg.key ||
|
|
177
|
+
(cfg.auth && Number.isFinite(expiry) && expiry > 30000
|
|
178
|
+
? cfg.auth.accessToken
|
|
179
|
+
: undefined);
|
|
180
|
+
if (!bearer)
|
|
181
|
+
return waiting("account_login_refresh_required");
|
|
182
|
+
input.message?.("Checking one stored Claude account with a one-token provider request. This may use Claude quota. Codex credentials are not refreshed. Phase eligibility is checked separately.");
|
|
183
|
+
// A display callback must not be able to switch the identity used for this request.
|
|
184
|
+
if (!equal(binding(ctx, journal), live.identity))
|
|
185
|
+
return waiting("account_identity_unverified");
|
|
186
|
+
const deadline = new AbortController();
|
|
187
|
+
const signal = external
|
|
188
|
+
? AbortSignal.any([external, deadline.signal])
|
|
189
|
+
: deadline.signal;
|
|
190
|
+
const timer = setTimeout(() => deadline.abort(), 15000);
|
|
191
|
+
let remove = () => { };
|
|
192
|
+
try {
|
|
193
|
+
return await new Promise((resolve) => {
|
|
194
|
+
const stopped = () => resolve(waiting(external?.aborted
|
|
195
|
+
? "interrupted"
|
|
196
|
+
: "account_validation_unavailable"));
|
|
197
|
+
if (signal.aborted) {
|
|
198
|
+
stopped();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
signal.addEventListener("abort", stopped, { once: true });
|
|
202
|
+
remove = () => signal.removeEventListener("abort", stopped);
|
|
203
|
+
Promise.resolve()
|
|
204
|
+
.then(async () => {
|
|
205
|
+
if (signal.aborted)
|
|
206
|
+
return waiting("interrupted");
|
|
207
|
+
// UI callbacks can queue a config change before this deferred send executes.
|
|
208
|
+
if (!equal(binding(ctx, journal), live.identity))
|
|
209
|
+
return waiting("account_identity_unverified");
|
|
210
|
+
attempted = true;
|
|
211
|
+
const response = await ctx.fetch(`${support.origin}/api/v1/coding-accounts/${encodeURIComponent(selected.accountSlot)}/validate`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
redirect: "error",
|
|
214
|
+
signal,
|
|
215
|
+
headers: {
|
|
216
|
+
authorization: `Bearer ${bearer}`,
|
|
217
|
+
accept: "application/json",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
if (response.status !== 200)
|
|
221
|
+
return waiting("account_validation_unavailable");
|
|
222
|
+
const body = object(await response.json());
|
|
223
|
+
if (signal.aborted ||
|
|
224
|
+
!equal(binding(ctx, journal), live.identity))
|
|
225
|
+
return waiting("account_identity_unverified");
|
|
226
|
+
const now = ctx.now().getTime();
|
|
227
|
+
if (!body ||
|
|
228
|
+
body.provider !== selected.provider ||
|
|
229
|
+
typeof body.checkedAtMs !== "number" ||
|
|
230
|
+
!Number.isSafeInteger(body.checkedAtMs) ||
|
|
231
|
+
body.checkedAtMs > now + 5000 ||
|
|
232
|
+
body.checkedAtMs < now - 30000 ||
|
|
233
|
+
!["working", "walled", "rejected", "inconclusive"].includes(String(body.result)))
|
|
234
|
+
return waiting("account_validation_unverified");
|
|
235
|
+
if (body.result !== "working")
|
|
236
|
+
return waiting(body.result === "walled"
|
|
237
|
+
? "account_provider_walled"
|
|
238
|
+
: body.result === "rejected"
|
|
239
|
+
? "account_provider_rejected"
|
|
240
|
+
: "account_provider_access_unverified");
|
|
241
|
+
proof = {
|
|
242
|
+
...live.identity,
|
|
243
|
+
slot: selected.accountSlot,
|
|
244
|
+
provider: selected.provider,
|
|
245
|
+
checkedAtMs: body.checkedAtMs,
|
|
246
|
+
};
|
|
247
|
+
input.message?.("Claude provider access was verified. This does not reserve a runner or prove that a phase can start.");
|
|
248
|
+
return check(ctx, journal, signal);
|
|
249
|
+
})
|
|
250
|
+
.then((result) => (signal.aborted ? stopped() : resolve(result)), () => stopped());
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
remove();
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Rule management is unbuilt. This disposition does not assess the customer's actual rules.
|
|
2
|
+
* Cloud readiness and first-ticket eligibility must still reject conflicting or unreadable rules.
|
|
3
|
+
*/
|
|
4
|
+
export function onboardAutomationManagementAdapter(input = {}) {
|
|
5
|
+
return {
|
|
6
|
+
check: async (_ctx, _journal, signal) => {
|
|
7
|
+
if (signal?.aborted)
|
|
8
|
+
return { state: "waiting", reason: "interrupted" };
|
|
9
|
+
input.message?.("Linear automation management is unavailable. Setup made no automation change. Cloud readiness still checks your existing rules.");
|
|
10
|
+
if (signal?.aborted)
|
|
11
|
+
return { state: "waiting", reason: "interrupted" };
|
|
12
|
+
return { state: "skipped", reason: "automation_management_unavailable" };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|