@catalyst-cloud/cli 0.14.1 → 0.14.3
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 +15 -0
- package/README.md +14 -0
- package/dist/args.js +7 -2
- package/dist/cli.js +94 -37
- package/dist/contract.js +5 -0
- package/dist/env/parse-env.js +40 -4
- package/dist/env/selected-import.js +249 -0
- package/dist/oauth.js +57 -6
- package/dist/onboard-existing.js +169 -0
- package/dist/onboard-progress.js +70 -0
- package/dist/onboard-ready.js +67 -0
- package/dist/onboard-repositories.js +160 -0
- package/dist/onboard-runtime.js +157 -0
- package/dist/onboard-signin.js +43 -0
- package/dist/onboard-ui.js +155 -0
- package/dist/onboard-workspace.js +185 -0
- package/dist/onboard.js +151 -240
- package/dist/ready.js +28 -1
- package/package.json +2 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +18 -5
- 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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { userInfo } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { openBrowser } from "./browser.js";
|
|
5
|
+
import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest } from "./config.js";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
import { cmdLegacy, findLegacy } from "./legacy.js";
|
|
8
|
+
import { bearerFor } from "./oauth.js";
|
|
9
|
+
import { pollConsent } from "./onboard-consent.js";
|
|
10
|
+
import { boundedOnboardSignin } from "./onboard-signin.js";
|
|
11
|
+
import { existingLinearAdapters } from "./onboard-existing.js";
|
|
12
|
+
import { linearWorkspaceAdapter } from "./onboard-workspace.js";
|
|
13
|
+
import { existingRepositoryAdapter } from "./onboard-repositories.js";
|
|
14
|
+
import { fetchMe } from "./transport.js";
|
|
15
|
+
const waiting = (reason) => ({ state: "waiting", reason });
|
|
16
|
+
function sameHome(a, b) {
|
|
17
|
+
try {
|
|
18
|
+
return realpathSync(a) === realpathSync(b);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Only existing bearer routes are registered here. Missing server capabilities stay unfinished. */
|
|
25
|
+
export function createOnboardRuntime(args, ctx, hooks) {
|
|
26
|
+
const identity = async (stepCtx = ctx, journal) => {
|
|
27
|
+
const cfg = loadConfig(stepCtx.home);
|
|
28
|
+
if (!cfg)
|
|
29
|
+
return null;
|
|
30
|
+
const requested = args.baseUrl ?? stepCtx.env.CATALYST_CLOUD_BASE_URL;
|
|
31
|
+
if (requested && normalizeBaseUrl(requested) !== normalizeBaseUrl(cfg.baseUrl))
|
|
32
|
+
throw new CliError("the saved login uses another cloud; resume against that cloud", "onboard-base-url-mismatch", 12);
|
|
33
|
+
if (!cfg.user)
|
|
34
|
+
throw new CliError("sign in as yourself to set up Catalyst", "onboard-person-required", 12);
|
|
35
|
+
if (journal && (((journal.account ?? journal.tenant) && (journal.account ?? journal.tenant) !== cfg.account) ||
|
|
36
|
+
(journal.membershipId && journal.membershipId !== cfg.user.id) ||
|
|
37
|
+
(journal.baseUrl && normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))))
|
|
38
|
+
throw new CliError("the saved setup belongs to another workspace or member", "onboard-identity-mismatch", 12);
|
|
39
|
+
const me = await fetchMe(cfg.baseUrl, await bearerFor(stepCtx, cfg), stepCtx.fetch);
|
|
40
|
+
if (!me.user || me.account !== cfg.account || me.user.id !== cfg.user.id)
|
|
41
|
+
throw new CliError("the saved login and live member do not match", "onboard-identity-mismatch", 12);
|
|
42
|
+
// /me names a person within the account, not an independently exposed membership-row ID.
|
|
43
|
+
return { account: me.account, membershipId: me.user.id, baseUrl: normalizeBaseUrl(cfg.baseUrl), role: me.user.role };
|
|
44
|
+
};
|
|
45
|
+
const legacyCheck = async (stepCtx) => {
|
|
46
|
+
const found = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform).filter(item => !item.data).length;
|
|
47
|
+
if (!sameHome(stepCtx.home, (hooks.realHome ?? (() => userInfo().homedir))()))
|
|
48
|
+
return { state: "skipped", reason: "fake_home_report_only", evidence: { found, remaining: found } };
|
|
49
|
+
return { state: found ? "pending" : "done", evidence: { found, remaining: found } };
|
|
50
|
+
};
|
|
51
|
+
const unsupported = { check: async () => waiting("cloud_capability_unavailable"), act: async () => waiting("cloud_capability_unavailable") };
|
|
52
|
+
const personalStatus = async (provider, stepCtx, signal) => {
|
|
53
|
+
const cfg = loadConfig(stepCtx.home);
|
|
54
|
+
if (!cfg || !cfg.user)
|
|
55
|
+
return { outcome: "refused", reason: "personal_login_required" };
|
|
56
|
+
const response = await stepCtx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}/api/v1/me/connections/${provider}/personal`, {
|
|
57
|
+
headers: { authorization: `Bearer ${await bearerFor(stepCtx, cfg)}`, accept: "application/json" },
|
|
58
|
+
signal: signal ?? AbortSignal.timeout(30_000),
|
|
59
|
+
});
|
|
60
|
+
if (response.status === 401 || response.status === 403)
|
|
61
|
+
return { outcome: "refused", reason: "personal_consent_refused" };
|
|
62
|
+
if (response.status === 503)
|
|
63
|
+
return { outcome: "unavailable" };
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
return { outcome: "failed", reason: "personal_status_failed" };
|
|
66
|
+
const body = await response.json();
|
|
67
|
+
if (!body || typeof body !== "object" || !("connected" in body))
|
|
68
|
+
return { outcome: "failed", reason: "personal_status_shape" };
|
|
69
|
+
if (body.connected === true)
|
|
70
|
+
return { outcome: "connected" };
|
|
71
|
+
if (body.connected !== false)
|
|
72
|
+
return { outcome: "failed", reason: "personal_status_shape" };
|
|
73
|
+
return { outcome: "reason" in body && body.reason === "lapsed" ? "lapsed" : "absent" };
|
|
74
|
+
};
|
|
75
|
+
const personalAdapter = (provider) => ({
|
|
76
|
+
check: async (stepCtx) => {
|
|
77
|
+
const result = await personalStatus(provider, stepCtx);
|
|
78
|
+
if (result.outcome === "connected")
|
|
79
|
+
return { state: "done" };
|
|
80
|
+
if (result.outcome === "refused" || result.outcome === "failed")
|
|
81
|
+
return { state: result.outcome, reason: result.reason };
|
|
82
|
+
return result.outcome === "unavailable" ? waiting("personal_status_unavailable") : { state: "pending" };
|
|
83
|
+
}, act: async (stepCtx, _journal, signal) => {
|
|
84
|
+
const cfg = loadConfig(stepCtx.home);
|
|
85
|
+
if (!cfg || !cfg.user)
|
|
86
|
+
return { state: "refused", reason: "personal_login_required" };
|
|
87
|
+
const response = await stepCtx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}/connect/${provider}/personal/start`, {
|
|
88
|
+
headers: { authorization: `Bearer ${await bearerFor(stepCtx, cfg)}`, accept: "application/json" }, signal: AbortSignal.timeout(30_000),
|
|
89
|
+
});
|
|
90
|
+
if (response.status === 409)
|
|
91
|
+
return waiting(provider === "linear" ? "linear_workspace_required" : "github_workspace_required");
|
|
92
|
+
if (response.status === 401 || response.status === 403)
|
|
93
|
+
return { state: "refused", reason: "personal_consent_refused" };
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
return { state: "failed", reason: "personal_consent_start_failed" };
|
|
96
|
+
const body = await response.json();
|
|
97
|
+
if (typeof body.authorizationUrl !== "string" || typeof body.expiresAt !== "number")
|
|
98
|
+
return { state: "failed", reason: "personal_consent_shape" };
|
|
99
|
+
let url;
|
|
100
|
+
try {
|
|
101
|
+
url = new URL(body.authorizationUrl);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return { state: "failed", reason: "personal_consent_shape" };
|
|
105
|
+
}
|
|
106
|
+
if (url.protocol !== "https:" || url.origin !== new URL(cfg.baseUrl).origin || url.username || url.password)
|
|
107
|
+
return { state: "refused", reason: "personal_consent_origin" };
|
|
108
|
+
stepCtx.stderr(`Approve your personal ${provider === "linear" ? "Linear" : "GitHub"} connection in the browser.`);
|
|
109
|
+
stepCtx.stderr(url.toString());
|
|
110
|
+
(hooks.openBrowser ?? openBrowser)(url.toString());
|
|
111
|
+
const poll = () => pollConsent({ readStatus: pollSignal => personalStatus(provider, stepCtx, pollSignal), sleep: hooks.sleep, signal });
|
|
112
|
+
return hooks.ui ? hooks.ui.wait(`Waiting for your ${provider === "linear" ? "Linear" : "GitHub"} approval`, poll) : poll();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam ? teams => hooks.ui.chooseTeam(teams.map(team => ({ ...team }))) : undefined);
|
|
116
|
+
return {
|
|
117
|
+
identity: journal => identity(ctx, journal), bindSignals: true, ui: hooks.ui,
|
|
118
|
+
adapters: {
|
|
119
|
+
machine: { check: async () => process.platform === "darwin" || process.platform === "linux"
|
|
120
|
+
? { state: "done", evidence: { provider: process.platform } } : waiting("machine_platform_unsupported") },
|
|
121
|
+
cli: { check: async () => ({ state: "done", evidence: { version: readManifest().version } }) },
|
|
122
|
+
skills: { check: async (stepCtx) => {
|
|
123
|
+
const cfg = loadConfig(stepCtx.home);
|
|
124
|
+
const dir = stepCtx.env.CATALYST_SKILLS_DIR ?? cfg?.skillsDir ?? defaultSkillsDirFor(stepCtx.home);
|
|
125
|
+
const names = hooks.skillNames ?? [];
|
|
126
|
+
const count = names.filter(name => existsSync(join(dir, name, "SKILL.md"))).length;
|
|
127
|
+
return names.length > 0 && count === names.length ? { state: "done", evidence: { count, path: dir } } : waiting("skills_install_unverified");
|
|
128
|
+
} },
|
|
129
|
+
legacy: { check: legacyCheck, act: async (stepCtx) => {
|
|
130
|
+
const before = await legacyCheck(stepCtx);
|
|
131
|
+
if (before.state !== "pending")
|
|
132
|
+
return before;
|
|
133
|
+
// Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
|
|
134
|
+
const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
|
|
135
|
+
const code = await cmdLegacy({ ...args, command: "legacy", subcommand: null, rest: [], flags: { remove: true, yes: true }, json: false }, quiet, hooks.legacy);
|
|
136
|
+
return code === 0 ? legacyCheck(stepCtx) : { state: "failed", reason: "legacy_cleanup_failed" };
|
|
137
|
+
} },
|
|
138
|
+
signin: { check: async (stepCtx, journal) => {
|
|
139
|
+
const who = await identity(stepCtx, journal);
|
|
140
|
+
return who ? { state: "done", evidence: { account: who.account, membershipId: who.membershipId, role: who.role } } : { state: "pending" };
|
|
141
|
+
}, act: async (stepCtx, _journal, signal) => {
|
|
142
|
+
return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
|
|
143
|
+
} },
|
|
144
|
+
...linear,
|
|
145
|
+
"linear.workspace": linearWorkspaceAdapter({ fallback: linear["linear.workspace"], openBrowser: hooks.openBrowser ?? openBrowser,
|
|
146
|
+
wait: hooks.ui ? (message, work) => hooks.ui.wait(message, work) : undefined, sleep: hooks.sleep }),
|
|
147
|
+
"github.install": unsupported,
|
|
148
|
+
"github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories ? repositories => hooks.ui.chooseRepositories(repositories.map(row => ({ ...row }))) : undefined),
|
|
149
|
+
"linear.personal": personalAdapter("linear"),
|
|
150
|
+
"github.personal": personalAdapter("github"),
|
|
151
|
+
daemon: { check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true) ? waiting("local_sync_capability_unavailable")
|
|
152
|
+
: { state: "skipped", reason: "local_sync_not_selected", evidence: { provider: "cloud" } } },
|
|
153
|
+
housekeeping: { check: async () => waiting("housekeeping_service_unverified") },
|
|
154
|
+
ready: { check: hooks.ready },
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CliError, UsageError } from "./errors.js";
|
|
2
|
+
const TIMED_OUT = Symbol("signin_timeout");
|
|
3
|
+
const cancelled = () => new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
|
|
4
|
+
/** One onboarding budget includes every device-code round and final member verification. */
|
|
5
|
+
export async function boundedOnboardSignin(ctx, login, external, timeoutMs = 600_000) {
|
|
6
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 600_000)
|
|
7
|
+
throw new UsageError("onboarding sign-in timeout must be 1 to 600000 milliseconds");
|
|
8
|
+
const deadline = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
10
|
+
const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
|
|
11
|
+
let removeAbort = () => { };
|
|
12
|
+
try {
|
|
13
|
+
const exit = await new Promise((resolve, reject) => {
|
|
14
|
+
const stopped = () => external?.aborted ? reject(cancelled()) : resolve(TIMED_OUT);
|
|
15
|
+
if (signal.aborted) {
|
|
16
|
+
stopped();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
signal.addEventListener("abort", stopped, { once: true });
|
|
20
|
+
removeAbort = () => signal.removeEventListener("abort", stopped);
|
|
21
|
+
const stepCtx = { ...ctx, fetch: (async (input, init) => {
|
|
22
|
+
if (signal.aborted)
|
|
23
|
+
throw cancelled();
|
|
24
|
+
const response = await ctx.fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal });
|
|
25
|
+
if (signal.aborted)
|
|
26
|
+
throw cancelled();
|
|
27
|
+
return response;
|
|
28
|
+
}) };
|
|
29
|
+
// Attach both handlers even when a custom callback ignores abort and settles after the budget.
|
|
30
|
+
Promise.resolve().then(() => {
|
|
31
|
+
if (signal.aborted)
|
|
32
|
+
throw cancelled();
|
|
33
|
+
return login(stepCtx, signal);
|
|
34
|
+
}).then(code => signal.aborted ? stopped() : resolve(code), error => signal.aborted ? stopped() : reject(error));
|
|
35
|
+
});
|
|
36
|
+
return exit === TIMED_OUT ? { state: "waiting", reason: "signin_timeout" }
|
|
37
|
+
: exit === 0 ? { state: "done" } : { state: "failed", reason: "signin_failed" };
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
removeAbort();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createOnboardProgress } from "./onboard-progress.js";
|
|
2
|
+
import { ONBOARD_STEPS, ONBOARD_TITLES } from "./onboard.js";
|
|
3
|
+
export function shouldUseOnboardUi(args, stdoutTty) {
|
|
4
|
+
return stdoutTty && !args.json && args.flags.yes !== true && args.flags["dry-run"] !== true;
|
|
5
|
+
}
|
|
6
|
+
export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
7
|
+
const abort = new AbortController();
|
|
8
|
+
const options = { ...streams, signal: abort.signal };
|
|
9
|
+
// Clack 1.8.1's spinner calls process.exit(0) on raw Ctrl-C, bypassing receipt/lock cleanup.
|
|
10
|
+
const spin = deps.progress ?? createOnboardProgress(streams.output, abort.signal, () => abort.abort());
|
|
11
|
+
const signals = deps.signals ?? process;
|
|
12
|
+
const interrupt = () => abort.abort();
|
|
13
|
+
for (const event of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
14
|
+
signals.on(event, interrupt);
|
|
15
|
+
let active = false;
|
|
16
|
+
let introduced = false;
|
|
17
|
+
let group;
|
|
18
|
+
const stop = () => { if (active) {
|
|
19
|
+
spin.stop();
|
|
20
|
+
active = false;
|
|
21
|
+
} };
|
|
22
|
+
const message = (text) => { stop(); prompts.log.message(text, { output: streams.output }); };
|
|
23
|
+
const start = (text) => { stop(); if (!abort.signal.aborted) {
|
|
24
|
+
spin.start(text);
|
|
25
|
+
active = true;
|
|
26
|
+
} };
|
|
27
|
+
return {
|
|
28
|
+
signal: abort.signal,
|
|
29
|
+
plan(journal) {
|
|
30
|
+
stop();
|
|
31
|
+
if (!introduced) {
|
|
32
|
+
prompts.intro("Catalyst setup", { output: streams.output });
|
|
33
|
+
introduced = true;
|
|
34
|
+
}
|
|
35
|
+
const steps = new Map(journal.steps.map(step => [step.id, step]));
|
|
36
|
+
message(ONBOARD_STEPS.map(id => `· ${ONBOARD_TITLES[id]}${steps.get(id)?.state === "done" ? " (recheck)" : ""}`).join("\n"));
|
|
37
|
+
message("Use cloud reads by default. Local sync is optional for SQL, offline work or sustained reads.");
|
|
38
|
+
},
|
|
39
|
+
async confirmPlan(localSync) {
|
|
40
|
+
stop();
|
|
41
|
+
const answer = await prompts.select({ ...options, message: "Continue with this plan?", initialValue: localSync ? "local" : "cloud", options: [
|
|
42
|
+
{ value: "cloud", label: "Continue using cloud reads", hint: "default" },
|
|
43
|
+
{ value: "local", label: "Continue and set up local sync" },
|
|
44
|
+
{ value: "stop", label: "Stop without changes" },
|
|
45
|
+
] });
|
|
46
|
+
if (prompts.isCancel(answer)) {
|
|
47
|
+
abort.abort();
|
|
48
|
+
return { proceed: false, localSync };
|
|
49
|
+
}
|
|
50
|
+
return { proceed: answer === "cloud" || answer === "local", localSync: answer === "local" };
|
|
51
|
+
},
|
|
52
|
+
async chooseTeam(teams) {
|
|
53
|
+
stop();
|
|
54
|
+
if (!teams.length || abort.signal.aborted)
|
|
55
|
+
return null;
|
|
56
|
+
const answer = await prompts.select({ ...options, message: "Which Linear team should this project use?", initialValue: teams[0].id,
|
|
57
|
+
options: teams.map(team => ({ value: team.id, label: `${team.name || team.key || team.id}${team.key && team.name ? ` (${team.key})` : ""}` })) });
|
|
58
|
+
if (prompts.isCancel(answer)) {
|
|
59
|
+
abort.abort();
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return typeof answer === "string" ? answer : null;
|
|
63
|
+
},
|
|
64
|
+
...(prompts.multiselect ? { async chooseRepositories(repositories) {
|
|
65
|
+
stop();
|
|
66
|
+
if (!repositories.length || abort.signal.aborted)
|
|
67
|
+
return null;
|
|
68
|
+
const answer = await prompts.multiselect({ ...options, message: "Which repositories should use this Linear team?", required: true,
|
|
69
|
+
options: repositories.map(row => ({ value: `${row.owner}/${row.name}`, label: `${row.owner}/${row.name}` })) });
|
|
70
|
+
if (prompts.isCancel(answer)) {
|
|
71
|
+
abort.abort();
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return Array.isArray(answer) ? answer : null;
|
|
75
|
+
} } : {}),
|
|
76
|
+
stepStart(id) {
|
|
77
|
+
const next = ["machine", "cli", "skills", "legacy"].includes(id) ? "This computer"
|
|
78
|
+
: id === "signin" ? "Catalyst sign-in" : id.startsWith("linear.") || id.startsWith("github.") ? "Connections"
|
|
79
|
+
: ["projects", "accounts", "settings", "values"].includes(id) ? "Project setup"
|
|
80
|
+
: ["capacity", "daemon", "housekeeping"].includes(id) ? "Runner and services" : "Work and readiness";
|
|
81
|
+
if (next !== group) {
|
|
82
|
+
message(next);
|
|
83
|
+
group = next;
|
|
84
|
+
}
|
|
85
|
+
start(ONBOARD_TITLES[id]);
|
|
86
|
+
},
|
|
87
|
+
stepEnd(step) {
|
|
88
|
+
stop();
|
|
89
|
+
const reasons = {
|
|
90
|
+
cloud_capability_unavailable: "Your cloud does not support this setup step yet.",
|
|
91
|
+
step_not_available_in_this_release: "This setup step is not available yet.",
|
|
92
|
+
prerequisite_not_ready: "Waiting for an earlier setup step.",
|
|
93
|
+
local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
|
|
94
|
+
local_sync_capability_unavailable: "Local sync was selected but could not be verified.",
|
|
95
|
+
member_scope: "Your workspace administrator handles this step.",
|
|
96
|
+
onboarding_checks_pending: "Some required checks are still unverified.",
|
|
97
|
+
interrupted: "Setup paused. Run the same command to resume.",
|
|
98
|
+
signin_timeout: "Sign-in timed out. Run catalyst onboard to try again.",
|
|
99
|
+
linear_workspace_unverified: "Your Linear workspace connection could not be verified.",
|
|
100
|
+
team_inventory_empty: "No existing Linear teams could be verified.",
|
|
101
|
+
team_inventory_shape: "The cloud returned a team list that could not be verified.",
|
|
102
|
+
team_read_unavailable: "Your existing Linear teams could not be read. Run catalyst onboard to try again.",
|
|
103
|
+
team_read_login_refresh_required: "Your login needs a refresh. Run catalyst onboard to resume.",
|
|
104
|
+
team_choice_required: "Choose an existing team with catalyst onboard --team <team ID or key>.",
|
|
105
|
+
team_selection_unverified: "Your selected Linear team could not be verified. Run catalyst onboard --team <team ID or key> to choose again.",
|
|
106
|
+
repository_choice_required: "Choose repositories with catalyst onboard --repo <owner/name>. Repeat --repo to choose more than one.",
|
|
107
|
+
repository_inventory_empty: "No accessible repositories are registered to this Linear team yet.",
|
|
108
|
+
repository_read_unavailable: "Your repository list could not be read. Run catalyst onboard to try again.",
|
|
109
|
+
repository_inventory_shape: "The cloud returned a repository list that could not be verified.",
|
|
110
|
+
repository_contract_unavailable: "Current repository IDs could not be checked. Run catalyst onboard to try again.",
|
|
111
|
+
repository_contract_unverified: "The cloud returned repository IDs that could not be verified for this workspace.",
|
|
112
|
+
repository_id_unverified: "A repository's current ID could not be verified. Keep your selection and try again.",
|
|
113
|
+
repository_binding_ambiguous: "A repository's Linear team binding is ambiguous. Your administrator needs to check it.",
|
|
114
|
+
repository_identity_unverified: "Sign in to the original workspace to choose repositories.",
|
|
115
|
+
repository_selection_unverified: "Your selected repositories could not be verified. Run catalyst onboard --repo <owner/name> to choose again.",
|
|
116
|
+
};
|
|
117
|
+
const text = `${ONBOARD_TITLES[step.id]}${step.reason ? `: ${reasons[step.reason] ?? step.reason.replaceAll("_", " ")}` : ""}`;
|
|
118
|
+
const kind = step.state === "done" ? "info" : step.state === "failed" ? "error" : step.state === "waiting" || step.state === "pending" ? "warn" : "message";
|
|
119
|
+
prompts.log[kind](`${step.state === "done" ? "✓ " : ""}${text}`, { output: streams.output });
|
|
120
|
+
},
|
|
121
|
+
message,
|
|
122
|
+
finish(journal, only) {
|
|
123
|
+
stop();
|
|
124
|
+
let text = "Setup still needs checks. Run catalyst onboard to resume.";
|
|
125
|
+
if (abort.signal.aborted)
|
|
126
|
+
text = "Setup paused. Your progress is saved.\nresume: catalyst onboard";
|
|
127
|
+
else if (journal.complete && journal.exit === 0 && !only)
|
|
128
|
+
text = "Onboarding complete.";
|
|
129
|
+
else if (only && journal.exit === 0)
|
|
130
|
+
text = `${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.\nresume: catalyst onboard`;
|
|
131
|
+
prompts.outro(text, { output: streams.output });
|
|
132
|
+
},
|
|
133
|
+
async wait(text, run) { start(text); try {
|
|
134
|
+
return await run();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
stop();
|
|
138
|
+
} },
|
|
139
|
+
dispose() {
|
|
140
|
+
try {
|
|
141
|
+
stop();
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
abort.abort();
|
|
145
|
+
try {
|
|
146
|
+
spin.dispose();
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
for (const event of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
150
|
+
signals.off(event, interrupt);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
|
+
import { pollConsent } from "./onboard-consent.js";
|
|
3
|
+
const STATUS = "/api/v1/me/connections/linear/workspace";
|
|
4
|
+
const HANDOFF = "/connect/linear/workspace/handoff";
|
|
5
|
+
const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
+
const states = new Set(["connected", "missing-scope", "expired-or-revoked", "unreachable", "not-connected"]);
|
|
7
|
+
const warmth = new Set(["warm", "absent", "expired", "renewal-due", "no-expiry", "unavailable"]);
|
|
8
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
9
|
+
const waiting = (reason) => ({ state: "waiting", reason });
|
|
10
|
+
const nullableText = (v) => v === null || typeof v === "string";
|
|
11
|
+
const nullableTime = (v) => v === null || typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
12
|
+
/** A bounded read never rotates credentials or writes a token after the receipt lock is released. */
|
|
13
|
+
async function read(ctx, journal, path, timeoutMs, external) {
|
|
14
|
+
const cfg = loadConfig(ctx.home);
|
|
15
|
+
if (!cfg?.user)
|
|
16
|
+
return { reason: "personal_login_required" };
|
|
17
|
+
if ((journal.account ?? journal.tenant) && (journal.account ?? journal.tenant) !== cfg.account ||
|
|
18
|
+
journal.membershipId && journal.membershipId !== cfg.user.id ||
|
|
19
|
+
journal.baseUrl && normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
|
|
20
|
+
return { reason: "workspace_identity_refused" };
|
|
21
|
+
const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
|
|
22
|
+
const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
|
|
23
|
+
if (!bearer)
|
|
24
|
+
return { reason: "workspace_login_refresh_required" };
|
|
25
|
+
const deadline = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
27
|
+
const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
|
|
28
|
+
let remove = () => { };
|
|
29
|
+
try {
|
|
30
|
+
return await new Promise(resolve => {
|
|
31
|
+
const stopped = () => resolve({ reason: external?.aborted ? "interrupted" : "workspace_status_unavailable" });
|
|
32
|
+
if (signal.aborted) {
|
|
33
|
+
stopped();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
signal.addEventListener("abort", stopped, { once: true });
|
|
37
|
+
remove = () => signal.removeEventListener("abort", stopped);
|
|
38
|
+
Promise.resolve().then(async () => {
|
|
39
|
+
if (signal.aborted)
|
|
40
|
+
return { reason: "interrupted" };
|
|
41
|
+
const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
|
|
42
|
+
method: "GET", redirect: "error", headers: { authorization: `Bearer ${bearer}`, accept: "application/json" }, signal,
|
|
43
|
+
});
|
|
44
|
+
// Refusal bodies and provider diagnostics are never parsed, returned or printed.
|
|
45
|
+
if (response.status !== 200)
|
|
46
|
+
return { status: response.status };
|
|
47
|
+
return { status: 200, body: await response.json() };
|
|
48
|
+
}).then(value => signal.aborted ? stopped() : resolve(value), () => signal.aborted ? stopped() : resolve({ reason: "workspace_status_unavailable" }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
remove();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function readFailure(value) {
|
|
57
|
+
if ("reason" in value)
|
|
58
|
+
return value.reason === "workspace_identity_refused" || value.reason === "personal_login_required"
|
|
59
|
+
? { state: "refused", reason: value.reason } : waiting(value.reason);
|
|
60
|
+
if (value.status === 401 || value.status === 403)
|
|
61
|
+
return { state: "refused", reason: "workspace_consent_refused" };
|
|
62
|
+
return value.status === 200 ? null : waiting("workspace_status_unavailable");
|
|
63
|
+
}
|
|
64
|
+
/** Live liveness plus stored grant scopes is a connection verdict, not live workspace identity. */
|
|
65
|
+
function observation(body, now) {
|
|
66
|
+
const row = object(body);
|
|
67
|
+
const workspace = object(row?.workspace);
|
|
68
|
+
const credential = object(row?.credential);
|
|
69
|
+
const verification = object(row?.verification);
|
|
70
|
+
if (!row || typeof row.connected !== "boolean" || !workspace || typeof workspace.bound !== "boolean" ||
|
|
71
|
+
!nullableText(workspace.workspaceId) || !nullableText(workspace.workspaceSlug) || !nullableText(workspace.workspaceName) ||
|
|
72
|
+
!credential || ![true, false, null].includes(credential.stored) ||
|
|
73
|
+
!nullableTime(credential.updatedAt) || !nullableTime(credential.expiresAt) || (typeof credential.warmth !== "string" || !warmth.has(credential.warmth)) || !verification)
|
|
74
|
+
return waiting("workspace_status_shape");
|
|
75
|
+
if (verification.source === "none" && verification.state === "not-run" && verification.checkedAt === null && row.connected === false)
|
|
76
|
+
return waiting("workspace_status_unavailable");
|
|
77
|
+
if (verification.source !== "live-probe" || (typeof verification.state !== "string" || !states.has(verification.state)) ||
|
|
78
|
+
typeof verification.checkedAt !== "number" || !Number.isFinite(verification.checkedAt) ||
|
|
79
|
+
verification.checkedAt < now - 120_000 || verification.checkedAt > now + 30_000 ||
|
|
80
|
+
row.connected !== (verification.state === "connected"))
|
|
81
|
+
return waiting("workspace_status_shape");
|
|
82
|
+
if (verification.state === "unreachable")
|
|
83
|
+
return waiting("workspace_status_unavailable");
|
|
84
|
+
if (!row.connected)
|
|
85
|
+
return { state: "pending" };
|
|
86
|
+
// A working credential alone cannot prove this account has a bound Linear workspace.
|
|
87
|
+
if (!workspace.bound || typeof workspace.workspaceId !== "string" || !idPattern.test(workspace.workspaceId))
|
|
88
|
+
return waiting("linear_workspace_unverified");
|
|
89
|
+
return { state: "done", evidence: { provider: "linear", workspace: workspace.workspaceId, checkedAt: verification.checkedAt } };
|
|
90
|
+
}
|
|
91
|
+
function safeHandoff(body, ctx) {
|
|
92
|
+
const row = object(body);
|
|
93
|
+
const cfg = loadConfig(ctx.home);
|
|
94
|
+
if (!cfg || !row || typeof row.authorizationUrl !== "string" || row.authorizationUrl.length > 8192 || /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
|
|
95
|
+
typeof row.expiresAt !== "number" || !Number.isFinite(row.expiresAt))
|
|
96
|
+
return null;
|
|
97
|
+
const remaining = row.expiresAt - ctx.now().getTime();
|
|
98
|
+
// Thirty seconds accommodates clock skew; an expired link is never launched.
|
|
99
|
+
if (remaining <= 0 || remaining > 630_000)
|
|
100
|
+
return null;
|
|
101
|
+
try {
|
|
102
|
+
const url = new URL(row.authorizationUrl);
|
|
103
|
+
const keys = [...url.searchParams.keys()];
|
|
104
|
+
const handoff = url.searchParams.get("handoff");
|
|
105
|
+
if (url.protocol !== "https:" || url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin || url.username || url.password ||
|
|
106
|
+
url.pathname !== HANDOFF || url.hash || keys.length !== 1 || keys[0] !== "handoff" ||
|
|
107
|
+
!handoff || handoff.length > 4096 || /[\s\u0000-\u001f\u007f]/.test(handoff))
|
|
108
|
+
return null;
|
|
109
|
+
return url.toString();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Uses the new personal bearer twins; old clouds retain their supported read-only readiness path. */
|
|
116
|
+
export function linearWorkspaceAdapter(options) {
|
|
117
|
+
const timeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
118
|
+
const consentTimeoutMs = options.consentTimeoutMs ?? 600_000;
|
|
119
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000 ||
|
|
120
|
+
!Number.isSafeInteger(consentTimeoutMs) || consentTimeoutMs < 1 || consentTimeoutMs > 600_000)
|
|
121
|
+
throw new Error("workspace_timeout_invalid");
|
|
122
|
+
const inspect = async (ctx, journal, signal) => {
|
|
123
|
+
const result = await read(ctx, journal, STATUS, timeoutMs, signal);
|
|
124
|
+
if ("status" in result && result.status === 404)
|
|
125
|
+
return { result: await options.fallback.check(ctx, journal, signal), unsupported: true };
|
|
126
|
+
const failure = readFailure(result);
|
|
127
|
+
return { result: failure ?? observation("body" in result ? result.body : undefined, ctx.now().getTime()) };
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
check: async (ctx, journal, signal) => (await inspect(ctx, journal, signal)).result,
|
|
131
|
+
act: async (ctx, journal, signal) => {
|
|
132
|
+
const cfg = loadConfig(ctx.home);
|
|
133
|
+
if (!cfg?.user || !["owner", "admin"].includes(cfg.user.role))
|
|
134
|
+
return { state: "refused", reason: "workspace_admin_required" };
|
|
135
|
+
if (signal?.aborted)
|
|
136
|
+
return waiting("interrupted");
|
|
137
|
+
const before = await inspect(ctx, journal, signal);
|
|
138
|
+
if (before.result.state !== "pending")
|
|
139
|
+
return before.result;
|
|
140
|
+
if (before.unsupported)
|
|
141
|
+
return waiting("cloud_capability_unavailable");
|
|
142
|
+
const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
|
|
143
|
+
if ("status" in start && start.status === 404)
|
|
144
|
+
return waiting("cloud_capability_unavailable");
|
|
145
|
+
const failure = readFailure(start);
|
|
146
|
+
if (failure)
|
|
147
|
+
return failure;
|
|
148
|
+
const url = safeHandoff("body" in start ? start.body : undefined, ctx);
|
|
149
|
+
if (!url)
|
|
150
|
+
return { state: "refused", reason: "workspace_consent_handoff" };
|
|
151
|
+
if (signal?.aborted)
|
|
152
|
+
return waiting("interrupted");
|
|
153
|
+
const current = loadConfig(ctx.home);
|
|
154
|
+
if (!current?.user || current.account !== cfg.account || current.user.id !== cfg.user.id ||
|
|
155
|
+
normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
|
|
156
|
+
return { state: "refused", reason: "workspace_identity_refused" };
|
|
157
|
+
if (!["owner", "admin"].includes(current.user.role))
|
|
158
|
+
return { state: "refused", reason: "workspace_admin_required" };
|
|
159
|
+
ctx.stderr("Approve the organization's Linear connection in your browser. Your personal approval follows.");
|
|
160
|
+
// The signed URL is transient credential material: only the browser receives it.
|
|
161
|
+
try {
|
|
162
|
+
options.openBrowser(url);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return waiting("workspace_browser_unavailable");
|
|
166
|
+
}
|
|
167
|
+
let latest = waiting("workspace_status_unavailable");
|
|
168
|
+
const run = () => pollConsent({ timeoutMs: consentTimeoutMs, signal, sleep: options.sleep,
|
|
169
|
+
readStatus: async (pollSignal) => {
|
|
170
|
+
const value = await inspect(ctx, journal, pollSignal);
|
|
171
|
+
latest = value.result;
|
|
172
|
+
if (value.unsupported)
|
|
173
|
+
return { outcome: "failed", reason: "cloud_capability_unavailable" };
|
|
174
|
+
if (latest.state === "done")
|
|
175
|
+
return { outcome: "connected" };
|
|
176
|
+
if (latest.state === "refused" || latest.state === "failed")
|
|
177
|
+
return { outcome: latest.state, reason: latest.reason };
|
|
178
|
+
return { outcome: latest.state === "pending" ? "absent" : "unavailable" };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const result = options.wait ? await options.wait("Waiting for workspace approval", run) : await run();
|
|
182
|
+
return result.state === "done" ? latest : result;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|