@catalyst-cloud/cli 0.14.10 → 0.15.0

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 (50) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +41 -0
  3. package/dist/args.js +34 -2
  4. package/dist/capabilities.js +2 -1
  5. package/dist/cli-update-notice.js +76 -0
  6. package/dist/cli.js +263 -101
  7. package/dist/oauth.js +48 -18
  8. package/dist/onboard-accounts.js +34 -5
  9. package/dist/onboard-automation-management.js +27 -9
  10. package/dist/onboard-capacity.js +10 -1
  11. package/dist/onboard-existing.js +132 -31
  12. package/dist/onboard-github.js +171 -6
  13. package/dist/onboard-headless.js +647 -0
  14. package/dist/onboard-interrupts.js +41 -0
  15. package/dist/onboard-next.js +215 -0
  16. package/dist/onboard-permissions.js +100 -0
  17. package/dist/onboard-personal.js +46 -10
  18. package/dist/onboard-runner-cloud.js +213 -0
  19. package/dist/onboard-runner-egress.js +67 -0
  20. package/dist/onboard-runner.js +780 -0
  21. package/dist/onboard-runtime.js +48 -8
  22. package/dist/onboard-standalone-copy.js +110 -0
  23. package/dist/onboard-team-create.js +267 -0
  24. package/dist/onboard-ui.js +623 -29
  25. package/dist/onboard-workflow.js +311 -168
  26. package/dist/onboard-workspace.js +52 -5
  27. package/dist/onboard.js +359 -212
  28. package/dist/setup-events.js +100 -0
  29. package/dist/setup-onboard-copy.js +438 -0
  30. package/dist/setup-prompt-frame.js +94 -0
  31. package/dist/setup-prompts.js +108 -0
  32. package/dist/setup-render.js +339 -0
  33. package/dist/setup-signin.js +187 -0
  34. package/dist/setup.js +426 -0
  35. package/package.json +7 -3
  36. package/skills/catalyst-github/SKILL.md +1 -1
  37. package/skills/catalyst-linear/SKILL.md +1 -1
  38. package/skills/catalyst-onboard/SKILL.md +3 -2
  39. package/skills/run-this-project/SKILL.md +1 -1
  40. package/skills/unstick/SKILL.md +1 -1
  41. package/skills/what-needs-me/SKILL.md +1 -1
  42. package/skills/what-this-repo-needs/SKILL.md +1 -1
  43. package/skills/whats-happening/SKILL.md +1 -1
  44. package/vendor/README.md +4 -0
  45. package/vendor/self-host/compose.yaml +110 -0
  46. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
  47. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
  48. package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
  49. package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
  50. package/vendor/self-host/provenance.json +11 -0
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { configPathFor, loadConfig, normalizeBaseUrl } from "./config.js";
4
4
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
5
5
  import { selectedOnboardTeam } from "./onboard-existing.js";
6
+ import { onboardTeamKey } from "./onboard-next.js";
6
7
  import { ownedFetch } from "./owned-fetch.js";
7
8
  import { loadHttpSdk } from "./sdk.js";
8
9
  import { fetchMe } from "./transport.js";
@@ -36,6 +37,12 @@ const object = (value) => value !== null && typeof value === "object" && !Array.
36
37
  : null;
37
38
  const id = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
38
39
  const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
40
+ const automationChecks = [
41
+ ["open", "linear_automation_pr_open"],
42
+ ["review", "linear_automation_pr_review"],
43
+ ["ready", "linear_automation_pr_ready"],
44
+ ["merge", "linear_automation_pr_merge"],
45
+ ];
39
46
  /** Existing mappings can satisfy this step by observation. This never means a preview was applied. */
40
47
  export function observeOnboardWorkflow(value, team, now) {
41
48
  const row = object(value), config = object(row?.config), readiness = object(row?.readiness);
@@ -120,191 +127,327 @@ export function observeOnboardWorkflow(value, team, now) {
120
127
  "labels_present",
121
128
  ].some((check) => checks.get(check) !== "pass"))
122
129
  return null;
