@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.
- package/CHANGELOG.md +11 -0
- package/dist/onboard-capabilities.js +177 -0
- package/dist/onboard-consent.js +38 -11
- package/dist/onboard-github.js +329 -0
- package/dist/onboard-personal.js +231 -0
- package/dist/onboard-runtime.js +209 -113
- package/dist/onboard-ui.js +125 -47
- package/dist/onboard-workspace.js +150 -46
- package/dist/onboard.js +251 -65
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- 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,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
|
+
}
|
package/dist/onboard-runtime.js
CHANGED
|
@@ -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 {
|
|
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) => ({
|
|
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 &&
|
|
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 &&
|
|
36
|
-
(journal.
|
|
37
|
-
|
|
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 {
|
|
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 {
|
|
49
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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),
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
}
|