@catalyst-cloud/cli 0.14.4 → 0.14.6

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.
@@ -0,0 +1,329 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { pollConsent } from "./onboard-consent.js";
3
+ const STATUS = "/api/v1/me/connections/github/workspace";
4
+ const HANDOFF = "/connect/github/workspace/handoff";
5
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
+ const states = new Set([
7
+ "connected",
8
+ "missing-scope",
9
+ "expired-or-revoked",
10
+ "unreachable",
11
+ "not-connected",
12
+ ]);
13
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
14
+ ? v
15
+ : null;
16
+ const waiting = (reason) => ({
17
+ state: "waiting",
18
+ reason,
19
+ });
20
+ const nullableText = (v) => v === null || typeof v === "string";
21
+ /** A bounded read never rotates credentials or writes a token after the receipt lock is released. */
22
+ async function read(ctx, journal, path, timeoutMs, external) {
23
+ const cfg = loadConfig(ctx.home);
24
+ if (!cfg?.user)
25
+ return { reason: "personal_login_required" };
26
+ const personId = cfg.user.id;
27
+ if ((journal.account ?? journal.tenant) !== cfg.account ||
28
+ journal.membershipId !== cfg.user.id ||
29
+ !journal.baseUrl ||
30
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
31
+ return { reason: "github_installation_identity_refused" };
32
+ const expiry = cfg.auth
33
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
34
+ : 0;
35
+ const bearer = cfg.key ||
36
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
37
+ ? cfg.auth.accessToken
38
+ : undefined);
39
+ if (!bearer)
40
+ return { reason: "github_installation_login_refresh_required" };
41
+ const deadline = new AbortController();
42
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
43
+ const signal = external
44
+ ? AbortSignal.any([external, deadline.signal])
45
+ : deadline.signal;
46
+ let remove = () => { };
47
+ try {
48
+ return await new Promise((resolve) => {
49
+ const stopped = () => resolve({
50
+ reason: external?.aborted
51
+ ? "interrupted"
52
+ : "github_installation_status_unavailable",
53
+ });
54
+ if (signal.aborted) {
55
+ stopped();
56
+ return;
57
+ }
58
+ signal.addEventListener("abort", stopped, { once: true });
59
+ remove = () => signal.removeEventListener("abort", stopped);
60
+ Promise.resolve()
61
+ .then(async () => {
62
+ if (signal.aborted)
63
+ return { reason: "interrupted" };
64
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
65
+ method: "GET",
66
+ redirect: "error",
67
+ headers: {
68
+ authorization: `Bearer ${bearer}`,
69
+ accept: "application/json",
70
+ },
71
+ signal,
72
+ });
73
+ // Refusal bodies and provider diagnostics are never parsed, returned or printed.
74
+ if (response.status !== 200)
75
+ return { status: response.status };
76
+ const body = await response.json();
77
+ const current = loadConfig(ctx.home);
78
+ if (!current?.user ||
79
+ current.account !== cfg.account ||
80
+ current.user.id !== personId ||
81
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
82
+ return { reason: "github_installation_identity_refused" };
83
+ return { status: 200, body };
84
+ })
85
+ .then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
86
+ ? stopped()
87
+ : resolve({ reason: "github_installation_status_unavailable" }));
88
+ });
89
+ }
90
+ finally {
91
+ clearTimeout(timer);
92
+ remove();
93
+ }
94
+ }
95
+ function readFailure(value) {
96
+ if ("reason" in value)
97
+ return value.reason === "github_installation_identity_refused" ||
98
+ value.reason === "personal_login_required"
99
+ ? { state: "refused", reason: value.reason }
100
+ : waiting(value.reason);
101
+ if (value.status === 401 || value.status === 403)
102
+ return { state: "refused", reason: "github_installation_consent_refused" };
103
+ return value.status === 200
104
+ ? null
105
+ : waiting("github_installation_status_unavailable");
106
+ }
107
+ /** Every reported installation must have a fresh live installation/App-permission probe.
108
+ * This proves installation connectivity, not access to repositories selected later in Q3. */
109
+ function observation(body, now) {
110
+ const row = object(body);
111
+ if (!row ||
112
+ typeof row.connected !== "boolean" ||
113
+ !Array.isArray(row.installations) ||
114
+ !Array.isArray(row.pending) ||
115
+ row.installations.length + row.pending.length > 100)
116
+ return waiting("github_installation_status_shape");
117
+ const ids = [];
118
+ let unknown = false;
119
+ let allConnected = row.installations.length > 0;
120
+ let checkedAt = now + 30_000;
121
+ for (const value of row.installations) {
122
+ const item = object(value);
123
+ const verification = object(item?.verification);
124
+ if (!item ||
125
+ typeof item.installationId !== "string" ||
126
+ !idPattern.test(item.installationId) ||
127
+ ids.includes(item.installationId) ||
128
+ !nullableText(item.githubOrg) ||
129
+ (typeof item.githubOrg === "string" &&
130
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/.test(item.githubOrg)) ||
131
+ !verification)
132
+ return waiting("github_installation_status_shape");
133
+ ids.push(item.installationId);
134
+ if (verification.source === "none" &&
135
+ verification.state === "not-run" &&
136
+ verification.checkedAt === null) {
137
+ unknown = true;
138
+ allConnected = false;
139
+ continue;
140
+ }
141
+ if (verification.source !== "live-probe" ||
142
+ typeof verification.state !== "string" ||
143
+ !states.has(verification.state) ||
144
+ typeof verification.checkedAt !== "number" ||
145
+ !Number.isFinite(verification.checkedAt) ||
146
+ verification.checkedAt < now - 120_000 ||
147
+ verification.checkedAt > now + 30_000)
148
+ return waiting("github_installation_status_shape");
149
+ if (verification.missing !== undefined &&
150
+ (!Array.isArray(verification.missing) ||
151
+ verification.missing.length > 128 ||
152
+ !verification.missing.every((name) => typeof name === "string" &&
153
+ (/^[A-Za-z0-9_-]{1,128}$/.test(name) ||
154
+ /^[A-Za-z0-9_-]{1,80} \((?:read|write|admin)\)$/.test(name)))))
155
+ return waiting("github_installation_status_shape");
156
+ checkedAt = Math.min(checkedAt, verification.checkedAt);
157
+ if (verification.state === "unreachable")
158
+ unknown = true;
159
+ if (verification.state !== "connected")
160
+ allConnected = false;
161
+ }
162
+ for (const value of row.pending) {
163
+ const pending = object(value);
164
+ if (!pending ||
165
+ !nullableText(pending.githubOrg) ||
166
+ (typeof pending.githubOrg === "string" &&
167
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/.test(pending.githubOrg)) ||
168
+ typeof pending.requestedAt !== "number" ||
169
+ !Number.isFinite(pending.requestedAt) ||
170
+ pending.requestedAt < 0)
171
+ return waiting("github_installation_status_shape");
172
+ }
173
+ if (row.connected !== allConnected)
174
+ return waiting("github_installation_status_shape");
175
+ if (unknown)
176
+ return waiting("github_installation_status_unavailable");
177
+ if (!allConnected)
178
+ return { state: "pending" };
179
+ return {
180
+ state: "done",
181
+ evidence: {
182
+ provider: "github",
183
+ installation: JSON.stringify(ids),
184
+ checkedAt,
185
+ },
186
+ };
187
+ }
188
+ function safeHandoff(body, ctx) {
189
+ const row = object(body);
190
+ const cfg = loadConfig(ctx.home);
191
+ if (!cfg ||
192
+ !row ||
193
+ typeof row.authorizationUrl !== "string" ||
194
+ row.authorizationUrl.length > 8192 ||
195
+ /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
196
+ typeof row.expiresAt !== "number" ||
197
+ !Number.isFinite(row.expiresAt))
198
+ return null;
199
+ const remaining = row.expiresAt - ctx.now().getTime();
200
+ // Thirty seconds accommodates clock skew; an expired link is never launched.
201
+ if (remaining <= 0 || remaining > 630_000)
202
+ return null;
203
+ try {
204
+ const url = new URL(row.authorizationUrl);
205
+ const keys = [...url.searchParams.keys()];
206
+ const handoff = url.searchParams.get("handoff");
207
+ if (url.protocol !== "https:" ||
208
+ url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin ||
209
+ url.username ||
210
+ url.password ||
211
+ url.pathname !== HANDOFF ||
212
+ url.hash ||
213
+ keys.length !== 1 ||
214
+ keys[0] !== "handoff" ||
215
+ !handoff ||
216
+ handoff.length > 4096 ||
217
+ /[\s\u0000-\u001f\u007f]/.test(handoff))
218
+ return null;
219
+ return url.toString();
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ /** Uses personal bearer App-install routes. Older clouds wait without a browser/session fallback. */
226
+ export function githubInstallationAdapter(options) {
227
+ const timeoutMs = options.requestTimeoutMs ?? 30_000;
228
+ const consentTimeoutMs = options.consentTimeoutMs ?? 600_000;
229
+ if (!Number.isSafeInteger(timeoutMs) ||
230
+ timeoutMs < 1 ||
231
+ timeoutMs > 30_000 ||
232
+ !Number.isSafeInteger(consentTimeoutMs) ||
233
+ consentTimeoutMs < 1 ||
234
+ consentTimeoutMs > 600_000)
235
+ throw new Error("github_installation_timeout_invalid");
236
+ const inspect = async (ctx, journal, signal) => {
237
+ const result = await read(ctx, journal, STATUS, timeoutMs, signal);
238
+ if ("status" in result && [404, 405].includes(result.status))
239
+ return {
240
+ result: waiting("cloud_capability_unavailable"),
241
+ unsupported: true,
242
+ };
243
+ const failure = readFailure(result);
244
+ return {
245
+ result: failure ??
246
+ observation("body" in result ? result.body : undefined, ctx.now().getTime()),
247
+ };
248
+ };
249
+ return {
250
+ check: async (ctx, journal, signal) => (await inspect(ctx, journal, signal)).result,
251
+ act: async (ctx, journal, signal) => {
252
+ const cfg = loadConfig(ctx.home);
253
+ if (!cfg?.user || !["owner", "admin"].includes(cfg.user.role))
254
+ return {
255
+ state: "refused",
256
+ reason: "github_installation_admin_required",
257
+ };
258
+ if (signal?.aborted)
259
+ return waiting("interrupted");
260
+ const before = await inspect(ctx, journal, signal);
261
+ if (before.result.state !== "pending")
262
+ return before.result;
263
+ if (before.unsupported)
264
+ return waiting("cloud_capability_unavailable");
265
+ const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
266
+ if ("status" in start && [404, 405].includes(start.status))
267
+ return waiting("cloud_capability_unavailable");
268
+ const failure = readFailure(start);
269
+ if (failure)
270
+ return failure;
271
+ const url = safeHandoff("body" in start ? start.body : undefined, ctx);
272
+ if (!url)
273
+ return {
274
+ state: "refused",
275
+ reason: "github_installation_consent_handoff",
276
+ };
277
+ if (signal?.aborted)
278
+ return waiting("interrupted");
279
+ const current = loadConfig(ctx.home);
280
+ if (!current?.user ||
281
+ current.account !== cfg.account ||
282
+ current.user.id !== cfg.user.id ||
283
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
284
+ return {
285
+ state: "refused",
286
+ reason: "github_installation_identity_refused",
287
+ };
288
+ if (!["owner", "admin"].includes(current.user.role))
289
+ return {
290
+ state: "refused",
291
+ reason: "github_installation_admin_required",
292
+ };
293
+ ctx.stderr("Approve the GitHub App installation in your browser. Your personal GitHub connection is checked separately.");
294
+ // The signed URL is transient credential material: only the browser receives it.
295
+ try {
296
+ options.openBrowser(url);
297
+ }
298
+ catch {
299
+ return waiting("github_installation_browser_unavailable");
300
+ }
301
+ let latest = waiting("github_installation_status_unavailable");
302
+ const run = () => pollConsent({
303
+ timeoutMs: consentTimeoutMs,
304
+ signal,
305
+ sleep: options.sleep,
306
+ readStatus: async (pollSignal) => {
307
+ const value = await inspect(ctx, journal, pollSignal);
308
+ latest = value.result;
309
+ if (value.unsupported)
310
+ return {
311
+ outcome: "waiting",
312
+ reason: "cloud_capability_unavailable",
313
+ };
314
+ if (latest.state === "done")
315
+ return { outcome: "connected" };
316
+ if (latest.state === "refused" || latest.state === "failed")
317
+ return { outcome: latest.state, reason: latest.reason };
318
+ return {
319
+ outcome: latest.state === "pending" ? "absent" : "unavailable",
320
+ };
321
+ },
322
+ });
323
+ const result = options.wait
324
+ ? await options.wait("Waiting for GitHub App approval", run)
325
+ : await run();
326
+ return result.state === "done" ? latest : result;
327
+ },
328
+ };
329
+ }
@@ -90,7 +90,7 @@ function failure(value, start = false) {
90
90
  return { state: "refused", reason: "personal_consent_refused" };
91
91
  if (value.status === 503)
92
92
  return waiting("personal_status_unavailable");
93
- if (value.status === 404)
93
+ if ([404, 405].includes(value.status))
94
94
  return waiting("cloud_capability_unavailable");
95
95
  return value.status === 200
96
96
  ? null
@@ -211,6 +211,8 @@ export function personalConsentAdapter(options) {
211
211
  sleep: options.sleep,
212
212
  readStatus: async (pollSignal) => {
213
213
  latest = await inspect(ctx, journal, pollSignal);
214
+ if (latest.reason === "cloud_capability_unavailable")
215
+ return { outcome: "waiting", reason: latest.reason };
214
216
  if (latest.state === "done")
215
217
  return { outcome: "connected" };
216
218
  if (latest.state === "refused" || latest.state === "failed")
@@ -11,6 +11,8 @@ import { boundedOnboardSignin } from "./onboard-signin.js";
11
11
  import { existingLinearAdapters } from "./onboard-existing.js";
12
12
  import { linearWorkspaceAdapter } from "./onboard-workspace.js";
13
13
  import { existingRepositoryAdapter } from "./onboard-repositories.js";
14
+ import { githubInstallationAdapter } from "./onboard-github.js";
15
+ import { guardOnboardCapabilities, } from "./onboard-capabilities.js";
14
16
  import { fetchMe } from "./transport.js";
15
17
  const waiting = (reason) => ({
16
18
  state: "waiting",
@@ -67,10 +69,6 @@ export function createOnboardRuntime(args, ctx, hooks) {
67
69
  evidence: { found, remaining: found },
68
70
  };
69
71
  };
70
- const unsupported = {
71
- check: async () => waiting("cloud_capability_unavailable"),
72
- act: async () => waiting("cloud_capability_unavailable"),
73
- };
74
72
  const personalAdapter = (provider) => personalConsentAdapter({
75
73
  provider,
76
74
  openBrowser: hooks.openBrowser ?? openBrowser,
@@ -82,102 +80,174 @@ export function createOnboardRuntime(args, ctx, hooks) {
82
80
  const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
83
81
  ? (teams) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })))
84
82
  : undefined);
85
- return {
86
- identity: (journal) => identity(ctx, journal),
87
- bindSignals: true,
88
- ui: hooks.ui,
89
- adapters: {
90
- machine: {
91
- check: async () => process.platform === "darwin" || process.platform === "linux"
92
- ? { state: "done", evidence: { provider: process.platform } }
93
- : waiting("machine_platform_unsupported"),
83
+ const adapters = {
84
+ machine: {
85
+ check: async () => process.platform === "darwin" || process.platform === "linux"
86
+ ? { state: "done", evidence: { provider: process.platform } }
87
+ : waiting("machine_platform_unsupported"),
88
+ },
89
+ cli: {
90
+ check: async () => ({
91
+ state: "done",
92
+ evidence: { version: readManifest().version },
93
+ }),
94
+ },
95
+ skills: {
96
+ check: async (stepCtx) => {
97
+ const cfg = loadConfig(stepCtx.home);
98
+ const dir = stepCtx.env.CATALYST_SKILLS_DIR ??
99
+ cfg?.skillsDir ??
100
+ defaultSkillsDirFor(stepCtx.home);
101
+ const names = hooks.skillNames ?? [];
102
+ const count = names.filter((name) => existsSync(join(dir, name, "SKILL.md"))).length;
103
+ return names.length > 0 && count === names.length
104
+ ? { state: "done", evidence: { count, path: dir } }
105
+ : waiting("skills_install_unverified");
94
106
  },
95
- cli: {
96
- check: async () => ({
97
- state: "done",
98
- evidence: { version: readManifest().version },
99
- }),
107
+ },
108
+ legacy: {
109
+ check: legacyCheck,
110
+ act: async (stepCtx) => {
111
+ const before = await legacyCheck(stepCtx);
112
+ if (before.state !== "pending")
113
+ return before;
114
+ // Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
115
+ const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
116
+ const code = await cmdLegacy({
117
+ ...args,
118
+ command: "legacy",
119
+ subcommand: null,
120
+ rest: [],
121
+ flags: { remove: true, yes: true },
122
+ json: false,
123
+ }, quiet, hooks.legacy);
124
+ return code === 0
125
+ ? legacyCheck(stepCtx)
126
+ : { state: "failed", reason: "legacy_cleanup_failed" };
100
127
  },
101
- skills: {
102
- check: async (stepCtx) => {
103
- const cfg = loadConfig(stepCtx.home);
104
- const dir = stepCtx.env.CATALYST_SKILLS_DIR ??
105
- cfg?.skillsDir ??
106
- defaultSkillsDirFor(stepCtx.home);
107
- const names = hooks.skillNames ?? [];
108
- const count = names.filter((name) => existsSync(join(dir, name, "SKILL.md"))).length;
109
- return names.length > 0 && count === names.length
110
- ? { state: "done", evidence: { count, path: dir } }
111
- : waiting("skills_install_unverified");
112
- },
128
+ },
129
+ signin: {
130
+ check: async (stepCtx, journal) => {
131
+ const who = await identity(stepCtx, journal);
132
+ return who
133
+ ? {
134
+ state: "done",
135
+ evidence: {
136
+ account: who.account,
137
+ membershipId: who.membershipId,
138
+ role: who.role,
139
+ },
140
+ }
141
+ : { state: "pending" };
113
142
  },
114
- legacy: {
115
- check: legacyCheck,
116
- act: async (stepCtx) => {
117
- const before = await legacyCheck(stepCtx);
118
- if (before.state !== "pending")
119
- return before;
120
- // Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
121
- const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
122
- const code = await cmdLegacy({
123
- ...args,
124
- command: "legacy",
125
- subcommand: null,
126
- rest: [],
127
- flags: { remove: true, yes: true },
128
- json: false,
129
- }, quiet, hooks.legacy);
130
- return code === 0
131
- ? legacyCheck(stepCtx)
132
- : { state: "failed", reason: "legacy_cleanup_failed" };
133
- },
143
+ act: async (stepCtx, _journal, signal) => {
144
+ return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
134
145
  },
135
- signin: {
136
- check: async (stepCtx, journal) => {
137
- const who = await identity(stepCtx, journal);
138
- return who
139
- ? {
140
- state: "done",
141
- evidence: {
142
- account: who.account,
143
- membershipId: who.membershipId,
144
- role: who.role,
145
- },
146
- }
147
- : { state: "pending" };
148
- },
149
- act: async (stepCtx, _journal, signal) => {
150
- return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
146
+ },
147
+ ...linear,
148
+ "linear.workspace": linearWorkspaceAdapter({
149
+ fallback: linear["linear.workspace"],
150
+ openBrowser: hooks.openBrowser ?? openBrowser,
151
+ wait: hooks.ui
152
+ ? (message, work) => hooks.ui.wait(message, work)
153
+ : undefined,
154
+ sleep: hooks.sleep,
155
+ }),
156
+ "github.install": githubInstallationAdapter({
157
+ openBrowser: hooks.openBrowser ?? openBrowser,
158
+ wait: hooks.ui
159
+ ? (message, work) => hooks.ui.wait(message, work)
160
+ : undefined,
161
+ sleep: hooks.sleep,
162
+ }),
163
+ "github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories
164
+ ? (repositories) => hooks.ui.chooseRepositories(repositories.map((row) => ({ ...row })))
165
+ : undefined),
166
+ "linear.personal": personalAdapter("linear"),
167
+ "github.personal": personalAdapter("github"),
168
+ daemon: {
169
+ check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true)
170
+ ? waiting("local_sync_capability_unavailable")
171
+ : {
172
+ state: "skipped",
173
+ reason: "local_sync_not_selected",
174
+ evidence: { provider: "cloud" },
151
175
  },
152
- },
153
- ...linear,
154
- "linear.workspace": linearWorkspaceAdapter({
155
- fallback: linear["linear.workspace"],
156
- openBrowser: hooks.openBrowser ?? openBrowser,
157
- wait: hooks.ui
158
- ? (message, work) => hooks.ui.wait(message, work)
159
- : undefined,
160
- sleep: hooks.sleep,
161
- }),
162
- "github.install": unsupported,
163
- "github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories
164
- ? (repositories) => hooks.ui.chooseRepositories(repositories.map((row) => ({ ...row })))
165
- : undefined),
166
- "linear.personal": personalAdapter("linear"),
167
- "github.personal": personalAdapter("github"),
168
- daemon: {
169
- check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true)
170
- ? waiting("local_sync_capability_unavailable")
171
- : {
172
- state: "skipped",
173
- reason: "local_sync_not_selected",
174
- evidence: { provider: "cloud" },
175
- },
176
- },
177
- housekeeping: {
178
- check: async () => waiting("housekeeping_service_unverified"),
179
- },
180
- ready: { check: hooks.ready },
181
176
  },
177
+ housekeeping: {
178
+ check: async () => waiting("housekeeping_service_unverified"),
179
+ },
180
+ ready: { check: hooks.ready },
181
+ };
182
+ const get = (path) => ({ method: "GET", path });
183
+ const capabilities = {
184
+ "linear.workspace": {
185
+ check: [get("/api/v1/me/connections/linear/workspace")],
186
+ act: [
187
+ get("/api/v1/me/connections/linear/workspace"),
188
+ get("/api/v1/me/connections/linear/workspace/start"),
189
+ ],
190
+ fallback: "connections",
191
+ },
192
+ "linear.personal": {
193
+ check: [get("/api/v1/me/connections/linear/personal")],
194
+ act: [
195
+ get("/api/v1/me/connections/linear/personal"),
196
+ get("/connect/linear/personal/start"),
197
+ ],
198
+ fallback: "personalConnections",
199
+ },
200
+ "github.install": {
201
+ check: [get("/api/v1/me/connections/github/workspace")],
202
+ act: [
203
+ get("/api/v1/me/connections/github/workspace"),
204
+ get("/api/v1/me/connections/github/workspace/start"),
205
+ ],
206
+ fallback: "connections",
207
+ },
208
+ "github.personal": {
209
+ check: [get("/api/v1/me/connections/github/personal")],
210
+ act: [
211
+ get("/api/v1/me/connections/github/personal"),
212
+ get("/connect/github/personal/start"),
213
+ ],
214
+ fallback: "personalConnections",
215
+ },
216
+ "linear.team": {
217
+ check: [
218
+ get("/api/v1/agent/teams"),
219
+ get("/api/v1/agent/tenant/readiness"),
220
+ ],
221
+ fallback: "connections",
222
+ },
223
+ "linear.automations": {
224
+ check: [get("/api/v1/agent/tenant/readiness")],
225
+ fallback: "connections",
226
+ },
227
+ "github.repos": {
228
+ check: [get("/api/v1/repos"), get("/api/v1/agent/contract")],
229
+ fallback: "connections",
230
+ },
231
+ ready: {
232
+ check: [get("/api/v1/agent/contract")],
233
+ fallback: "connections",
234
+ },
235
+ };
236
+ for (const [id, options] of Object.entries(capabilities)) {
237
+ const step = id;
238
+ const adapter = adapters[step];
239
+ if (adapter)
240
+ adapters[step] = guardOnboardCapabilities(adapter, {
241
+ ...options,
242
+ message: (text) => hooks.ui ? hooks.ui.message(text) : ctx.stderr(text),
243
+ });
244
+ }
245
+ // Public auth discovery and the existing /me sign-in bootstrap establish identity before the
246
+ // tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
247
+ return {
248
+ identity: (journal) => identity(ctx, journal),
249
+ bindSignals: true,
250
+ ui: hooks.ui,
251
+ adapters,
182
252
  };
183
253
  }
@@ -141,7 +141,19 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
141
141
  personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
142
142
  personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
143
143
  personal_browser_unavailable: "The browser could not open. Run catalyst onboard to try again.",
144
- cloud_capability_unavailable: "Your cloud does not support this setup step yet.",
144
+ cloud_capability_unavailable: "This setup step is not available on this server yet. Use the web app link shown above, then resume after the server update.",
145
+ onboarding_capability_unavailable: "The server's setup capabilities could not be checked. Run catalyst onboard to try again.",
146
+ onboarding_capability_identity_unverified: "The server's setup capabilities belong to another login or workspace. Sign in to the original workspace to resume.",
147
+ onboarding_capability_login_refresh_required: "Your login needs a refresh before setup capabilities can be checked. Run catalyst login, then catalyst onboard.",
148
+ workspace_consent_refused: "The server did not accept this login for Linear workspace setup. Sign in again or ask your workspace administrator.",
149
+ github_installation_status_unavailable: "Your GitHub App installation could not be checked. Run catalyst onboard to try again.",
150
+ github_installation_status_shape: "The server returned a GitHub installation result that could not be verified.",
151
+ github_installation_consent_handoff: "The GitHub installation approval link could not be verified. Run catalyst onboard to try again.",
152
+ github_installation_consent_refused: "The server did not accept this login for GitHub App setup. Sign in again or ask your workspace administrator.",
153
+ github_installation_identity_refused: "GitHub setup belongs to another login or workspace. Sign in to the original workspace to resume.",
154
+ github_installation_admin_required: "A workspace owner or administrator approves the GitHub App installation.",
155
+ github_installation_login_refresh_required: "Your login needs a refresh. Run catalyst onboard to resume.",
156
+ github_installation_browser_unavailable: "The GitHub App approval page could not be opened. Use your workspace's Connections page, then resume.",
145
157
  step_not_available_in_this_release: "This setup step is not available yet.",
146
158
  prerequisite_not_ready: "Waiting for an earlier setup step.",
147
159
  local_sync_not_selected: "Using cloud reads. Local sync was not selected.",