@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,7 +3,7 @@ import { userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { openConsentBrowser } from "./consent-browser.js";
5
5
  import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
6
- import { CliError } from "./errors.js";
6
+ import { CliError, UsageError } from "./errors.js";
7
7
  import { cmdLegacy, findLegacy } from "./legacy.js";
8
8
  import { personalConsentAdapter } from "./onboard-personal.js";
9
9
  import { boundedOnboardSignin } from "./onboard-signin.js";
@@ -11,6 +11,7 @@ import { existingLinearAdapters } from "./onboard-existing.js";
11
11
  import { linearWorkspaceAdapter } from "./onboard-workspace.js";
12
12
  import { firstProjectAdapters } from "./onboard-projects.js";
13
13
  import { onboardCapacityAdapter } from "./onboard-capacity.js";
14
+ import { onboardRunnerAdapter } from "./onboard-runner.js";
14
15
  import { onboardAutomationManagementAdapter } from "./onboard-automation-management.js";
15
16
  import { onboardWorkflowVerificationAdapter } from "./onboard-workflow.js";
16
17
  import { onboardAccountsAdapter } from "./onboard-accounts.js";
@@ -32,6 +33,8 @@ function sameHome(a, b) {
32
33
  }
33
34
  /** Only existing bearer routes are registered here. Missing server capabilities stay unfinished. */
34
35
  export function createOnboardRuntime(args, ctx, hooks) {
36
+ if (args.flags.runner === true && args.flags["no-runner"] === true)
37
+ throw new UsageError("choose one of --runner and --no-runner");
35
38
  const identity = async (stepCtx = ctx, journal) => {
36
39
  const cfg = loadConfig(stepCtx.home);
37
40
  if (!cfg)
@@ -128,17 +131,24 @@ export function createOnboardRuntime(args, ctx, hooks) {
128
131
  evidence: { found, remaining: found },
129
132
  };
130
133
  };
134
+ const interactiveWait = hooks.ui &&
135
+ hooks.ui.interactive !== false &&
136
+ !args.json &&
137
+ args.flags.yes !== true &&
138
+ args.flags.headless !== true;
131
139
  const personalAdapter = (provider) => personalConsentAdapter({
132
140
  provider,
133
141
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
134
142
  sleep: hooks.sleep,
135
- wait: hooks.ui
143
+ wait: interactiveWait
136
144
  ? (message, run) => hooks.ui.wait(message, run)
137
145
  : undefined,
138
146
  });
139
147
  const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
140
- ? (teams) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })))
141
- : undefined);
148
+ ? (teams, create) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })), create)
149
+ : undefined, hooks.ui?.nameNewTeam
150
+ ? (question) => hooks.ui.nameNewTeam(question)
151
+ : undefined, (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)));
142
152
  const adapters = {
143
153
  machine: {
144
154
  check: async () => process.platform === "darwin" || process.platform === "linux"
@@ -207,14 +217,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
207
217
  "linear.workspace": linearWorkspaceAdapter({
208
218
  fallback: linear["linear.workspace"],
209
219
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
210
- wait: hooks.ui
220
+ wait: interactiveWait
211
221
  ? (message, work) => hooks.ui.wait(message, work)
212
222
  : undefined,
213
223
  sleep: hooks.sleep,
214
224
  }),
215
225
  "github.install": githubInstallationAdapter({
216
226
  openBrowser: hooks.openBrowser ?? openConsentBrowser,
217
- wait: hooks.ui
227
+ wait: interactiveWait
218
228
  ? (message, work) => hooks.ui.wait(message, work)
219
229
  : undefined,
220
230
  sleep: hooks.sleep,
@@ -238,17 +248,47 @@ export function createOnboardRuntime(args, ctx, hooks) {
238
248
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
239
249
  }),
240
250
  accounts: onboardAccountsAdapter({
251
+ ...(hooks.ui &&
252
+ hooks.ui.interactive !== false &&
253
+ !args.json &&
254
+ args.flags.yes !== true &&
255
+ args.flags.headless !== true
256
+ ? {
257
+ waitForAccount: (work) => hooks.ui.wait("Waiting for a coding account", work),
258
+ sleep: hooks.sleep,
259
+ }
260
+ : {}),
241
261
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
262
+ ...(typeof args.flags["coding-account"] === "string"
263
+ ? { slot: args.flags["coding-account"] }
264
+ : {}),
242
265
  }),
243
266
  capacity: onboardCapacityAdapter({
244
267
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
245
268
  }),
246
- "linear.adopt": onboardWorkflowVerificationAdapter({
269
+ runner: onboardRunnerAdapter({
270
+ selected: args.flags.runner === true
271
+ ? true
272
+ : args.flags["no-runner"] === true
273
+ ? false
274
+ : undefined,
275
+ choose: hooks.ui?.chooseRunner
276
+ ? () => hooks.ui.chooseRunner()
277
+ : undefined,
278
+ engine: hooks.runnerEngine,
279
+ sleep: hooks.sleep,
247
280
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
248
281
  }),
249
- "linear.automations": onboardAutomationManagementAdapter({
282
+ "linear.adopt": onboardWorkflowVerificationAdapter({
250
283
  message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
284
+ ...(hooks.ui?.confirmWorkflowAdoption
285
+ ? {
286
+ confirm: (team, lines) => hooks.ui.confirmWorkflowAdoption(team, lines),
287
+ }
288
+ : {}),
289
+ yes: args.flags.yes === true,
251
290
  }),
291
+ "linear.automations": onboardAutomationManagementAdapter(),
252
292
  "linear.personal": personalAdapter("linear"),
253
293
  "github.personal": personalAdapter("github"),
254
294
  daemon: {
@@ -0,0 +1,110 @@
1
+ import { setupStepView, SETUP_NUMBERS } from "./setup-onboard-copy.js";
2
+ export const COMPUTER_CHECKS = ["machine", "cli", "skills", "legacy"];
3
+ const PLAN = [
4
+ ["On this computer", "signin", "you approve once in your browser"],
5
+ [
6
+ "On this computer",
7
+ "housekeeping",
8
+ "schedule the daily update, where supported",
9
+ ],
10
+ [
11
+ "In Linear and GitHub",
12
+ "linear.workspace",
13
+ "an admin connects the workspace",
14
+ ],
15
+ [
16
+ "In Linear and GitHub",
17
+ "linear.personal",
18
+ "you connect your own Linear account",
19
+ ],
20
+ ["In Linear and GitHub", "linear.team", "you choose the team"],
21
+ [
22
+ "In Linear and GitHub",
23
+ "linear.adopt",
24
+ "review any states and labels to add",
25
+ ],
26
+ [
27
+ "In Linear and GitHub",
28
+ "linear.automations",
29
+ "check how pull requests change issue states",
30
+ ],
31
+ [
32
+ "In Linear and GitHub",
33
+ "github.install",
34
+ "an admin chooses access on GitHub",
35
+ ],
36
+ [
37
+ "In Linear and GitHub",
38
+ "github.personal",
39
+ "you connect your own GitHub account",
40
+ ],
41
+ [
42
+ "In Linear and GitHub",
43
+ "github.repos",
44
+ "you choose where Catalyst can work",
45
+ ],
46
+ ["Work", "accounts", "check a coding account"],
47
+ ["Work", "capacity", "check which runners can take work"],
48
+ ["Work", "settings", "you review settings before saving them"],
49
+ ["Work", "first-ticket", "you choose a ticket to start"],
50
+ ];
51
+ export function standalonePlan(journal, scope, runnerSelected = false) {
52
+ const includes = (id) => !scope || scope.includes(id);
53
+ const steps = new Map(journal.steps.map((step) => [step.id, step]));
54
+ const rows = [];
55
+ if (COMPUTER_CHECKS.some((id) => includes(id)))
56
+ rows.push({
57
+ group: "On this computer",
58
+ number: 1,
59
+ title: "Check this computer",
60
+ detail: includes("legacy")
61
+ ? "check the command and skills; remove any earlier Catalyst install and keep its data folders"
62
+ : "check the selected machine requirements",
63
+ });
64
+ for (const [group, id, detail] of PLAN) {
65
+ if (!includes(id) &&
66
+ !(id === "capacity" && includes("runner")) &&
67
+ !(id === "settings" && includes("values")))
68
+ continue;
69
+ const reviewedDetail = id === "capacity" && includes("runner") && runnerSelected
70
+ ? "start a Catalyst runner here with Docker"
71
+ : id === "capacity" && !includes("capacity")
72
+ ? "review the optional runner on this machine; start only if selected"
73
+ : detail;
74
+ rows.push({
75
+ group,
76
+ number: SETUP_NUMBERS[id],
77
+ title: setupStepView({ id, state: "pending" }).title,
78
+ detail: steps.get(id)?.state === "done"
79
+ ? `check again; ${reviewedDetail}`
80
+ : reviewedDetail,
81
+ });
82
+ }
83
+ return rows;
84
+ }
85
+ export function pendingContinuation(journal) {
86
+ const unfinished = (...ids) => ids.some((id) => {
87
+ const step = journal.steps.find((step) => step.id === id);
88
+ return (step &&
89
+ step.state !== "done" &&
90
+ setupStepView(step, journal).mark !== "skip");
91
+ });
92
+ const next = [];
93
+ if (unfinished("github.repos", "projects"))
94
+ next.push("chooses repositories");
95
+ if (unfinished("accounts"))
96
+ next.push("checks a coding account");
97
+ if (unfinished("capacity", "runner"))
98
+ next.push("checks runners");
99
+ if (unfinished("settings", "values"))
100
+ next.push("reviews repository settings");
101
+ if (unfinished("first-ticket"))
102
+ next.push("starts a first ticket");
103
+ if (!next.length)
104
+ return null;
105
+ const last = next.pop();
106
+ return `Then setup ${next.length ? `${next.join(", ")} and ` : ""}${last}.`;
107
+ }
108
+ export function standalonePlanNotes(localSync) {
109
+ return `${localSync ? "Local sync is selected." : "Local sync stays off, so Catalyst reads from the cloud."} Checking a coding account sends Claude one short request, which counts toward its usage. Completed steps are checked again before work starts. Codex credentials are not refreshed. Your code, git settings and other skills stay as they are.`;
110
+ }
@@ -0,0 +1,267 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { verifyOnboardRoutes } from "./onboard-capabilities.js";
3
+ import { onboardStatePath, writeOnboardJournal, } from "./onboard.js";
4
+ /** The picker's value for "Create a new Linear team…". Never a team ID: those start with a letter or digit. */
5
+ export const CREATE_TEAM_CHOICE = ":create";
6
+ export const TEAM_CREATE_PATH = "/api/v1/agent/linear/team";
7
+ export const MEMBER_CANNOT_CREATE = "Creating a Linear team needs a Catalyst workspace owner or admin.";
8
+ // eslint-disable-next-line no-control-regex -- the point is to find control characters
9
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
10
+ const KEY = /^[A-Z][A-Z0-9]{0,6}$/;
11
+ /** Why a team name cannot be used, or undefined when it can. */
12
+ export function teamNameProblem(value) {
13
+ const name = (value ?? "").trim();
14
+ return !name || name.length > 80 || CONTROL.test(name)
15
+ ? "Enter a team name of 1 to 80 characters."
16
+ : undefined;
17
+ }
18
+ /** Why a team key cannot be used, or undefined when it can. Lower case is accepted and upper-cased. */
19
+ export function teamKeyProblem(value) {
20
+ return KEY.test((value ?? "").trim().toUpperCase())
21
+ ? undefined
22
+ : "A key starts with a letter and has up to 7 letters or digits, like MOB.";
23
+ }
24
+ /** A key suggested from the name: the initials of a several-word name, or a one-word name's first three letters. */
25
+ export function suggestTeamKey(name) {
26
+ const words = name
27
+ .normalize("NFD")
28
+ .replace(/[\u0300-\u036f]/g, "")
29
+ .toUpperCase()
30
+ .split(/[^A-Z0-9]+/)
31
+ .filter(Boolean);
32
+ const raw = words.length > 1
33
+ ? words.map((word) => word[0]).join("")
34
+ : (words[0] ?? "").slice(0, 3);
35
+ const key = raw.replace(/^[0-9]+/, "").slice(0, 5);
36
+ return key || "TEAM";
37
+ }
38
+ /** The picker's create option for this person, or undefined when this cloud does not offer it. */
39
+ export async function teamCreateOffer(ctx, journal, signal) {
40
+ const support = await verifyOnboardRoutes(ctx, journal, [{ method: "POST", path: TEAM_CREATE_PATH }], signal);
41
+ if ("reason" in support)
42
+ return undefined;
43
+ const role = loadConfig(ctx.home)?.user?.role;
44
+ return role === "owner" || role === "admin"
45
+ ? { available: true }
46
+ : { available: false, reason: MEMBER_CANNOT_CREATE };
47
+ }
48
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
49
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
50
+ ? value
51
+ : null;
52
+ // Server text reaches the terminal only without control characters, and bounded.
53
+ const text = (value, fallback) => typeof value === "string" && value.trim()
54
+ ? value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 240)
55
+ : fallback;
56
+ /** POST the new team. Every outcome is named; nothing here selects a team. */
57
+ export async function postNewTeam(ctx, journal, answer, external, uncertainKey) {
58
+ const cfg = loadConfig(ctx.home);
59
+ if (!cfg?.user ||
60
+ cfg.account !== (journal.account ?? journal.tenant) ||
61
+ cfg.user.id !== journal.membershipId ||
62
+ !journal.baseUrl ||
63
+ normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
64
+ return { kind: "failed", reason: "team_create_identity_unverified" };
65
+ const expiry = cfg.auth
66
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
67
+ : 0;
68
+ const bearer = cfg.key ||
69
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
70
+ ? cfg.auth.accessToken
71
+ : undefined);
72
+ if (!bearer)
73
+ return { kind: "failed", reason: "team_read_login_refresh_required" };
74
+ const key = answer.key.trim().toUpperCase();
75
+ const previous = journal.steps.find((step) => step.id === "linear.team");
76
+ const recoveryKey = uncertainKey ??
77
+ (previous?.reason === "team_create_unverified" &&
78
+ typeof previous.evidence?.teamKey === "string"
79
+ ? previous.evidence.teamKey
80
+ : undefined);
81
+ // Resolve the outstanding result before allowing a different key to create a second team.
82
+ if (recoveryKey && recoveryKey !== key)
83
+ return {
84
+ kind: "failed",
85
+ reason: "team_create_unverified",
86
+ key: recoveryKey,
87
+ };
88
+ const owned = new AbortController();
89
+ const signal = external
90
+ ? AbortSignal.any([external, owned.signal])
91
+ : owned.signal;
92
+ // Creating a team and adopting the workflow on it is several Linear writes; allow longer than a read.
93
+ const timer = setTimeout(() => owned.abort(), 60_000);
94
+ let remove = () => { };
95
+ let sent = false;
96
+ try {
97
+ return await new Promise((resolve) => {
98
+ const stopped = () => resolve({
99
+ kind: "failed",
100
+ reason: sent || recoveryKey ? "team_create_unverified" : "interrupted",
101
+ ...(sent || recoveryKey ? { key: recoveryKey ?? key } : {}),
102
+ });
103
+ if (signal.aborted) {
104
+ stopped();
105
+ return;
106
+ }
107
+ signal.addEventListener("abort", stopped, { once: true });
108
+ remove = () => signal.removeEventListener("abort", stopped);
109
+ Promise.resolve()
110
+ .then(async () => {
111
+ if (signal.aborted)
112
+ return { kind: "failed", reason: "interrupted" };
113
+ // Save before sending: SIGINT and a killed process must leave the attempted key on disk.
114
+ const pending = {
115
+ id: "linear.team",
116
+ state: "waiting",
117
+ reason: "team_create_unverified",
118
+ evidence: { teamKey: key },
119
+ at: ctx.now().toISOString(),
120
+ };
121
+ const index = journal.steps.findIndex((step) => step.id === "linear.team");
122
+ if (index === -1)
123
+ journal.steps.push(pending);
124
+ else
125
+ journal.steps[index] = pending;
126
+ writeOnboardJournal(onboardStatePath(ctx.home, ctx.env), journal);
127
+ sent = true;
128
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${TEAM_CREATE_PATH}`, {
129
+ method: "POST",
130
+ redirect: "error",
131
+ signal,
132
+ headers: {
133
+ authorization: `Bearer ${bearer}`,
134
+ "content-type": "application/json",
135
+ accept: "application/json",
136
+ },
137
+ body: JSON.stringify({
138
+ name: answer.name.trim(),
139
+ key,
140
+ }),
141
+ });
142
+ const body = object(await response.json().catch(() => null)) ?? {};
143
+ const reply = recoveryKey &&
144
+ response.status === 409 &&
145
+ (body.error === "key-taken" || body.error === "name-taken")
146
+ ? {
147
+ kind: "failed",
148
+ reason: "team_create_unverified",
149
+ key,
150
+ }
151
+ : readReply(response.status, body, key);
152
+ if (signal.aborted)
153
+ return { kind: "failed", reason: "team_create_unverified", key };
154
+ if (reply.kind === "created") {
155
+ // Preserve a confirmed creation too, in case interruption happens during inventory readback.
156
+ journal.steps[index === -1 ? journal.steps.length - 1 : index] = {
157
+ id: "linear.team",
158
+ state: "waiting",
159
+ reason: "team_created_not_adopted",
160
+ evidence: { team: reply.team.id, teamKey: reply.team.key },
161
+ at: ctx.now().toISOString(),
162
+ };
163
+ }
164
+ else if (!recoveryKey && !(reply.kind === "failed" && reply.key)) {
165
+ journal.steps[index === -1 ? journal.steps.length - 1 : index] = {
166
+ id: "linear.team",
167
+ state: "running",
168
+ at: ctx.now().toISOString(),
169
+ };
170
+ }
171
+ writeOnboardJournal(onboardStatePath(ctx.home, ctx.env), journal);
172
+ return reply;
173
+ })
174
+ .then((reply) => (signal.aborted ? stopped() : resolve(reply)), () => signal.aborted
175
+ ? stopped()
176
+ : resolve({
177
+ kind: "failed",
178
+ reason: "team_create_unverified",
179
+ key,
180
+ }));
181
+ });
182
+ }
183
+ finally {
184
+ clearTimeout(timer);
185
+ remove();
186
+ owned.abort();
187
+ }
188
+ }
189
+ function readReply(status, body, key) {
190
+ if (status === 201) {
191
+ const team = object(body.team);
192
+ const adoption = object(body.adoption);
193
+ if (!team ||
194
+ typeof team.id !== "string" ||
195
+ !idPattern.test(team.id) ||
196
+ typeof team.key !== "string" ||
197
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(team.key) ||
198
+ typeof team.name !== "string" ||
199
+ (adoption?.outcome !== "adopted" && adoption?.outcome !== "not-adopted"))
200
+ // The team exists but this answer cannot be trusted to say which it is or how far Adopt got.
201
+ return { kind: "failed", reason: "team_create_unverified", key };
202
+ return {
203
+ kind: "created",
204
+ team: { id: team.id, key: team.key, name: text(team.name, team.key) },
205
+ adoption: adoption.outcome === "adopted"
206
+ ? { adopted: true }
207
+ : {
208
+ adopted: false,
209
+ reason: text(adoption.reason, "the adoption did not complete"),
210
+ },
211
+ };
212
+ }
213
+ // Only an answer about the name or key itself asks for another one. Anything else re-asked here
214
+ // would loop a person through the naming prompt for a problem a new name cannot fix.
215
+ if ((status === 409 &&
216
+ (body.error === "key-taken" || body.error === "name-taken")) ||
217
+ (status === 400 &&
218
+ (body.error === "invalid-name" || body.error === "invalid-key")) ||
219
+ (status === 422 && body.error === "linear-rejected"))
220
+ return {
221
+ kind: "reask",
222
+ message: text(body.linearMessage ?? body.message, "Linear did not accept that team. Choose another name or key."),
223
+ };
224
+ if (status === 403 && body.error === "linear-refused")
225
+ return {
226
+ kind: "refused",
227
+ message: text(body.message, "Linear did not let you create a team. Ask a workspace admin, or create the team in Linear, then pick it here."),
228
+ ...(typeof body.linearMessage === "string"
229
+ ? { linearMessage: text(body.linearMessage, "") }
230
+ : {}),
231
+ };
232
+ if (status === 403 && body.error === "not-an-admin")
233
+ return { kind: "refused", message: MEMBER_CANNOT_CREATE };
234
+ // The person's own Linear authorization: missing, unknown or lapsed. Nothing was created.
235
+ if ((status === 400 || status === 401) &&
236
+ (body.error === "no-grant" ||
237
+ body.error === "identity-unknown" ||
238
+ body.error === "expired"))
239
+ return {
240
+ kind: "failed",
241
+ reason: "team_create_grant_required",
242
+ message: text(body.reason, ""),
243
+ };
244
+ // Linear's answer was lost and the team could not be read back: it may exist.
245
+ if (status === 502 && body.error === "linear-unconfirmed")
246
+ return {
247
+ kind: "failed",
248
+ reason: "team_create_unverified",
249
+ message: text(body.message, ""),
250
+ key,
251
+ };
252
+ // Only the route's explicit no-create outcomes permit a fresh attempt. A proxy, timeout or
253
+ // Worker failure can lose the answer after the mutation, so an unnamed response is uncertain.
254
+ if ((status === 503 && body.error === "linear-unavailable") ||
255
+ (status === 404 && body.error === "team-create-unavailable") ||
256
+ (status === 502 && body.error === "linear-schema-error"))
257
+ return {
258
+ kind: "failed",
259
+ reason: "team_create_unavailable",
260
+ message: text(body.message ?? body.reason, ""),
261
+ };
262
+ return {
263
+ kind: "failed",
264
+ reason: "team_create_unverified",
265
+ key,
266
+ };
267
+ }