@catalyst-cloud/cli 0.14.6 → 0.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
@@ -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
+ }
@@ -0,0 +1,162 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, normalize } from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ function refused() {
5
+ throw new CliError("The staged installation plan could not be verified. Run setup again.", "onboard-bootstrap-plan", 12);
6
+ }
7
+ function record(value) {
8
+ if (value === null || typeof value !== "object" || Array.isArray(value))
9
+ return refused();
10
+ return value;
11
+ }
12
+ function exactKeys(value, keys) {
13
+ if (Object.keys(value).length !== keys.length ||
14
+ keys.some((key) => !Object.hasOwn(value, key)))
15
+ refused();
16
+ }
17
+ function text(value) {
18
+ if (typeof value !== "string" ||
19
+ value.length === 0 ||
20
+ value.length > 4096 ||
21
+ /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value))
22
+ refused();
23
+ return value;
24
+ }
25
+ function path(value) {
26
+ const result = text(value);
27
+ if (!isAbsolute(result) || normalize(result) !== result || result === "/")
28
+ refused();
29
+ return result;
30
+ }
31
+ function origin(value) {
32
+ const result = text(value);
33
+ let url;
34
+ try {
35
+ url = new URL(result);
36
+ }
37
+ catch {
38
+ return refused();
39
+ }
40
+ if (url.protocol !== "https:" ||
41
+ url.origin !== result ||
42
+ url.username ||
43
+ url.password ||
44
+ url.search ||
45
+ url.hash)
46
+ refused();
47
+ return result;
48
+ }
49
+ /** Copies a closed DTO before freezing it; a mutable caller object cannot alter the displayed plan. */
50
+ export function parseBootstrapPlan(input) {
51
+ const value = record(input);
52
+ exactKeys(value, [
53
+ "schema",
54
+ "home",
55
+ "origin",
56
+ "platform",
57
+ "arch",
58
+ "cliPath",
59
+ "skillsPath",
60
+ "statePath",
61
+ "dailyUpdate",
62
+ "artifacts",
63
+ ]);
64
+ if (value.schema !== 1 ||
65
+ (value.platform !== "darwin" && value.platform !== "linux") ||
66
+ (value.arch !== "arm64" && value.arch !== "x64") ||
67
+ typeof value.dailyUpdate !== "boolean" ||
68
+ !Array.isArray(value.artifacts) ||
69
+ value.artifacts.length !== 4)
70
+ refused();
71
+ const kinds = new Set();
72
+ const artifacts = value.artifacts
73
+ .map((input) => {
74
+ const item = record(input);
75
+ exactKeys(item, ["kind", "version", "sha256"]);
76
+ if (item.kind !== "runtime" &&
77
+ item.kind !== "cli" &&
78
+ item.kind !== "skills" &&
79
+ item.kind !== "installer")
80
+ refused();
81
+ if (kinds.has(item.kind))
82
+ refused();
83
+ kinds.add(item.kind);
84
+ const version = text(item.version);
85
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/.test(version) ||
86
+ typeof item.sha256 !== "string" ||
87
+ !/^[a-f0-9]{64}$/.test(item.sha256))
88
+ refused();
89
+ return Object.freeze({ kind: item.kind, version, sha256: item.sha256 });
90
+ })
91
+ .sort((a, b) => a.kind.localeCompare(b.kind));
92
+ const home = path(value.home), cliPath = path(value.cliPath), skillsPath = path(value.skillsPath), statePath = path(value.statePath);
93
+ if (new Set([home, cliPath, skillsPath, statePath]).size !== 4)
94
+ refused();
95
+ return Object.freeze({
96
+ schema: 1,
97
+ home,
98
+ origin: origin(value.origin),
99
+ platform: value.platform,
100
+ arch: value.arch,
101
+ cliPath,
102
+ skillsPath,
103
+ statePath,
104
+ dailyUpdate: value.dailyUpdate,
105
+ artifacts: Object.freeze(artifacts),
106
+ });
107
+ }
108
+ export function bootstrapPlanHash(plan) {
109
+ return createHash("sha256")
110
+ .update(JSON.stringify(parseBootstrapPlan(plan)))
111
+ .digest("hex");
112
+ }
113
+ export function bootstrapPlanLines(plan) {
114
+ return [
115
+ `Install on ${plan.platform}/${plan.arch} for ${plan.home}`,
116
+ `Catalyst cloud: ${plan.origin}`,
117
+ `Command: ${plan.cliPath}`,
118
+ `Skills: ${plan.skillsPath}`,
119
+ `Setup record: ${plan.statePath}`,
120
+ `Daily update: ${plan.dailyUpdate ? "schedule after installation" : "not selected"}`,
121
+ ...plan.artifacts.map((artifact) => `${artifact.kind} ${artifact.version} (SHA-256 ${artifact.sha256})`),
122
+ ];
123
+ }
124
+ /** A prepared native continuation is consumed before entering the first persistent machine stage.
125
+ * Retry after a partial install constructs a new preview from the actual machine and journal.
126
+ */
127
+ export function createBootstrapPreview(input, ports) {
128
+ const plan = parseBootstrapPlan(input);
129
+ const planHash = bootstrapPlanHash(plan);
130
+ let consumed = false;
131
+ return Object.freeze({
132
+ plan,
133
+ async recheck() {
134
+ if (consumed)
135
+ refused();
136
+ await ports.recheck();
137
+ },
138
+ assertCurrent() {
139
+ if (consumed)
140
+ refused();
141
+ ports.assertCurrent();
142
+ },
143
+ async continue(approved, signal) {
144
+ if (consumed ||
145
+ signal.aborted ||
146
+ approved.planHash !== planHash ||
147
+ bootstrapPlanHash(approved.plan) !== planHash ||
148
+ approved.origin !== plan.origin ||
149
+ !approved.account ||
150
+ !approved.person ||
151
+ !approved.runId ||
152
+ !Number.isSafeInteger(approved.ownerPid) ||
153
+ approved.ownerPid < 1 ||
154
+ !approved.ownerToken ||
155
+ !isAbsolute(approved.lockPath))
156
+ refused();
157
+ consumed = true;
158
+ ports.assertCurrent();
159
+ await ports.continue(approved, signal);
160
+ },
161
+ });
162
+ }
@@ -119,7 +119,7 @@ async function read(ctx, journal, timeoutMs, external) {
119
119
  !["GET", "POST"].includes(row.method) ||
120
120
  typeof row.path !== "string" ||
121
121
  row.path.length > 240 ||
122
- !/^\/(?:api\/v1\/|connect\/|me\/)[A-Za-z0-9_/-]+$/.test(row.path) ||
122
+ !(/^\/(?:api\/v1\/|connect\/|me\/)[A-Za-z0-9_/-]+$/.test(row.path) || row.path === "/api/v1/coding-accounts/:slot/validate") ||
123
123
  row.personalBearer !== true)
