@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.
- package/CHANGELOG.md +9 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +263 -101
- package/dist/oauth.js +48 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -212
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- package/vendor/self-host/provenance.json +11 -0
package/dist/onboard-runtime.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|