130
+ const automations = automationChecks.map(([event, check]) => [
131
+ event,
132
+ checks.get(check),
133
+ ]);
134
+ const conflicts = automations
135
+ .filter(([, state]) => state === "fail")
136
+ .map(([event]) => event);
123
137
  return {
124
138
  mappingHash: row.mappingHash,
125
139
  mappingRevision: config.workflowRev,
126
140
  readinessRevision: readiness.workflowRev,
127
141
  checkedAt: readiness.checkedAt,
128
142
  mappedSlots: [...mapping.values()].filter((value) => value !== null).length,
143
+ ...(automations.every(([, state]) => state === "pass" || state === "fail")
144
+ ? { automations: conflicts.length ? conflicts.join(",") : "none" }
145
+ : {}),
129
146
  };
130
147
  }
131
- /** HTTP only, without rotating credentials or calling adopt/apply. Every entered fetch/body joins. */
132
- export function onboardWorkflowVerificationAdapter(input = {}) {
133
- return {
134
- check: async (ctx, journal, external) => {
135
- let cfg;
136
- try {
137
- cfg = loadConfig(ctx.home);
138
- }
139
- catch {
140
- return waiting("workflow_identity_unverified");
141
- }
142
- const team = selectedOnboardTeam(journal);
143
- if (!team ||
144
- !cfg?.user ||
145
- (cfg.user.role !== "owner" && cfg.user.role !== "admin") ||
146
- cfg.account !== (journal.account ?? journal.tenant) ||
147
- cfg.user.id !== journal.membershipId ||
148
- !journal.baseUrl ||
149
- normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
148
+ const label = (text) => text
149
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g, "")
150
+ .trim()
151
+ .slice(0, 80);
152
+ const listed = (names) => names.length > 10
153
+ ? `${names.slice(0, 10).join(", ")}, and ${names.length - 10} more`
154
+ : names.join(", ");
155
+ /** The adopt preview in plain words: stages and labels it creates or keeps. Linear's names are data. */
156
+ export function adoptPlanLines(plan) {
157
+ const group = (rows, outcome) => rows.filter((row) => row.outcome === outcome).map((row) => label(row.name));
158
+ return [
159
+ ["Create stages", group(plan.stages, "would-create")],
160
+ ["Keep existing stages", group(plan.stages, "already-present")],
161
+ ["Linear refused stages", group(plan.stages, "refused")],
162
+ ["Create labels", group(plan.labels, "would-create")],
163
+ ["Labels already present", group(plan.labels, "already-present")],
164
+ ["Linear refused labels", group(plan.labels, "refused")],
165
+ ]
166
+ .filter(([, names]) => names.length > 0)
167
+ .map(([title, names]) => `${title}: ${listed(names)}`)
168
+ .concat(plan.unfilledLoadBearing?.length ? [`Required work stages still missing: ${plan.unfilledLoadBearing.length}`] : [], (plan.provenanceGaps?.length ?? 0) + (plan.labelProvenanceGaps?.length ?? 0) ? ["Some existing states or labels have no recorded creator; setup keeps them"] : [], plan.labelsNotCreated?.length ? [`Labels that cannot be added: ${plan.labelsNotCreated.length}`] : []);
169
+ }
170
+ /** The only adopt bodies setup sends: this team, and the approved mode and hash. */
171
+ export function adoptRequestAllowed(body, team, allow) {
172
+ if (typeof body !== "string")
173
+ return false;
174
+ try {
175
+ const sent = object(JSON.parse(body));
176
+ return (!!sent &&
177
+ sent.team === team &&
178
+ sent.mode === allow.mode &&
179
+ Object.keys(sent).length === (allow.mode === "apply" ? 3 : 2) &&
180
+ (allow.mode === "preview" ||
181
+ (allow.team === team && sent.planHash === allow.planHash)));
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
187
+ /** One bounded, identity-pinned exchange. Reads are GETs on the workflow, /me and the contract;
188
+ * `adopt` also allows the one POST it names to the adopt route, the same route `catalyst team adopt`
189
+ * uses. No credential rotation, no config write. Every entered fetch/body joins before return. */
190
+ async function workflowSession(ctx, journal, external, adopt, run, announce) {
191
+ let cfg;
192
+ try {
193
+ cfg = loadConfig(ctx.home);
194
+ }
195
+ catch {
196
+ return waiting("workflow_identity_unverified");
197
+ }
198
+ const team = selectedOnboardTeam(journal);
199
+ if (!team ||
200
+ !cfg?.user ||
201
+ (cfg.user.role !== "owner" && cfg.user.role !== "admin") ||
202
+ cfg.account !== (journal.account ?? journal.tenant) ||
203
+ cfg.user.id !== journal.membershipId ||
204
+ !journal.baseUrl ||
205
+ normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
206
+ return waiting("workflow_identity_unverified");
207
+ if (adopt?.mode === "apply" && adopt.team !== team)
208
+ return waiting("workflow_identity_unverified");
209
+ const origin = normalizeBaseUrl(cfg.baseUrl), person = cfg.user.id, role = cfg.user.role;
210
+ let url;
211
+ try {
212
+ url = new URL(origin);
213
+ }
214
+ catch {
215
+ return waiting("workflow_identity_unverified");
216
+ }
217
+ if (url.protocol !== "https:" ||
218
+ url.origin !== origin ||
219
+ url.username ||
220
+ url.password)
221
+ return waiting("workflow_identity_unverified");
222
+ const credential = cfg.key ||
223
+ (cfg.auth && Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() > 30_000
224
+ ? cfg.auth.accessToken
225
+ : undefined);
226
+ if (!credential)
227
+ return waiting("workflow_login_refresh_required");
228
+ const configWitness = () => createHash("sha256")
229
+ .update(readFileSync(configPathFor(ctx.home)))
230
+ .digest("hex");
231
+ let before;
232
+ try {
233
+ before = configWitness();
234
+ }
235
+ catch {
236
+ return waiting("workflow_identity_unverified");
237
+ }
238
+ const current = () => {
239
+ try {
240
+ const now = loadConfig(ctx.home);
241
+ return (now?.user?.id === person &&
242
+ now.user.role === role &&
243
+ now.account === cfg.account &&
244
+ normalizeBaseUrl(now.baseUrl) === origin &&
245
+ configWitness() === before &&
246
+ selectedOnboardTeam(journal) === team &&
247
+ (!!cfg.key ||
248
+ (!!cfg.auth && Date.parse(cfg.auth.expiresAt) > ctx.now().getTime())));
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ };
254
+ const deadline = new AbortController();
255
+ const timer = setTimeout(() => deadline.abort(), 30_000);
256
+ const signal = external
257
+ ? AbortSignal.any([external, deadline.signal])
258
+ : deadline.signal;
259
+ const owned = ownedFetch(ctx.fetch, signal, { maxBodyBytes: 524_288 });
260
+ const read = (request, init) => {
261
+ if (!current() || signal.aborted)
262
+ return Promise.reject(new Error("workflow_read_stopped"));
263
+ const requested = new URL(request instanceof Request ? request.url : String(request));
264
+ const method = init?.method ?? (request instanceof Request ? request.method : "GET");
265
+ const write = !!adopt &&
266
+ method === "POST" &&
267
+ requested.pathname === `${path}/adopt` &&
268
+ requested.search === "" &&
269
+ !(request instanceof Request) &&
270
+ adoptRequestAllowed(init?.body, team, adopt);
271
+ if ((method !== "GET" && !write) ||
272
+ requested.origin !== origin ||
273
+ requested.username ||
274
+ requested.password ||
275
+ requested.hash ||
276
+ (!write &&
277
+ (!["/api/v1/me", "/api/v1/agent/contract", path].includes(requested.pathname) ||
278
+ (requested.pathname === path
279
+ ? requested.search !== `?team=${encodeURIComponent(team)}`
280
+ : requested.search !== ""))))
281
+ return Promise.reject(new Error("workflow_read_scope"));
282
+ return owned.fetch(request, { ...init, signal, redirect: "error" });
283
+ };
284
+ const live = async () => {
285
+ const me = await fetchMe(origin, credential, read);
286
+ return (current() &&
287
+ !signal.aborted &&
288
+ me.account === cfg.account &&
289
+ me.user?.id === person &&
290
+ me.user.role === role);
291
+ };
292
+ const result = await (async () => {
293
+ try {
294
+ if (!(await live()))
150
295
  return waiting("workflow_identity_unverified");
151
- const origin = normalizeBaseUrl(cfg.baseUrl), person = cfg.user.id, role = cfg.user.role;
152
- let url;
153
- try {
154
- url = new URL(origin);
296
+ const support = await verifyOnboardRoutes({ ...ctx, fetch: read }, journal, [{ method: "GET", path }], signal);
297
+ if ("reason" in support)
298
+ return waiting(support.reason);
299
+ if (adopt) {
300
+ // Onboarding's own route list does not carry adopt; the tenant contract does, and it is the
301
+ // list `catalyst team adopt` requires before it posts.
302
+ const reply = await read(`${origin}/api/v1/agent/contract`, {
303
+ headers: {
304
+ authorization: `Bearer ${credential}`,
305
+ accept: "application/json",
306
+ },
307
+ });
308
+ const routes = reply.ok ? object(await reply.json())?.routes : null;
309
+ if (!Array.isArray(routes) ||
310
+ !routes.some((route) => object(route)?.method === "POST" &&
311
+ object(route)?.path === `${path}/adopt`))
312
+ return waiting("cloud_capability_unavailable");
155
313
  }
156
- catch {
314
+ if (!current() || signal.aborted)
157
315
  return waiting("workflow_identity_unverified");
158
- }
159
- if (url.protocol !== "https:" ||
160
- url.origin !== origin ||
161
- url.username ||
162
- url.password)
316
+ const sdk = await loadHttpSdk();
317
+ if (!current() || signal.aborted)
163
318
  return waiting("workflow_identity_unverified");
164
- const credential = cfg.key ||
165
- (cfg.auth &&
166
- Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() > 30_000
167
- ? cfg.auth.accessToken
168
- : undefined);
169
- if (!credential)
170
- return waiting("workflow_login_refresh_required");
171
- const configWitness = () => createHash("sha256")
172
- .update(readFileSync(configPathFor(ctx.home)))
173
- .digest("hex");
174
- let before;
319
+ const client = sdk.createTenantClient({
320
+ baseUrl: origin,
321
+ key: credential,
322
+ fetch: read,
323
+ timeoutMs: 30_000,
324
+ now: () => ctx.now().getTime(),
325
+ });
326
+ return await run({ team, client: client.teamWorkflow, live });
327
+ }
328
+ catch {
329
+ return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
330
+ }
331
+ finally {
175
332
  try {
176
- before = configWitness();
333
+ owned.abort(new Error("workflow_read_complete"));
334
+ await owned.settle();
177
335
  }
178
- catch {
179
- return waiting("workflow_identity_unverified");
336
+ finally {
337
+ clearTimeout(timer);
180
338
  }
181
- const current = () => {
182
- try {
183
- const now = loadConfig(ctx.home);
184
- return (now?.user?.id === person &&
185
- now.user.role === role &&
186
- now.account === cfg.account &&
187
- normalizeBaseUrl(now.baseUrl) === origin &&
188
- configWitness() === before &&
189
- selectedOnboardTeam(journal) === team &&
190
- (!!cfg.key ||
191
- (!!cfg.auth &&
192
- Date.parse(cfg.auth.expiresAt) > ctx.now().getTime())));
193
- }
194
- catch {
195
- return false;
196
- }
197
- };
198
- const deadline = new AbortController();
199
- const timer = setTimeout(() => deadline.abort(), 30_000);
200
- const signal = external
201
- ? AbortSignal.any([external, deadline.signal])
202
- : deadline.signal;
203
- const owned = ownedFetch(ctx.fetch, signal, { maxBodyBytes: 524_288 });
204
- const read = (request, init) => {
205
- if (!current() || signal.aborted)
206
- return Promise.reject(new Error("workflow_read_stopped"));
207
- const requested = new URL(request instanceof Request ? request.url : String(request));
208
- const method = init?.method ?? (request instanceof Request ? request.method : "GET");
209
- if (method !== "GET" ||
210
- requested.origin !== origin ||
211
- requested.username ||
212
- requested.password ||
213
- requested.hash ||
214
- !["/api/v1/me", "/api/v1/agent/contract", path].includes(requested.pathname) ||
215
- (requested.pathname === path
216
- ? requested.search !== `?team=${encodeURIComponent(team)}`
217
- : requested.search !== ""))
218
- return Promise.reject(new Error("workflow_read_scope"));
219
- return owned.fetch(request, { ...init, signal, redirect: "error" });
220
- };
221
- const live = async () => {
222
- const me = await fetchMe(origin, credential, read);
223
- return (current() &&
224
- !signal.aborted &&
225
- me.account === cfg.account &&
226
- me.user?.id === person &&
227
- me.user.role === role);
228
- };
229
- const result = await (async () => {
230
- try {
231
- if (!(await live()))
232
- return waiting("workflow_identity_unverified");
233
- const support = await verifyOnboardRoutes({ ...ctx, fetch: read }, journal, [{ method: "GET", path }], signal);
234
- if ("reason" in support)
235
- return waiting(support.reason);
236
- if (!current() || signal.aborted)
237
- return waiting("workflow_identity_unverified");
238
- const sdk = await loadHttpSdk();
239
- if (!current() || signal.aborted)
240
- return waiting("workflow_identity_unverified");
241
- const client = sdk.createTenantClient({
242
- baseUrl: origin,
243
- key: credential,
244
- fetch: read,
245
- timeoutMs: 30_000,
246
- now: () => ctx.now().getTime(),
247
- });
248
- const first = await client.teamWorkflow.get(team);
249
- const observed = first.outcome === "ok"
250
- ? observeOnboardWorkflow(first, team, ctx.now().getTime())
251
- : null;
252
- if (!observed || !current() || signal.aborted) {
253
- input.message?.("Workflow adoption is waiting for a verified mapping or full-plan server support. Setup made no workflow change.");
254
- return waiting("workflow_mapping_unverified");
255
- }
256
- if (!(await live()))
257
- return waiting("workflow_identity_unverified");
258
- const last = await client.teamWorkflow.get(team);
259
- const verified = last.outcome === "ok"
260
- ? observeOnboardWorkflow(last, team, ctx.now().getTime())
261
- : null;
262
- if (!verified ||
263
- verified.mappingHash !== observed.mappingHash ||
264
- verified.mappingRevision !== observed.mappingRevision ||
265
- verified.readinessRevision !== observed.readinessRevision ||
266
- !current() ||
267
- signal.aborted)
268
- return waiting("workflow_mapping_changed");
269
- if (!(await live()))
270
- return waiting("workflow_identity_unverified");
271
- if (!current() || signal.aborted)
272
- return waiting("workflow_identity_unverified");
273
- return {
274
- state: "done",
275
- evidence: {
276
- team,
277
- revision: verified.readinessRevision,
278
- mappingRevision: verified.mappingRevision,
279
- checkedAt: verified.checkedAt,
280
- mappingHash: verified.mappingHash,
281
- count: verified.mappedSlots,
282
- },
283
- };
284
- }
285
- catch {
286
- return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
287
- }
288
- finally {
289
- try {
290
- owned.abort(new Error("workflow_read_complete"));
291
- await owned.settle();
292
- }
293
- finally {
294
- clearTimeout(timer);
339
+ }
340
+ })();
341
+ if (!current())
342
+ return waiting("workflow_identity_unverified");
343
+ if (signal.aborted)
344
+ return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
345
+ if (result.state === "done" && announce) {
346
+ // The display runs outside the bounded exchange, so identity is checked again after it.
347
+ announce();
348
+ if (!current() || signal.aborted)
349
+ return waiting("workflow_identity_unverified");
350
+ }
351
+ return result;
352
+ }
353
+ /** Verifies the selected team's mapping by observation. With a plan question or --yes, an unadopted
354
+ * team is `pending` and `act` previews, asks and applies the same adopt plan `catalyst team adopt`
355
+ * applies, then the engine checks again. Without either, setup never writes the workflow. */
356
+ export function onboardWorkflowVerificationAdapter(input = {}) {
357
+ const adopts = !!input.confirm || input.yes === true;
358
+ const check = (ctx, journal, external) => workflowSession(ctx, journal, external, null, async ({ team, client, live }) => {
359
+ const first = await client.get(team);
360
+ const observed = first.outcome === "ok"
361
+ ? observeOnboardWorkflow(first, team, ctx.now().getTime())
362
+ : null;
363
+ if (!observed)
364
+ return first.outcome === "ok" && adopts && Array.isArray(first.rows) && first.rows.length === 0
365
+ ? { state: "pending", reason: "workflow_mapping_unverified" }
366
+ : waiting("workflow_mapping_unverified");
367
+ if (!(await live()))
368
+ return waiting("workflow_identity_unverified");
369
+ const last = await client.get(team);
370
+ const verified = last.outcome === "ok"
371
+ ? observeOnboardWorkflow(last, team, ctx.now().getTime())
372
+ : null;
373
+ if (!verified ||
374
+ verified.mappingHash !== observed.mappingHash ||
375
+ verified.mappingRevision !== observed.mappingRevision ||
376
+ verified.readinessRevision !== observed.readinessRevision)
377
+ return waiting("workflow_mapping_changed");
378
+ if (!(await live()))
379
+ return waiting("workflow_identity_unverified");
380
+ return {
381
+ state: "done",
382
+ evidence: {
383
+ team,
384
+ revision: verified.readinessRevision,
385
+ mappingRevision: verified.mappingRevision,
386
+ checkedAt: verified.checkedAt,
387
+ mappingHash: verified.mappingHash,
388
+ count: verified.mappedSlots,
389
+ ...(verified.automations
390
+ ? { automations: verified.automations }
391
+ : {}),
392
+ },
393
+ };
394
+ }, () => input.message?.(`${onboardTeamKey(journal) ?? "This team"} already has every state and label`));
395
+ if (!adopts)
396
+ return { check };
397
+ // A local non-admin never gets here; a 403 means the seat changed after login.
398
+ const refusal = (reply) => reply.outcome === "unauthorized"
399
+ ? waiting("workflow_login_refresh_required")
400
+ : "status" in reply && reply.status === 403
401
+ ? waiting("workflow_admin_required")
402
+ : waiting("workflow_unavailable");
403
+ return {
404
+ check,
405
+ act: async (ctx, journal, external) => {
406
+ // A plan that changed between preview and apply is planned and approved once more.
407
+ for (let attempt = 0; attempt < 2; attempt++) {
408
+ let plan, previewedTeam = "";
409
+ const previewed = await workflowSession(ctx, journal, external, { mode: "preview" }, async ({ team, client }) => {
410
+ const reply = await client.adoptPreview(team);
411
+ if (reply.outcome !== "ok")
412
+ return refusal(reply);
413
+ plan = reply;
414
+ previewedTeam = team;
415
+ return { state: "done" };
416
+ });
417
+ if (previewed.state !== "done" || !plan)
418
+ return previewed;
419
+ const key = onboardTeamKey(journal) ?? (label(plan.teamKey) || "this team");
420
+ const lines = adoptPlanLines(plan);
421
+ if (!input.confirm)
422
+ input.message?.([`Catalyst workflow plan for ${key}:`, ...lines].join("\n"));
423
+ const approved = input.confirm
424
+ ? await input.confirm(key, lines)
425
+ : input.yes === true;
426
+ if (external?.aborted)
427
+ return waiting("interrupted");
428
+ if (!approved)
429
+ return waiting("workflow_adoption_declined");
430
+ const hash = plan.planHash;
431
+ let stale = false, summary = "";
432
+ const applied = await workflowSession(ctx, journal, external, { mode: "apply", team: previewedTeam, planHash: hash }, async ({ team, client }) => {
433
+ const reply = await client.adoptApply(team, hash);
434
+ if (reply.outcome !== "ok") {
435
+ stale =
436
+ "status" in reply &&
437
+ reply.status === 409 &&
438
+ reply.error === "plan-stale";
439
+ return stale ? waiting("workflow_plan_changed") : refusal(reply);
295
440
  }
296
- }
297
- })();
298
- if (!current())
299
- return waiting("workflow_identity_unverified");
300
- if (signal.aborted)
301
- return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
302
- if (result.state === "done") {
303
- input.message?.("The selected team's existing workflow mapping and labels are verified. Setup made no workflow change.");
304
- if (!current() || signal.aborted)
305
- return waiting("workflow_identity_unverified");
441
+ const created = (rows) => rows.filter((row) => row.outcome === "created").length;
442
+ const stages = created(reply.stages), labels = created(reply.labels);
443
+ summary = `Applied the Catalyst workflow to ${key}: created ${stages} ${stages === 1 ? "stage" : "stages"} and ${labels} ${labels === 1 ? "label" : "labels"}.`;
444
+ return { state: "done" };
445
+ }, () => input.message?.(summary));
446
+ // Only the server's own plan-stale answer plans again; a later identity refusal does not.
447
+ if (!input.confirm || !(stale && applied.reason === "workflow_plan_changed"))
448
+ return applied;
306
449
  }
307
- return result;
450
+ return waiting("workflow_plan_changed");
308
451
  },
309
452
  };
310
453
  }
@@ -1,6 +1,7 @@
1
1
  import { finishOnTheWeb } from "./consent-browser.js";
2
2
  import { loadConfig, normalizeBaseUrl } from "./config.js";
3
3
  import { pollConsent } from "./onboard-consent.js";
4
+ import { LINEAR_SCOPE, parsePermissions, savedOrigin, } from "./onboard-permissions.js";
4
5
  const STATUS = "/api/v1/me/connections/linear/workspace";
5
6
  const HANDOFF = "/connect/linear/workspace/handoff";
6
7
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
@@ -106,7 +107,7 @@ function readFailure(value) {
106
107
  return value.status === 200 ? null : waiting("workspace_status_unavailable");
107
108
  }
108
109
  /** Live liveness plus stored grant scopes is a connection verdict, not live workspace identity. */
109
- function observation(body, now) {
110
+ function observation(body, now, origin) {
110
111
  const row = object(body);
111
112
  const workspace = object(row?.workspace);
112
113
  const credential = object(row?.credential);
@@ -142,8 +143,42 @@ function observation(body, now) {
142
143
  return waiting("workspace_status_shape");
143
144
  if (verification.state === "unreachable")
144
145
  return waiting("workspace_status_unavailable");
146
+ // CTC-4629: an existing grant short of this version's scopes is re-authorized at the one URL the
147
+ // cloud names, and only when that URL is this cloud's own OAuth start.
148
+ const permissions = parsePermissions(row.permissions, {
149
+ grant: "linear-workspace",
150
+ action: "reauthorize",
151
+ label: LINEAR_SCOPE,
152
+ actorFor: (url) => url.origin === origin &&
153
+ url.pathname === "/settings/connections" &&
154
+ url.search === "?reauthorize=linear"
155
+ ? "workspace-admin"
156
+ : null,
157
+ });
158
+ // The verdict is derived from the verification: outdated only for missing-scope, current only
159
+ // for connected, and unknown may accompany either.
160
+ if (permissions === null ||
161
+ (permissions?.state === "outdated" &&
162
+ verification.state !== "missing-scope") ||
163
+ (permissions?.state === "current" && verification.state !== "connected"))
164
+ return waiting("workspace_status_shape");
165
+ if (permissions?.state === "outdated")
166
+ return {
167
+ state: "waiting",
168
+ reason: "linear_workspace_scope_outdated",
169
+ evidence: {
170
+ provider: "linear",
171
+ grant: "linear-workspace",
172
+ granted: permissions.granted.join(", "),
173
+ missing: permissions.missing.join(", "),
174
+ url: permissions.url,
175
+ actor: permissions.actor,
176
+ },
177
+ };
145
178
  if (!row.connected)
146
179
  return { state: "pending" };
180
+ if (permissions?.state === "unknown")
181
+ return waiting("linear_workspace_permissions_unverified");
147
182
  // A working credential alone cannot prove this account has a bound Linear workspace.
148
183
  if (!workspace.bound ||
149
184
  typeof workspace.workspaceId !== "string" ||
@@ -154,7 +189,13 @@ function observation(body, now) {
154
189
  evidence: {
155
190
  provider: "linear",
156
191
  workspace: workspace.workspaceId,
192
+ ...(typeof workspace.workspaceSlug === "string"
193
+ ? { workspaceSlug: workspace.workspaceSlug }
194
+ : {}),
157
195
  checkedAt: verification.checkedAt,
196
+ ...(permissions?.state === "current"
197
+ ? { granted: permissions.granted.join(", ") }
198
+ : {}),
158
199
  },
159
200
  };
160
201
  }
@@ -216,7 +257,7 @@ export function linearWorkspaceAdapter(options) {
216
257
  const failure = readFailure(result);
217
258
  return {
218
259
  result: failure ??
219
- observation("body" in result ? result.body : undefined, ctx.now().getTime()),
260
+ observation("body" in result ? result.body : undefined, ctx.now().getTime(), savedOrigin(ctx)),
220
261
  };
221
262
  };
222
263
  return {
@@ -232,6 +273,8 @@ export function linearWorkspaceAdapter(options) {
232
273
  return before.result;
233
274
  if (before.unsupported)
234
275
  return waiting("cloud_capability_unavailable");
276
+ if (!options.wait)
277
+ return waiting("workspace_approval_required");
235
278
  const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
236
279
  if ("status" in start && [404, 405].includes(start.status))
237
280
  return waiting("cloud_capability_unavailable");
@@ -251,7 +294,6 @@ export function linearWorkspaceAdapter(options) {
251
294
  return { state: "refused", reason: "workspace_identity_refused" };
252
295
  if (!["owner", "admin"].includes(current.user.role))
253
296
  return { state: "refused", reason: "workspace_admin_required" };
254
- ctx.stderr("Approve the organization's Linear connection in your browser. Your personal approval follows.");
255
297
  // The signed URL is transient credential material: only the browser receives it.
256
298
  let browserUnavailable = false;
257
299
  try {
@@ -276,7 +318,10 @@ export function linearWorkspaceAdapter(options) {
276
318
  outcome: "waiting",
277
319
  reason: "cloud_capability_unavailable",
278
320
  };
279
- if (latest.reason === "workspace_login_refresh_required")
321
+ // An outdated grant is re-authorized at its own URL; polling longer cannot finish it.
322
+ if (latest.reason === "workspace_login_refresh_required" ||
323
+ latest.reason === "linear_workspace_scope_outdated" ||
324
+ latest.reason === "linear_workspace_permissions_unverified")
280
325
  return { outcome: "waiting", reason: latest.reason };
281
326
  if (latest.state === "done")
282
327
  return { outcome: "connected" };
@@ -290,7 +335,9 @@ export function linearWorkspaceAdapter(options) {
290
335
  const result = options.wait
291
336
  ? await options.wait("Waiting for workspace approval", run)
292
337
  : await run();
293
- if (result.state === "done")
338
+ if (result.state === "done" ||
339
+ latest.reason === "linear_workspace_scope_outdated" ||
340
+ latest.reason === "linear_workspace_permissions_unverified")
294
341
  return latest;
295
342
  return browserUnavailable && result.reason === "consent_timeout"
296
343
  ? waiting("workspace_browser_unavailable")