@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +5 -3
  3. package/dist/args.js +51 -13
  4. package/dist/cli.js +58 -10
  5. package/dist/config.js +16 -3
  6. package/dist/daemon-authority-lifetime.js +171 -0
  7. package/dist/daemon-authority.js +216 -0
  8. package/dist/daemon-cache.js +348 -0
  9. package/dist/daemon-store.js +370 -0
  10. package/dist/daemon-supervisor.js +460 -0
  11. package/dist/env/onboard-draft-store.js +243 -0
  12. package/dist/env/onboard-draft.js +33 -0
  13. package/dist/env/selected-import-writer.js +163 -0
  14. package/dist/env/settings-authority.js +203 -0
  15. package/dist/events-cloud-tail.js +134 -0
  16. package/dist/events-cloud.js +500 -0
  17. package/dist/events.js +13 -4
  18. package/dist/oauth.js +125 -33
  19. package/dist/onboard-accounts.js +259 -0
  20. package/dist/onboard-automation-management.js +15 -0
  21. package/dist/onboard-bootstrap.js +162 -0
  22. package/dist/onboard-capabilities.js +15 -7
  23. package/dist/onboard-capacity.js +226 -0
  24. package/dist/onboard-existing.js +106 -32
  25. package/dist/onboard-file-snapshot.js +45 -0
  26. package/dist/onboard-login-candidate.js +261 -0
  27. package/dist/onboard-projects.js +350 -0
  28. package/dist/onboard-repositories.js +147 -37
  29. package/dist/onboard-runtime.js +104 -9
  30. package/dist/onboard-settings-context.js +141 -0
  31. package/dist/onboard-settings-import.js +127 -0
  32. package/dist/onboard-settings.js +234 -0
  33. package/dist/onboard-ui.js +99 -11
  34. package/dist/onboard-workflow.js +316 -0
  35. package/dist/onboard.js +359 -27
  36. package/dist/owned-fetch.js +182 -0
  37. package/dist/query.js +77 -27
  38. package/dist/ready.js +143 -35
  39. package/dist/replica.js +11 -4
  40. package/dist/sdk.js +33 -0
  41. package/dist/watch.js +2 -2
  42. package/package.json +3 -2
  43. package/skills/catalyst-github/SKILL.md +2 -2
  44. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  45. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  46. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  47. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  48. package/skills/catalyst-linear/SKILL.md +3 -3
  49. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  50. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  51. package/skills/catalyst-onboard/SKILL.md +5 -4
  52. package/skills/catalyst-onboard/references/local-sync.md +3 -1
  53. package/skills/run-this-project/SKILL.md +1 -1
  54. package/skills/unstick/SKILL.md +1 -1
  55. package/skills/what-needs-me/SKILL.md +1 -1
  56. package/skills/what-this-repo-needs/SKILL.md +1 -1
  57. package/skills/whats-happening/SKILL.md +2 -2
  58. package/skills/whats-happening/references/status-reply.md +1 -1
  59. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  60. package/vendor/settings/index.d.ts +4 -0
  61. package/vendor/settings/index.js +2937 -0
  62. 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) && typeof parsed.fetchedAt === "number" && typeof parsed.baseUrl === "string")
23
+ if (isDiscovery(parsed.doc) &&
24
+ typeof parsed.fetchedAt === "number" &&
25
+ typeof parsed.baseUrl === "string")
24
26
  return parsed;
25
27
  }