124
124
  return { reason: "onboarding_capability_unavailable", origin };
125
125
  const key = `${row.method} ${row.path}`;
@@ -142,6 +142,17 @@ async function read(ctx, journal, timeoutMs, external) {
142
142
  remove();
143
143
  }
144
144
  }
145
+ /** Read current route support for context checks and selected writes without refreshing a token. */
146
+ export async function verifyOnboardRoutes(ctx, binding, requirements, signal, timeoutMs = 30_000) {
147
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
148
+ throw new Error("onboarding_capability_timeout_invalid");
149
+ const result = await read(ctx, binding, timeoutMs, signal);
150
+ if ("reason" in result)
151
+ return result;
152
+ if (requirements.some((route) => !result.capabilities.routes.has(routeKey(route))))
153
+ return { reason: "cloud_capability_unavailable", origin: result.origin };
154
+ return { origin: result.origin };
155
+ }
145
156
  /** Guard both the observation and action before their first endpoint request. A route's real
146
157
  * refusal is preserved only after the same-account cloud advertised personal bearer support. */
147
158
  export function guardOnboardCapabilities(adapter, options) {
@@ -149,12 +160,9 @@ export function guardOnboardCapabilities(adapter, options) {
149
160
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
150
161
  throw new Error("onboarding_capability_timeout_invalid");
151
162
  const run = async (ctx, journal, signal, requirements, work) => {
152
- const advertised = await read(ctx, journal, timeoutMs, signal);
153
- if ("reason" in advertised ||
154
- requirements.some((route) => !advertised.capabilities.routes.has(routeKey(route)))) {
155
- const reason = "reason" in advertised
156
- ? advertised.reason
157
- : "cloud_capability_unavailable";
163
+ const advertised = await verifyOnboardRoutes(ctx, journal, requirements, signal, timeoutMs);
164
+ if ("reason" in advertised) {
165
+ const reason = advertised.reason;
158
166
  const url = advertised.origin
159
167
  ? `${advertised.origin}${safePages[options.fallback]}`
160
168
  : undefined;