@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
@@ -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;
@@ -0,0 +1,226 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { verifyOnboardRoutes } from "./onboard-capabilities.js";
3
+ import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
+ import { readOnboardRepositoryInventory, selectedOnboardRepositories, } from "./onboard-repositories.js";
5
+ const route = "/api/v1/me/runner-capacity";
6
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? value
8
+ : null;
9
+ const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
10
+ const text = (value) => typeof value === "string" &&
11
+ value.length > 0 &&
12
+ value.length <= 256 &&
13
+ !Array.from(value).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
14
+ const waiting = (reason) => ({
15
+ state: "waiting",
16
+ reason,
17
+ });
18
+ function snapshot(value, now) {
19
+ const body = object(value);
20
+ if (!body ||
21
+ body.status !== "ok" ||
22
+ body.scope !== "mapped-team-defaults" ||
23
+ !integer(body.observedAtMs) ||
24
+ body.observedAtMs < now - 30_000 ||
25
+ body.observedAtMs > now + 5_000 ||
26
+ !Array.isArray(body.buckets) ||
27
+ body.buckets.length > 500)
28
+ return null;
29
+ const buckets = [], seen = new Set(), seenTeam = new Set();
30
+ let totalTeams = 0;
31
+ for (const value of body.buckets) {
32
+ const b = object(value);
33
+ if (!b ||
34
+ !text(b.repoId) ||
35
+ seen.has(b.repoId) ||
36
+ !integer(b.effectiveLimit) ||
37
+ !integer(b.occupiedUnits) ||
38
+ !Array.isArray(b.teams) ||
39
+ !b.teams.length ||
40
+ b.teams.length > 500)
41
+ return null;
42
+ seen.add(b.repoId);
43
+ totalTeams += b.teams.length;
44
+ if (totalTeams > 1000)
45
+ return null;
46
+ const teams = [];
47
+ let minimum = Number.MAX_SAFE_INTEGER;
48
+ for (const value of b.teams) {
49
+ const t = object(value);
50
+ if (!t ||
51
+ !text(t.teamKey) ||
52
+ seenTeam.has(t.teamKey) ||
53
+ !integer(t.defaultLimit) ||
54
+ t.defaultLimit < 1 ||
55
+ !(t.configuredLimit === null ||
56
+ (integer(t.configuredLimit) && t.configuredLimit > 0)) ||
57
+ typeof t.paused !== "boolean" ||
58
+ typeof t.admissionEnabled !== "boolean" ||
59
+ !integer(t.effectiveTeamLimit))
60
+ return null;
61
+ const configured = t.configuredLimit === null ? t.defaultLimit : t.configuredLimit;
62
+ if (t.source !== (t.configuredLimit === null ? "default" : "configured") ||
63
+ t.effectiveTeamLimit !== (t.paused ? 0 : configured))
64
+ return null;
65
+ const remaining = t.admissionEnabled
66
+ ? Math.max(0, b.effectiveLimit - b.occupiedUnits)
67
+ : null;
68
+ if (t.remainingUnits !== remaining)
69
+ return null;
70
+ seenTeam.add(t.teamKey);
71
+ minimum = Math.min(minimum, t.effectiveTeamLimit);
72
+ teams.push({ key: t.teamKey, admission: t.admissionEnabled, remaining });
73
+ }
74
+ if (minimum !== b.effectiveLimit)
75
+ return null;
76
+ buckets.push({
77
+ repo: b.repoId,
78
+ limit: b.effectiveLimit,
79
+ occupied: b.occupiedUnits,
80
+ teams,
81
+ });
82
+ }
83
+ return { at: body.observedAtMs, buckets };
84
+ }
85
+ function binding(ctx, journal) {
86
+ const cfg = loadConfig(ctx.home);
87
+ if (!cfg?.user ||
88
+ !["owner", "admin"].includes(cfg.user.role) ||
89
+ cfg.account !== (journal.account ?? journal.tenant) ||
90
+ cfg.user.id !== journal.membershipId ||
91
+ !journal.baseUrl ||
92
+ normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
93
+ return null;
94
+ return JSON.stringify([
95
+ cfg.account,
96
+ cfg.user.id,
97
+ cfg.user.role,
98
+ normalizeBaseUrl(cfg.baseUrl),
99
+ cfg.permissions,
100
+ cfg.key ?? cfg.auth?.sessionId,
101
+ ]);
102
+ }
103
+ const selection = (journal) => JSON.stringify([
104
+ selectedOnboardTeam(journal),
105
+ selectedOnboardRepositories(journal),
106
+ ]);
107
+ async function liveTeamKey(ctx, teamId, signal) {
108
+ const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
109
+ if ("reason" in read)
110
+ return null;
111
+ const body = object(read.body), live = object(body?.liveTeamRead);
112
+ if (!body ||
113
+ !Array.isArray(body.teams) ||
114
+ body.teams.length > 1000 ||
115
+ (live?.error !== undefined && live.error !== null))
116
+ return null;
117
+ const ids = new Set(), keys = new Set();
118
+ let selected = null;
119
+ for (const value of body.teams) {
120
+ const team = object(value);
121
+ if (!team ||
122
+ !text(team.teamId) ||
123
+ !text(team.teamKey) ||
124
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(team.teamKey) ||
125
+ ids.has(team.teamId) ||
126
+ keys.has(team.teamKey))
127
+ return null;
128
+ ids.add(team.teamId);
129
+ keys.add(team.teamKey);
130
+ if (team.teamId === teamId)
131
+ selected = team.teamKey;
132
+ }
133
+ return selected;
134
+ }
135
+ async function liveIdentity(ctx, journal, signal) {
136
+ const read = await readExistingOnboardJson(ctx, "/api/v1/me", signal);
137
+ if ("reason" in read)
138
+ return false;
139
+ const me = object(read.body), user = object(me?.user), cfg = loadConfig(ctx.home);
140
+ return (!!cfg?.user &&
141
+ me?.account === (journal.account ?? journal.tenant) &&
142
+ user?.id === cfg.user.id &&
143
+ user.role === cfg.user.role &&
144
+ ["owner", "admin"].includes(cfg.user.role) &&
145
+ me?.principal === cfg.principal);
146
+ }
147
+ /** Fresh advisory for the selected team's DEFAULT mapped repo. It grants no lease and does not
148
+ * establish provider eligibility, host liveness or selected-repo overrides. No extra question. */
149
+ export function onboardCapacityAdapter(input = {}) {
150
+ return {
151
+ check: async (ctx, journal, external) => {
152
+ if (external?.aborted)
153
+ return waiting("interrupted");
154
+ const expected = binding(ctx, journal), selected = selection(journal), teamId = selectedOnboardTeam(journal), repos = selectedOnboardRepositories(journal);
155
+ if (!expected)
156
+ return waiting("capacity_identity_unverified");
157
+ if (!teamId || repos.length !== 1 || repos[0].teamId !== teamId)
158
+ return waiting("capacity_context_unverified");
159
+ const controller = new AbortController(), signal = external
160
+ ? AbortSignal.any([external, controller.signal])
161
+ : controller.signal;
162
+ const timer = setTimeout(() => controller.abort(), 30_000);
163
+ const current = () => !signal.aborted &&
164
+ binding(ctx, journal) === expected &&
165
+ selection(journal) === selected;
166
+ const pause = (reason) => waiting(external?.aborted
167
+ ? "interrupted"
168
+ : controller.signal.aborted
169
+ ? "capacity_unavailable"
170
+ : reason);
171
+ try {
172
+ const support = await verifyOnboardRoutes(ctx, journal, [{ method: "GET", path: route }], signal);
173
+ if ("reason" in support)
174
+ return pause(support.reason);
175
+ if (!current() ||
176
+ !(await liveIdentity(ctx, journal, signal)) ||
177
+ !current())
178
+ return pause("capacity_identity_unverified");
179
+ const teamKey = await liveTeamKey(ctx, teamId, signal);
180
+ if (!teamKey || !current())
181
+ return pause("capacity_context_unverified");
182
+ const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
183
+ if ("reason" in inventory ||
184
+ !current() ||
185
+ !inventory.repositories.some((r) => r.repoId === repos[0].repoId &&
186
+ r.teamId === teamId &&
187
+ r.owner.toLowerCase() === repos[0].owner.toLowerCase() &&
188
+ r.name.toLowerCase() === repos[0].name.toLowerCase()))
189
+ return pause("capacity_context_unverified");
190
+ const read = await readExistingOnboardJson(ctx, route, signal);
191
+ if ("reason" in read || !current())
192
+ return pause("capacity_unavailable");
193
+ if (!(await liveIdentity(ctx, journal, signal)) || !current())
194
+ return pause("capacity_identity_unverified");
195
+ const observed = snapshot(read.body, ctx.now().getTime());
196
+ const bucket = observed?.buckets.find((b) => b.repo === repos[0].repoId), mapped = bucket?.teams.find((t) => t.key === teamKey);
197
+ if (!observed || !bucket || !mapped)
198
+ return pause("capacity_context_unverified");
199
+ if (!mapped.admission || mapped.remaining === null)
200
+ return pause("capacity_admission_unverified");
201
+ if (mapped.remaining === 0)
202
+ return pause("capacity_currently_full");
203
+ input.message?.(`Runner capacity observed: ${bucket.occupied} of ${bucket.limit} units in use. Starting work will check admission again.`);
204
+ if (!current())
205
+ return pause("capacity_identity_unverified");
206
+ return {
207
+ state: "done",
208
+ evidence: {
209
+ repoId: bucket.repo,
210
+ effectiveLimit: bucket.limit,
211
+ occupiedUnits: bucket.occupied,
212
+ remainingUnits: mapped.remaining,
213
+ observedAtMs: observed.at,
214
+ advisory: true,
215
+ },
216
+ };
217
+ }
218
+ catch {
219
+ return pause(external?.aborted ? "interrupted" : "capacity_unavailable");
220
+ }
221
+ finally {
222
+ clearTimeout(timer);
223
+ }
224
+ },
225
+ };
226
+ }
@@ -1,12 +1,17 @@
1
1
  import { loadConfig, normalizeBaseUrl } from "./config.js";
2
2
  import { CliError } from "./errors.js";
3
3
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
4
- const waiting = (reason) => ({ state: "waiting", reason });
5
- const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
4
+ const waiting = (reason) => ({
5
+ state: "waiting",
6
+ reason,
7
+ });
8
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : null;
6
11
  const label = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
7
12
  /** A receipt keeps an explicit selection, never the team used to inspect the tenant grant. */
8
13
  export function selectedOnboardTeam(journal) {
9
- const step = journal?.steps.find(row => row.id === "linear.team");
14
+ const step = journal?.steps.find((row) => row.id === "linear.team");
10
15
  const id = step?.state === "done" ? step.evidence?.team : undefined;
11
16
  return typeof id === "string" && idPattern.test(id) ? id : undefined;
12
17
  }
@@ -15,15 +20,23 @@ export async function readExistingOnboardJson(ctx, path, external) {
15
20
  const cfg = loadConfig(ctx.home);
16
21
  if (!cfg?.user)
17
22
  return { reason: "personal_login_required" };
23
+ const personId = cfg.user.id;
18
24
  // Identity verification refreshes before adapters run. These bounded reads never rotate a grant
19
25
  // that could finish after the engine has released its lock.
20
- const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
21
- const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
26
+ const expiry = cfg.auth
27
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
28
+ : 0;
29
+ const bearer = cfg.key ||
30
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
31
+ ? cfg.auth.accessToken
32
+ : undefined);
22
33
  if (!bearer)
23
34
  return { reason: "team_read_login_refresh_required" };
24
35
  const deadline = new AbortController();
25
36
  const timer = setTimeout(() => deadline.abort(), 30_000);
26
- const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
37
+ const signal = external
38
+ ? AbortSignal.any([external, deadline.signal])
39
+ : deadline.signal;
27
40
  let remove = () => { };
28
41
  try {
29
42
  return await new Promise((resolve, reject) => {
@@ -36,17 +49,32 @@ export async function readExistingOnboardJson(ctx, path, external) {
36
49
  }
37
50
  signal.addEventListener("abort", stopped, { once: true });
38
51
  remove = () => signal.removeEventListener("abort", stopped);
39
- Promise.resolve().then(async () => {
52
+ Promise.resolve()
53
+ .then(async () => {
40
54
  if (signal.aborted)
41
55
  return { reason: "team_read_unavailable" };
42
56
  const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
43
- headers: { authorization: `Bearer ${bearer}`, accept: "application/json" }, signal,
57
+ redirect: "error",
58
+ headers: {
59
+ authorization: `Bearer ${bearer}`,
60
+ accept: "application/json",
61
+ },
62
+ signal,
44
63
  });
45
64
  if (response.status !== 200)
46
65
  return { reason: "team_read_unavailable" };
47
66
  const body = await response.json();
67
+ const current = loadConfig(ctx.home);
68
+ if (!current?.user ||
69
+ current.account !== cfg.account ||
70
+ current.user.id !== personId ||
71
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
72
+ return { reason: "team_read_identity_unverified" };
48
73
  return { body };
49
- }).then(value => signal.aborted ? stopped() : resolve(value), () => signal.aborted ? stopped() : resolve({ reason: "team_read_unavailable" }));
74
+ })
75
+ .then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
76
+ ? stopped()
77
+ : resolve({ reason: "team_read_unavailable" }));
50
78
  });