26
28
  catch {
@@ -32,16 +34,24 @@ function isDiscovery(v) {
32
34
  if (typeof v !== "object" || v === null)
33
35
  return false;
34
36
  const d = v;
35
- return ["clientId", "issuer", "deviceAuthorizationUrl", "tokenUrl", "jwksUrl"].every((k) => typeof d[k] === "string");
37
+ return [
38
+ "clientId",
39
+ "issuer",
40
+ "deviceAuthorizationUrl",
41
+ "tokenUrl",
42
+ "jwksUrl",
43
+ ].every((k) => typeof d[k] === "string");
36
44
  }
37
45
  export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
38
- const cancelled = () => { if (opts.signal?.aborted)
39
- throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
46
+ const cancelled = () => {
47
+ if (opts.signal?.aborted)
48
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
49
+ };
40
50
  cancelled();
41
51
  const base = normalizeBaseUrl(baseUrl);
42
52
  const nowMs = ctx.now().getTime();
43
53
  const fresh = (e) => e !== null && e.baseUrl === base && nowMs - e.fetchedAt < DISCOVERY_TTL_MS;
44
- if (!opts.refresh) {
54
+ if (!opts.refresh && opts.cache !== false) {
45
55
  if (fresh(memo))
46
56
  return memo.doc;
47
57
  const disk = readDiscoveryDisk(ctx.home);
@@ -51,7 +61,9 @@ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
51
61
  }
52
62
  }
53
63
  const url = `${base}/api/v1/auth/cli`;
54
- const res = await safeFetch(ctx, url, { headers: { accept: "application/json" } });
64
+ const res = await safeFetch(ctx, url, {
65
+ headers: { accept: "application/json" },
66
+ });
55
67
  if (!res.ok) {
56
68
  throw new CliError(`could not read the login discovery document (${res.status}) at ${url} — is this a Catalyst Cloud origin?`, "discovery-failed");
57
69
  }
@@ -60,6 +72,8 @@ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
60
72
  if (!isDiscovery(doc))
61
73
  throw new CliError(`the login discovery document at ${url} is not the expected shape`, "discovery-shape");
62
74
  const entry = { baseUrl: base, fetchedAt: nowMs, doc };
75
+ if (opts.cache === false)
76
+ return doc;
63
77
  memo = entry;
64
78
  try {
65
79
  writeFileSync(discoveryCachePathFor(ctx.home), JSON.stringify(entry, null, 2) + "\n");
@@ -83,17 +97,28 @@ export const MAX_DEVICE_CODES = 3;
83
97
  * expires, a fresh one is minted and printed, up to MAX_DEVICE_CODES in all.
84
98
  */
85
99
  export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
86
- const cancelled = () => { if (deps.signal?.aborted)
87
- throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
100
+ const cancelled = () => {
101
+ if (deps.signal?.aborted)
102
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
103
+ };
88
104
  cancelled();
89
105
  if (deps.signal) {
90
106
  const fetchImpl = ctx.fetch;
91
107
  const signal = deps.signal;
92
- ctx = { ...ctx, fetch: ((input, init) => fetchImpl(input, {
93
- ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
94
- })) };
108
+ ctx = {
109
+ ...ctx,
110
+ fetch: ((input, init) => fetchImpl(input, {
111
+ ...init,
112
+ signal: init?.signal
113
+ ? AbortSignal.any([signal, init.signal])
114
+ : signal,
115
+ })),
116
+ };
95
117
  }
96
- const discovery = await fetchDiscovery(ctx, baseUrl, { signal: deps.signal });
118
+ const discovery = await fetchDiscovery(ctx, baseUrl, {
119
+ signal: deps.signal,
120
+ cache: deps.discoveryCache,
121
+ });
97
122
  cancelled();
98
123
  // The browser rule is decided on the first code; a later code re-opens it only when the first did.
99
124
  let browserOpened = false;
@@ -120,7 +145,9 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
120
145
  }
121
146
  ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
122
147
  const poll = () => pollDeviceCode(ctx, discovery, auth, deps.sleep, deps.signal);
123
- const tokens = deps.waitForApproval ? await deps.waitForApproval(poll) : await poll();
148
+ const tokens = deps.waitForApproval
149
+ ? await deps.waitForApproval(poll)
150
+ : await poll();
124
151
  cancelled();
125
152
  if (tokens !== "expired")
126
153
  return tokensToAuth(ctx, tokens.access_token, tokens.refresh_token);
@@ -137,8 +164,10 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
137
164
  async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
138
165
  const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
139
166
  const signal = external ? AbortSignal.any([external, deadline]) : deadline;
140
- const cancelled = () => { if (external?.aborted)
141
- throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
167
+ const cancelled = () => {
168
+ if (external?.aborted)
169
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
170
+ };
142
171
  let intervalMs = Math.max(1, auth.interval) * 1_000;
143
172
  for (;;) {
144
173
  cancelled();
@@ -158,7 +187,15 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
158
187
  return "expired";
159
188
  let res;
160
189
  try {
161
- res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, signal);
190
+ res = await safeFetch(ctx, discovery.tokenUrl, {
191
+ method: "POST",
192
+ headers: formHeaders(),
193
+ body: form({
194
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
195
+ device_code: auth.device_code,
196
+ client_id: discovery.clientId,
197
+ }),
198
+ }, signal);
162
199
  cancelled();
163
200
  }
164
201
  catch (err) {
@@ -172,10 +209,14 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
172
209
  }
173
210
  if (res.ok) {
174
211
  const pair = (await parseJson(res, discovery.tokenUrl));
175
- if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
212
+ if (typeof pair.access_token !== "string" ||
213
+ typeof pair.refresh_token !== "string") {
176
214
  throw new CliError("the login token response was missing a token", "login-shape");
177
215
  }
178
- return { access_token: pair.access_token, refresh_token: pair.refresh_token };
216
+ return {
217
+ access_token: pair.access_token,
218
+ refresh_token: pair.refresh_token,
219
+ };
179
220
  }
180
221
  const err = await oauthError(res, discovery.tokenUrl);
181
222
  if (err === "authorization_pending")
@@ -192,7 +233,10 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
192
233
  // Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
193
234
  if (res.status === 408 || res.status === 429 || res.status >= 500) {
194
235
  const retryAfter = Number(res.headers.get("retry-after"));
195
- intervalMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : backoff(intervalMs);
236
+ intervalMs =
237
+ Number.isFinite(retryAfter) && retryAfter > 0
238
+ ? retryAfter * 1_000
239
+ : backoff(intervalMs);
196
240
  continue;
197
241
  }
198
242
  throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
@@ -202,18 +246,35 @@ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
202
246
  function devicePollDelay(ms, signal, sleep) {
203
247
  return new Promise((resolve, reject) => {
204
248
  let timer;
205
- const clean = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); };
206
- const abort = () => { clean(); reject(new Error("device_poll_stopped")); };
249
+ const clean = () => {
250
+ clearTimeout(timer);
251
+ signal.removeEventListener("abort", abort);
252
+ };
253
+ const abort = () => {
254
+ clean();
255
+ reject(new Error("device_poll_stopped"));
256
+ };
207
257
  if (signal.aborted) {
208
258
  abort();
209
259
  return;
210
260
  }
211
261
  signal.addEventListener("abort", abort, { once: true });
212
262
  if (sleep) {
213
- Promise.resolve().then(() => sleep(ms)).then(() => { clean(); resolve(); }, error => { clean(); reject(error); });
263
+ Promise.resolve()
264
+ .then(() => sleep(ms))
265
+ .then(() => {
266
+ clean();
267
+ resolve();
268
+ }, (error) => {
269
+ clean();
270
+ reject(error);
271
+ });
214
272
  }
215
273
  else
216
- timer = setTimeout(() => { clean(); resolve(); }, ms);
274
+ timer = setTimeout(() => {
275
+ clean();
276
+ resolve();
277
+ }, ms);
217
278
  });
218
279
  }
219
280
  /** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
@@ -243,18 +304,27 @@ function persistRotation(ctx, cfg, sessionId, rotated) {
243
304
  writeConfig(ctx.home, target); // atomic; the rotated pair survives a crash
244
305
  }
245
306
  async function deviceAuthorize(ctx, discovery) {
246
- const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, { method: "POST", headers: formHeaders(), body: form({ client_id: discovery.clientId }) });
307
+ const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, {
308
+ method: "POST",
309
+ headers: formHeaders(),
310
+ body: form({ client_id: discovery.clientId }),
311
+ });
247
312
  if (!res.ok)
248
313
  throw new CliError(`could not start login (${res.status}) at ${discovery.deviceAuthorizationUrl}`, "login-failed");
249
314
  const d = (await parseJson(res, discovery.deviceAuthorizationUrl));
250
- if (typeof d.device_code !== "string" || typeof d.user_code !== "string" || typeof d.verification_uri !== "string") {
315
+ if (typeof d.device_code !== "string" ||
316
+ typeof d.user_code !== "string" ||
317
+ typeof d.verification_uri !== "string") {
251
318
  throw new CliError("the login authorization response was not the expected shape", "login-shape");
252
319
  }
253
320
  return {
254
321
  device_code: d.device_code,
255
322
  user_code: d.user_code,
256
323
  verification_uri: d.verification_uri,
257
- ...(typeof d.verification_uri_complete === "string" && d.verification_uri_complete !== "" ? { verification_uri_complete: d.verification_uri_complete } : {}),
324
+ ...(typeof d.verification_uri_complete === "string" &&
325
+ d.verification_uri_complete !== ""
326
+ ? { verification_uri_complete: d.verification_uri_complete }
327
+ : {}),
258
328
  expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
259
329
  interval: typeof d.interval === "number" ? d.interval : 5,
260
330
  };
@@ -293,10 +363,19 @@ async function refreshAndPersist(ctx, cfg, deps) {
293
363
  for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
294
364
  if (attempt > 0)
295
365
  await sleep(REFRESH_BASE_BACKOFF_MS * 2 ** (attempt - 1));
296
- const res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: discovery.clientId }) });
366
+ const res = await safeFetch(ctx, discovery.tokenUrl, {
367
+ method: "POST",
368
+ headers: formHeaders(),
369
+ body: form({
370
+ grant_type: "refresh_token",
371
+ refresh_token: current.refreshToken,
372
+ client_id: discovery.clientId,
373
+ }),
374
+ });
297
375
  if (res.ok) {
298
376
  const pair = (await parseJson(res, discovery.tokenUrl));
299
- if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string")
377
+ if (typeof pair.access_token !== "string" ||
378
+ typeof pair.refresh_token !== "string")
300
379
  throw new CliError("the refresh response was missing a token", "session-refresh-shape");
301
380
  const rotated = tokensToAuth(ctx, pair.access_token, pair.refresh_token, current.sessionId);
302
381
  cfg.auth = rotated; // this process keeps using its own refreshed token
@@ -326,9 +405,17 @@ export function authStrategyFor(ctx, cfg) {
326
405
  // ── helpers ──────────────────────────────────────────────────────────────────────────────────────
327
406
  function tokensToAuth(ctx, accessToken, refreshToken, fallbackSid) {
328
407
  const claims = decodeJwtClaims(accessToken);
329
- const exp = typeof claims.exp === "number" ? claims.exp * 1_000 : ctx.now().getTime() + 15 * 60_000;
408
+ const exp = typeof claims.exp === "number"
409
+ ? claims.exp * 1_000
410
+ : ctx.now().getTime() + 15 * 60_000;
330
411
  const sid = typeof claims.sid === "string" ? claims.sid : (fallbackSid ?? "");
331
- return { kind: "oauth", accessToken, refreshToken, expiresAt: new Date(exp).toISOString(), sessionId: sid };
412
+ return {
413
+ kind: "oauth",
414
+ accessToken,
415
+ refreshToken,
416
+ expiresAt: new Date(exp).toISOString(),
417
+ sessionId: sid,
418
+ };
332
419
  }
333
420
  function decodeJwtClaims(token) {
334
421
  const parts = token.split(".");
@@ -342,13 +429,18 @@ function decodeJwtClaims(token) {
342
429
  }
343
430
  }
344
431
  function formHeaders() {
345
- return { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
432
+ return {
433
+ "content-type": "application/x-www-form-urlencoded",
434
+ accept: "application/json",
435
+ };
346
436
  }
347
437
  function form(fields) {
348
438
  return new URLSearchParams(fields).toString();
349
439
  }
350
440
  async function safeFetch(ctx, url, init, signal) {
351
- const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
441
+ const sig = signal
442
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
443
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
352
444
  try {
353
445
  return await ctx.fetch(url, { ...init, signal: sig });
354
446
  }
@@ -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
+ }