@catalyst-cloud/cli 0.14.3 → 0.14.5

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,231 @@
1
+ import { loadConfig, normalizeBaseUrl, } from "./config.js";
2
+ import { pollConsent } from "./onboard-consent.js";
3
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ const waiting = (reason) => ({
7
+ state: "waiting",
8
+ reason,
9
+ });
10
+ const same = (a, b) => !!b?.user &&
11
+ !!a.user &&
12
+ a.account === b.account &&
13
+ a.user.id === b.user.id &&
14
+ normalizeBaseUrl(a.baseUrl) === normalizeBaseUrl(b.baseUrl);
15
+ /** Headers and body share an owned deadline; abandoned reads cannot rotate local credentials. */
16
+ async function read(ctx, journal, path, timeoutMs, external) {
17
+ const cfg = loadConfig(ctx.home);
18
+ if (!cfg?.user)
19
+ return { reason: "personal_login_required" };
20
+ if (((journal.account ?? journal.tenant) &&
21
+ (journal.account ?? journal.tenant) !== cfg.account) ||
22
+ (journal.membershipId && journal.membershipId !== cfg.user.id) ||
23
+ (journal.baseUrl &&
24
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl)))
25
+ return { reason: "personal_identity_refused" };
26
+ const expiry = cfg.auth
27
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
28
+ : 0;
29
+ const bearer = cfg.key ||
30
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
31
+ ? cfg.auth.accessToken
32
+ : undefined);
33
+ if (!bearer)
34
+ return { reason: "personal_login_refresh_required" };
35
+ const owned = new AbortController();
36
+ const timer = setTimeout(() => owned.abort(), timeoutMs);
37
+ const signal = external
38
+ ? AbortSignal.any([external, owned.signal])
39
+ : owned.signal;
40
+ let remove = () => { };
41
+ try {
42
+ return await new Promise((resolve) => {
43
+ const stopped = () => resolve({
44
+ reason: external?.aborted
45
+ ? "interrupted"
46
+ : "personal_status_unavailable",
47
+ });
48
+ if (signal.aborted) {
49
+ stopped();
50
+ return;
51
+ }
52
+ signal.addEventListener("abort", stopped, { once: true });
53
+ remove = () => signal.removeEventListener("abort", stopped);
54
+ Promise.resolve()
55
+ .then(async () => {
56
+ if (signal.aborted)
57
+ return { reason: "interrupted" };
58
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
59
+ method: "GET",
60
+ redirect: "error",
61
+ headers: {
62
+ authorization: `Bearer ${bearer}`,
63
+ accept: "application/json",
64
+ },
65
+ signal,
66
+ });
67
+ if (response.status !== 200)
68
+ return { status: response.status };
69
+ const body = await response.json();
70
+ if (!same(cfg, loadConfig(ctx.home)))
71
+ return { reason: "personal_identity_refused" };
72
+ return { status: 200, body };
73
+ })
74
+ .then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
75
+ ? stopped()
76
+ : resolve({ reason: "personal_status_unavailable" }));
77
+ });
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ remove();
82
+ }
83
+ }
84
+ function failure(value, start = false) {
85
+ if ("reason" in value)
86
+ return ["personal_login_required", "personal_identity_refused"].includes(value.reason)
87
+ ? { state: "refused", reason: value.reason }
88
+ : waiting(value.reason);
89
+ if (value.status === 401 || value.status === 403)
90
+ return { state: "refused", reason: "personal_consent_refused" };
91
+ if (value.status === 503)
92
+ return waiting("personal_status_unavailable");
93
+ if ([404, 405].includes(value.status))
94
+ return waiting("cloud_capability_unavailable");
95
+ return value.status === 200
96
+ ? null
97
+ : {
98
+ state: "failed",
99
+ reason: start
100
+ ? "personal_consent_start_failed"
101
+ : "personal_status_failed",
102
+ };
103
+ }
104
+ function handoff(body, provider, cfg, now) {
105
+ const row = object(body);
106
+ if (!row ||
107
+ typeof row.authorizationUrl !== "string" ||
108
+ row.authorizationUrl.length > 8192 ||
109
+ /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
110
+ typeof row.expiresAt !== "number" ||
111
+ !Number.isFinite(row.expiresAt) ||
112
+ row.expiresAt <= now ||
113
+ row.expiresAt > now + 630_000)
114
+ return null;
115
+ try {
116
+ const url = new URL(row.authorizationUrl);
117
+ const keys = [...url.searchParams.keys()];
118
+ const token = url.searchParams.get("handoff");
119
+ if (url.protocol !== "https:" ||
120
+ url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin ||
121
+ url.username ||
122
+ url.password ||
123
+ url.pathname !== `/connect/${provider}/personal/start` ||
124
+ url.hash ||
125
+ keys.length !== 1 ||
126
+ keys[0] !== "handoff" ||
127
+ !token ||
128
+ token.length > 4096 ||
129
+ /[\s\u0000-\u001f\u007f]/.test(token))
130
+ return null;
131
+ return url.toString();
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /** The supported personal status reports server-resolved grant availability. A warm grant is
138
+ * not a new provider viewer probe, repository authorization, or workspace installation verdict. */
139
+ export function personalConsentAdapter(options) {
140
+ const timeout = options.requestTimeoutMs ?? 30_000;
141
+ const consentTimeout = options.consentTimeoutMs ?? 600_000;
142
+ if (!["linear", "github"].includes(options.provider) ||
143
+ !Number.isSafeInteger(timeout) ||
144
+ timeout < 1 ||
145
+ timeout > 30_000 ||
146
+ !Number.isSafeInteger(consentTimeout) ||
147
+ consentTimeout < 1 ||
148
+ consentTimeout > 600_000)
149
+ throw new Error("personal_timeout_invalid");
150
+ const statusPath = `/api/v1/me/connections/${options.provider}/personal`;
151
+ const inspect = async (ctx, journal, signal) => {
152
+ const response = await read(ctx, journal, statusPath, timeout, signal);
153
+ const failed = failure(response);
154
+ if (failed)
155
+ return failed;
156
+ const body = object("body" in response ? response.body : undefined);
157
+ if (!body ||
158
+ typeof body.connected !== "boolean" ||
159
+ (body.reason !== undefined &&
160
+ (body.connected || body.reason !== "lapsed")))
161
+ return { state: "failed", reason: "personal_status_shape" };
162
+ return body.connected
163
+ ? {
164
+ state: "done",
165
+ evidence: {
166
+ provider: options.provider,
167
+ checkedAt: ctx.now().getTime(),
168
+ },
169
+ }
170
+ : { state: "pending" };
171
+ };
172
+ return {
173
+ check: inspect,
174
+ act: async (ctx, journal, signal) => {
175
+ if (signal?.aborted)
176
+ return waiting("interrupted");
177
+ const cfg = loadConfig(ctx.home);
178
+ if (!cfg?.user)
179
+ return { state: "refused", reason: "personal_login_required" };
180
+ const before = await inspect(ctx, journal, signal);
181
+ if (before.state !== "pending")
182
+ return before;
183
+ const start = await read(ctx, journal, `/connect/${options.provider}/personal/start`, timeout, signal);
184
+ if ("status" in start &&
185
+ start.status === 409 &&
186
+ options.provider === "linear")
187
+ return waiting("linear_workspace_required");
188
+ const failed = failure(start, true);
189
+ if (failed)
190
+ return failed;
191
+ const url = handoff("body" in start ? start.body : undefined, options.provider, cfg, ctx.now().getTime());
192
+ if (!url)
193
+ return { state: "refused", reason: "personal_consent_handoff" };
194
+ if (signal?.aborted)
195
+ return waiting("interrupted");
196
+ if (!same(cfg, loadConfig(ctx.home)))
197
+ return { state: "refused", reason: "personal_identity_refused" };
198
+ const name = options.provider === "linear" ? "Linear" : "GitHub";
199
+ ctx.stderr(`Approve your personal ${name} connection in the browser.`);
200
+ // Signed continuation credentials belong only to the browser, never transcript or journal.
201
+ try {
202
+ options.openBrowser(url);
203
+ }
204
+ catch {
205
+ return waiting("personal_browser_unavailable");
206
+ }
207
+ let latest = waiting("personal_status_unavailable");
208
+ const run = () => pollConsent({
209
+ timeoutMs: consentTimeout,
210
+ signal,
211
+ sleep: options.sleep,
212
+ readStatus: async (pollSignal) => {
213
+ latest = await inspect(ctx, journal, pollSignal);
214
+ if (latest.reason === "cloud_capability_unavailable")
215
+ return { outcome: "waiting", reason: latest.reason };
216
+ if (latest.state === "done")
217
+ return { outcome: "connected" };
218
+ if (latest.state === "refused" || latest.state === "failed")
219
+ return { outcome: latest.state, reason: latest.reason };
220
+ return {
221
+ outcome: latest.state === "pending" ? "absent" : "unavailable",
222
+ };
223
+ },
224
+ });
225
+ const result = options.wait
226
+ ? await options.wait(`Waiting for your ${name} approval`, run)
227
+ : await run();
228
+ return result.state === "done" ? latest : result;
229
+ },
230
+ };
231
+ }
@@ -2,17 +2,22 @@ import { existsSync, realpathSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { openBrowser } from "./browser.js";
5
- import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest } from "./config.js";
5
+ import { defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
6
6
  import { CliError } from "./errors.js";
7
7
  import { cmdLegacy, findLegacy } from "./legacy.js";
8
8
  import { bearerFor } from "./oauth.js";
9
- import { pollConsent } from "./onboard-consent.js";
9
+ import { personalConsentAdapter } from "./onboard-personal.js";
10
10
  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
- const waiting = (reason) => ({ state: "waiting", reason });
17
+ const waiting = (reason) => ({
18
+ state: "waiting",
19
+ reason,
20
+ });
16
21
  function sameHome(a, b) {
17
22
  try {
18
23
  return realpathSync(a) === realpathSync(b);
@@ -28,130 +33,221 @@ export function createOnboardRuntime(args, ctx, hooks) {
28
33
  if (!cfg)
29
34
  return null;
30
35
  const requested = args.baseUrl ?? stepCtx.env.CATALYST_CLOUD_BASE_URL;
31
- if (requested && normalizeBaseUrl(requested) !== normalizeBaseUrl(cfg.baseUrl))
36
+ if (requested &&
37
+ normalizeBaseUrl(requested) !== normalizeBaseUrl(cfg.baseUrl))
32
38
  throw new CliError("the saved login uses another cloud; resume against that cloud", "onboard-base-url-mismatch", 12);
33
39
  if (!cfg.user)
34
40
  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))))
41
+ if (journal &&
42
+ (((journal.account ?? journal.tenant) &&
43
+ (journal.account ?? journal.tenant) !== cfg.account) ||
44
+ (journal.membershipId && journal.membershipId !== cfg.user.id) ||
45
+ (journal.baseUrl &&
46
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))))
38
47
  throw new CliError("the saved setup belongs to another workspace or member", "onboard-identity-mismatch", 12);
39
48
  const me = await fetchMe(cfg.baseUrl, await bearerFor(stepCtx, cfg), stepCtx.fetch);
40
49
  if (!me.user || me.account !== cfg.account || me.user.id !== cfg.user.id)
41
50
  throw new CliError("the saved login and live member do not match", "onboard-identity-mismatch", 12);
42
51
  // /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 };
52
+ return {
53
+ account: me.account,
54
+ membershipId: me.user.id,
55
+ baseUrl: normalizeBaseUrl(cfg.baseUrl),
56
+ role: me.user.role,
57
+ };
44
58
  };