51
79
  }
52
80
  finally {
@@ -54,7 +82,7 @@ export async function readExistingOnboardJson(ctx, path, external) {
54
82
  remove();
55
83
  }
56
84
  }
57
- async function inventory(ctx, signal) {
85
+ export async function readOnboardTeamInventory(ctx, signal) {
58
86
  const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
59
87
  if ("reason" in read)
60
88
  return read;
@@ -68,11 +96,19 @@ async function inventory(ctx, signal) {
68
96
  const seen = new Set();
69
97
  for (const value of body.teams) {
70
98
  const row = object(value);
71
- if (!row || typeof row.teamId !== "string" || !idPattern.test(row.teamId) || seen.has(row.teamId) ||
72
- typeof row.teamKey !== "string" || typeof row.teamName !== "string")
99
+ if (!row ||
100
+ typeof row.teamId !== "string" ||
101
+ !idPattern.test(row.teamId) ||
102
+ seen.has(row.teamId) ||
103
+ typeof row.teamKey !== "string" ||
104
+ typeof row.teamName !== "string")
73
105
  return { reason: "team_inventory_shape" };
74
106
  seen.add(row.teamId);
75
- teams.push({ id: row.teamId, key: label(row.teamKey), name: label(row.teamName) });
107
+ teams.push({
108
+ id: row.teamId,
109
+ key: label(row.teamKey),
110
+ name: label(row.teamName),
111
+ });
76
112
  }
77
113
  return { teams };
78
114
  }
@@ -82,14 +118,27 @@ async function observe(ctx, id, signal) {
82
118
  return null;
83
119
  const row = object(object(read.body)?.readiness);
84
120
  // The GET owns expiry/revision freshness. Do not invent an expiresAt absent from its wire shape.
85
- if (!row || row.teamId !== id || typeof row.status !== "string" || !["ready", "degraded", "blocked"].includes(row.status) || typeof row.checkedAt !== "number" ||
86
- !Number.isFinite(row.checkedAt) || row.checkedAt < 0 || row.checkedAt > ctx.now().getTime() ||
87
- typeof row.workflowRev !== "number" || !Number.isSafeInteger(row.workflowRev) || row.workflowRev < 0 || !Array.isArray(row.checks))
121
+ if (!row ||
122
+ row.teamId !== id ||
123
+ typeof row.status !== "string" ||
124
+ !["ready", "degraded", "blocked"].includes(row.status) ||
125
+ typeof row.checkedAt !== "number" ||
126
+ !Number.isFinite(row.checkedAt) ||
127
+ row.checkedAt < 0 ||
128
+ row.checkedAt > ctx.now().getTime() ||
129
+ typeof row.workflowRev !== "number" ||
130
+ !Number.isSafeInteger(row.workflowRev) ||
131
+ row.workflowRev < 0 ||
132
+ !Array.isArray(row.checks))
88
133
  return null;
89
134
  const checks = new Map();
90
135
  for (const value of row.checks) {
91
136
  const check = object(value);
92
- if (!check || typeof check.id !== "string" || checks.has(check.id) || typeof check.state !== "string" || !["pass", "fail", "unknown"].includes(check.state))
137
+ if (!check ||
138
+ typeof check.id !== "string" ||
139
+ checks.has(check.id) ||
140
+ typeof check.state !== "string" ||
141
+ !["pass", "fail", "unknown"].includes(check.state))
93
142
  return null;
94
143
  checks.set(check.id, check.state);
95
144
  }
@@ -100,17 +149,17 @@ export function existingLinearAdapters(args, choose) {
100
149
  const requested = (teams, journal) => {
101
150
  const explicit = args.flags.team;
102
151
  if (typeof explicit === "string") {
103
- const ids = teams.filter(team => team.id === explicit);
152
+ const ids = teams.filter((team) => team.id === explicit);
104
153
  if (ids.length === 1)
105
154
  return ids[0];
106
- const keys = teams.filter(team => team.key === explicit);
155
+ const keys = teams.filter((team) => team.key === explicit);
107
156
  return keys.length === 1 ? keys[0] : undefined;
108
157
  }
109
158
  const saved = chosen ?? selectedOnboardTeam(journal);
110
- return saved ? teams.find(team => team.id === saved) : undefined;
159
+ return saved ? teams.find((team) => team.id === saved) : undefined;
111
160
  };
112
161
  const teamCheck = async (ctx, journal, signal) => {
113
- const list = await inventory(ctx, signal);
162
+ const list = await readOnboardTeamInventory(ctx, signal);
114
163
  if ("reason" in list)
115
164
  return waiting(list.reason);
116
165
  if (!list.teams.length)
@@ -124,23 +173,43 @@ export function existingLinearAdapters(args, choose) {
124
173
  const result = await observe(ctx, team.id, signal);
125
174
  if (!result || result.checks.get("team_visible") !== "pass")
126
175
  return waiting("team_selection_unverified");
127
- return { state: "done", evidence: { team: team.id, teamKey: team.key, checkedAt: result.checkedAt, revision: result.revision } };
176
+ return {
177
+ state: "done",
178
+ evidence: {
179
+ team: team.id,
180
+ teamKey: team.key,
181
+ checkedAt: result.checkedAt,
182
+ revision: result.revision,
183
+ },
184
+ };
128
185
  };
129
186
  return {
130
- "linear.workspace": { check: async (ctx, journal, signal) => {
131
- const list = await inventory(ctx, signal);
187
+ "linear.workspace": {
188
+ check: async (ctx, journal, signal) => {
189
+ const list = await readOnboardTeamInventory(ctx, signal);
132
190
  if ("reason" in list)
133
191
  return waiting(list.reason);
134
192
  const candidate = requested(list.teams, journal) ?? list.teams[0];
135
193
  if (!candidate)
136
194
  return waiting("linear_workspace_unverified");
137
195
  const result = await observe(ctx, candidate.id, signal);
138
- return result?.checks.get("oauth_scope") === "pass" && result.checks.get("token_live") === "pass"
139
- ? { state: "done", evidence: { checkedAt: result.checkedAt, revision: result.revision } }
196
+ return result?.checks.get("oauth_scope") === "pass" &&
197
+ result.checks.get("token_live") === "pass"
198
+ ? {
199
+ state: "done",
200
+ evidence: {
201
+ checkedAt: result.checkedAt,
202
+ revision: result.revision,
203
+ },
204
+ }
140
205
  : waiting("linear_workspace_unverified");
141
- }, act: async () => waiting("cloud_capability_unavailable") },
142
- "linear.team": { check: teamCheck, act: async (ctx, journal, signal) => {
143
- const list = await inventory(ctx, signal);
206
+ },
207
+ act: async () => waiting("cloud_capability_unavailable"),
208
+ },
209
+ "linear.team": {
210
+ check: teamCheck,
211
+ act: async (ctx, journal, signal) => {
212
+ const list = await readOnboardTeamInventory(ctx, signal);
144
213
  if ("reason" in list)
145
214
  return waiting(list.reason);
146
215
  if (!list.teams.length)
@@ -156,14 +225,19 @@ export function existingLinearAdapters(args, choose) {
156
225
  }
157
226
  signal?.addEventListener("abort", stopped, { once: true });
158
227
  remove = () => signal?.removeEventListener("abort", stopped);
159
- Promise.resolve().then(() => signal?.aborted ? null : choose(list.teams.map(team => ({ ...team })))).then(resolve, reject);
228
+ Promise.resolve()
229
+ .then(() => signal?.aborted
230
+ ? null
231
+ : choose(list.teams.map((team) => ({ ...team }))))
232
+ .then(resolve, reject);
160
233
  }).finally(() => remove());
161
234
  if (!answer || signal?.aborted)
162
235
  return waiting("interrupted");
163
- if (!list.teams.some(team => team.id === answer))
236
+ if (!list.teams.some((team) => team.id === answer))
164
237
  return waiting("team_selection_unverified");
165
238
  chosen = answer;
166
239
  return teamCheck(ctx, journal, signal);
167
- } },
240
+ },
241
+ },
168
242
  };
169
243
  }