45
59
  const legacyCheck = async (stepCtx) => {
46
- const found = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform).filter(item => !item.data).length;
60
+ const found = findLegacy(stepCtx.home, hooks.legacy?.platform ?? process.platform).filter((item) => !item.data).length;
47
61
  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 } };
62
+ return {
63
+ state: "skipped",
64
+ reason: "fake_home_report_only",
65
+ evidence: { found, remaining: found },
66
+ };
67
+ return {
68
+ state: found ? "pending" : "done",
69
+ evidence: { found, remaining: found },
70
+ };
50
71
  };
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" };
72
+ const personalAdapter = (provider) => personalConsentAdapter({
73
+ provider,
74
+ openBrowser: hooks.openBrowser ?? openBrowser,
75
+ sleep: hooks.sleep,
76
+ wait: hooks.ui
77
+ ? (message, run) => hooks.ui.wait(message, run)
78
+ : undefined,
79
+ });
80
+ const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
81
+ ? (teams) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })))
82
+ : undefined);
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");
106
+ },
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" };
127
+ },
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" };
142
+ },
143
+ act: async (stepCtx, _journal, signal) => {
144
+ return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
145
+ },
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" },
175
+ },
176
+ },
177
+ housekeeping: {
178
+ check: async () => waiting("housekeeping_service_unverified"),
179
+ },
180
+ ready: { check: hooks.ready },
74
181
  };
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),
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),
89
243
  });
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);
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.
116
247
  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
- },
248
+ identity: (journal) => identity(ctx, journal),
249
+ bindSignals: true,
250
+ ui: hooks.ui,
251
+ adapters,
156
252
  };
157
253
  }