@catalyst-cloud/cli 0.13.1 → 0.14.1
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 +8 -0
- package/README.md +2 -2
- package/dist/args.js +291 -69
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +109 -17
- package/dist/config.js +3 -1
- package/dist/env/draft.js +134 -0
- package/dist/env/legacy.js +54 -0
- package/dist/env/parse-env.js +50 -0
- package/dist/env/settings-toml.js +66 -0
- package/dist/env.js +126 -26
- package/dist/legacy.js +16 -17
- package/dist/onboard-consent.js +65 -0
- package/dist/onboard.js +836 -0
- package/dist/project.js +75 -13
- package/dist/ready.js +11 -1
- package/dist/secret.js +24 -3
- package/dist/var.js +118 -0
- package/package.json +3 -2
- 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/catalyst-onboard/references/declaring-a-repository.md +19 -12
- package/skills/catalyst-onboard/references/reading-ready.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +4 -4
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +2 -2
- package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +885 -196
- 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 +4 -4
- package/skills/what-this-repo-needs/scripts/check.mjs +12 -13
- package/skills/whats-happening/SKILL.md +19 -35
- package/skills/whats-happening/references/coding-accounts.md +6 -19
- package/skills/whats-happening/references/routing-work.md +4 -15
- package/skills/whats-happening/references/settings-and-where-they-live.md +15 -18
- package/skills/whats-happening/references/stages-and-mapping.md +12 -18
- package/skills/whats-happening/references/status-reply.md +11 -29
- package/skills/whats-happening/references/the-ladder.md +15 -25
- package/skills/whats-happening/references/what-runs-next.md +7 -26
- package/skills/whats-happening/references/when-a-phase-fails.md +16 -22
- package/skills/whats-happening/references/why-is-it-stuck.md +38 -48
- package/dist/env/declaration-rules.js +0 -163
package/dist/onboard.js
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
7
|
+
import { parseMachinePaths, resolveCatalystPath, } from "../vendor/paths/index.js";
|
|
8
|
+
import { machinePathsFile } from "../vendor/paths/node.js";
|
|
9
|
+
import { CliError, UsageError } from "./errors.js";
|
|
10
|
+
export const ONBOARD_STEPS = [
|
|
11
|
+
"machine",
|
|
12
|
+
"cli",
|
|
13
|
+
"skills",
|
|
14
|
+
"legacy",
|
|
15
|
+
"signin",
|
|
16
|
+
"linear.workspace",
|
|
17
|
+
"linear.personal",
|
|
18
|
+
"linear.team",
|
|
19
|
+
"linear.adopt",
|
|
20
|
+
"linear.automations",
|
|
21
|
+
"github.install",
|
|
22
|
+
"github.repos",
|
|
23
|
+
"projects",
|
|
24
|
+
"accounts",
|
|
25
|
+
"settings",
|
|
26
|
+
"values",
|
|
27
|
+
"capacity",
|
|
28
|
+
"daemon",
|
|
29
|
+
"housekeeping",
|
|
30
|
+
"first-ticket",
|
|
31
|
+
"ready",
|
|
32
|
+
];
|
|
33
|
+
const EXIT_FAILED = 10;
|
|
34
|
+
const EXIT_WAITING = 11;
|
|
35
|
+
const EXIT_REFUSED = 12;
|
|
36
|
+
/** The bootstrap exports its actual state role so handoff cannot take a second lock. */
|
|
37
|
+
export function onboardStateRoot(home, env = {}) {
|
|
38
|
+
const bootstrap = env.CATALYST_INSTALL_STATE_DIR;
|
|
39
|
+
if (bootstrap !== undefined) {
|
|
40
|
+
if (!isAbsolute(bootstrap))
|
|
41
|
+
throw new CliError("bootstrap state directory must be absolute", "onboard-state-path", EXIT_REFUSED);
|
|
42
|
+
return bootstrap;
|
|
43
|
+
}
|
|
44
|
+
const pathsFile = machinePathsFile({ env: { ...env, HOME: home } });
|
|
45
|
+
const machine = pathsFile && existsSync(pathsFile)
|
|
46
|
+
? parseMachinePaths(JSON.parse(readFileSync(pathsFile, "utf8")))
|
|
47
|
+
: undefined;
|
|
48
|
+
if (env.CATALYST_PATHS_FILE && !machine)
|
|
49
|
+
throw new CliError("the selected machine paths file is missing", "onboard-state-path", EXIT_REFUSED);
|
|
50
|
+
if (env.CATALYST_STATE_DIR !== undefined || machine)
|
|
51
|
+
return resolveCatalystPath("state", { env, machine });
|
|
52
|
+
return join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), "catalyst");
|
|
53
|
+
}
|
|
54
|
+
export function onboardStatePath(home, env = {}) {
|
|
55
|
+
return join(onboardStateRoot(home, env), "install", "last-run.json");
|
|
56
|
+
}
|
|
57
|
+
export function onboardLockPath(home, env = {}) {
|
|
58
|
+
return join(onboardStateRoot(home, env), "install.lock");
|
|
59
|
+
}
|
|
60
|
+
export const ONBOARD_DEPENDENCIES = {
|
|
61
|
+
"linear.workspace": ["signin"],
|
|
62
|
+
"linear.personal": ["signin", "linear.workspace"],
|
|
63
|
+
"linear.team": ["signin", "linear.workspace", "linear.personal"],
|
|
64
|
+
"linear.adopt": ["linear.team"],
|
|
65
|
+
"linear.automations": ["linear.team", "linear.personal"],
|
|
66
|
+
"github.install": ["signin"],
|
|
67
|
+
"github.repos": ["github.install"],
|
|
68
|
+
projects: ["linear.team", "github.repos"],
|
|
69
|
+
accounts: ["signin"],
|
|
70
|
+
settings: ["projects"],
|
|
71
|
+
values: ["settings"],
|
|
72
|
+
capacity: ["projects"],
|
|
73
|
+
daemon: ["signin"],
|
|
74
|
+
"first-ticket": [
|
|
75
|
+
"projects",
|
|
76
|
+
"accounts",
|
|
77
|
+
"settings",
|
|
78
|
+
"values",
|
|
79
|
+
"capacity",
|
|
80
|
+
"daemon",
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
const ADMIN_STEPS = new Set([
|
|
84
|
+
"linear.workspace",
|
|
85
|
+
"linear.team",
|
|
86
|
+
"linear.adopt",
|
|
87
|
+
"linear.automations",
|
|
88
|
+
"github.install",
|
|
89
|
+
"github.repos",
|
|
90
|
+
"projects",
|
|
91
|
+
"settings",
|
|
92
|
+
"values",
|
|
93
|
+
"capacity",
|
|
94
|
+
"first-ticket",
|
|
95
|
+
]);
|
|
96
|
+
export const ONBOARD_TITLES = {
|
|
97
|
+
machine: "Check this computer",
|
|
98
|
+
cli: "Check the catalyst command",
|
|
99
|
+
skills: "Check the Catalyst skills",
|
|
100
|
+
legacy: "Remove earlier Catalyst installs",
|
|
101
|
+
signin: "Sign in to Catalyst",
|
|
102
|
+
"linear.workspace": "Connect your Linear workspace",
|
|
103
|
+
"linear.personal": "Connect your Linear account",
|
|
104
|
+
"linear.team": "Choose a Linear team",
|
|
105
|
+
"linear.adopt": "Apply the Catalyst workflow",
|
|
106
|
+
"linear.automations": "Check Linear's pull request automations",
|
|
107
|
+
"github.install": "Install Catalyst on GitHub",
|
|
108
|
+
"github.repos": "Choose repositories",
|
|
109
|
+
projects: "Register your projects",
|
|
110
|
+
accounts: "Check coding accounts",
|
|
111
|
+
settings: "Review repository settings",
|
|
112
|
+
values: "Import selected local values",
|
|
113
|
+
capacity: "Check runner capacity",
|
|
114
|
+
daemon: "Start background sync",
|
|
115
|
+
housekeeping: "Schedule the daily update",
|
|
116
|
+
"first-ticket": "Start a first ticket",
|
|
117
|
+
ready: "Check onboarding readiness",
|
|
118
|
+
};
|
|
119
|
+
function isoNow(ctx, deps) {
|
|
120
|
+
return (deps.now ?? ctx.now)().toISOString();
|
|
121
|
+
}
|
|
122
|
+
function safeRead(path) {
|
|
123
|
+
try {
|
|
124
|
+
if (lstatSync(path).isSymbolicLink())
|
|
125
|
+
throw new CliError(`the saved setup record at ${path} cannot be a symbolic link`, "onboard-state-symlink", EXIT_REFUSED);
|
|
126
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof CliError)
|
|
130
|
+
throw err;
|
|
131
|
+
if (object(err)?.code === "ENOENT")
|
|
132
|
+
return undefined;
|
|
133
|
+
throw new CliError(`the saved setup record at ${path} is not valid JSON; keep it and move it aside before trying again`, "onboard-state-corrupt", EXIT_REFUSED);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function object(value) {
|
|
137
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
138
|
+
? value
|
|
139
|
+
: null;
|
|
140
|
+
}
|
|
141
|
+
function mapLegacyStep(id) {
|
|
142
|
+
if (typeof id !== "string")
|
|
143
|
+
return null;
|
|
144
|
+
const aliases = {
|
|
145
|
+
folders: "machine",
|
|
146
|
+
sign_in: "signin",
|
|
147
|
+
login: "signin",
|
|
148
|
+
housekeeping: "housekeeping",
|
|
149
|
+
ready: "ready",
|
|
150
|
+
daily_updates: "housekeeping",
|
|
151
|
+
final_check: "ready",
|
|
152
|
+
};
|
|
153
|
+
const mapped = aliases[id] ?? id;
|
|
154
|
+
return ONBOARD_STEPS.includes(mapped)
|
|
155
|
+
? mapped
|
|
156
|
+
: null;
|
|
157
|
+
}
|
|
158
|
+
function normalizeStep(value, fallbackAt) {
|
|
159
|
+
const row = object(value);
|
|
160
|
+
if (!row)
|
|
161
|
+
return null;
|
|
162
|
+
const id = mapLegacyStep(row.id);
|
|
163
|
+
if (!id)
|
|
164
|
+
return null;
|
|
165
|
+
const rawState = typeof row.state === "string" ? row.state : row.result;
|
|
166
|
+
const states = {
|
|
167
|
+
pending: "pending",
|
|
168
|
+
running: "running",
|
|
169
|
+
done: "done",
|
|
170
|
+
already_done: "done",
|
|
171
|
+
skipped: "skipped",
|
|
172
|
+
failed: "failed",
|
|
173
|
+
interrupted: "failed",
|
|
174
|
+
not_approved: "waiting",
|
|
175
|
+
needs_you: "waiting",
|
|
176
|
+
waiting: "waiting",
|
|
177
|
+
};
|
|
178
|
+
const state = typeof rawState === "string" ? states[rawState] : undefined;
|
|
179
|
+
if (!state)
|
|
180
|
+
return null;
|
|
181
|
+
const step = { id, state };
|
|
182
|
+
if (typeof row.at === "string")
|
|
183
|
+
step.at = row.at;
|
|
184
|
+
else if (typeof row.updatedAt === "string")
|
|
185
|
+
step.at = row.updatedAt;
|
|
186
|
+
else
|
|
187
|
+
step.at = fallbackAt;
|
|
188
|
+
if (typeof row.reason === "string" &&
|
|
189
|
+
/^[a-z][a-z0-9_]{1,63}$/.test(row.reason))
|
|
190
|
+
step.reason = row.reason;
|
|
191
|
+
const evidence = object(row.evidence);
|
|
192
|
+
const safeEvidence = {};
|
|
193
|
+
for (const key of [
|
|
194
|
+
"principal",
|
|
195
|
+
"role",
|
|
196
|
+
"workspace",
|
|
197
|
+
"found",
|
|
198
|
+
"remaining",
|
|
199
|
+
"count",
|
|
200
|
+
"version",
|
|
201
|
+
"account",
|
|
202
|
+
"membershipId",
|
|
203
|
+
"checkedAt",
|
|
204
|
+
"project",
|
|
205
|
+
"repository",
|
|
206
|
+
"ticket",
|
|
207
|
+
"cursor",
|
|
208
|
+
"lag",
|
|
209
|
+
"heartbeatAgeMs",
|
|
210
|
+
"supervised",
|
|
211
|
+
"streaming",
|
|
212
|
+
"scheduled",
|
|
213
|
+
"validated",
|
|
214
|
+
"available",
|
|
215
|
+
"phaseStarted",
|
|
216
|
+
"linearComment",
|
|
217
|
+
"fleetActivity",
|
|
218
|
+
"requiredValues",
|
|
219
|
+
"paused",
|
|
220
|
+
"revision",
|
|
221
|
+
"hash",
|
|
222
|
+
"path",
|
|
223
|
+
"provider",
|
|
224
|
+
"scope",
|
|
225
|
+
"checks",
|
|
226
|
+
"passed",
|
|
227
|
+
]) {
|
|
228
|
+
const item = evidence?.[key];
|
|
229
|
+
if (item === null ||
|
|
230
|
+
typeof item === "string" ||
|
|
231
|
+
typeof item === "number" ||
|
|
232
|
+
typeof item === "boolean")
|
|
233
|
+
safeEvidence[key] = item;
|
|
234
|
+
}
|
|
235
|
+
if (Object.keys(safeEvidence).length > 0)
|
|
236
|
+
step.evidence = safeEvidence;
|
|
237
|
+
return step;
|
|
238
|
+
}
|
|
239
|
+
/** Reads both the M1 journal and the 0.13.4 install-script receipt without discarding completed steps. */
|
|
240
|
+
export function readOnboardJournal(path, cliVersion = "0.14.0", now = new Date()) {
|
|
241
|
+
const raw = safeRead(path);
|
|
242
|
+
if (raw === undefined)
|
|
243
|
+
return null;
|
|
244
|
+
const value = object(raw);
|
|
245
|
+
if (!value || !Array.isArray(value.steps))
|
|
246
|
+
throw new CliError(`the saved setup record at ${path} has an unknown shape; keep it and move it aside before trying again`, "onboard-state-shape", EXIT_REFUSED);
|
|
247
|
+
if (value.schema !== 1 && value.schema !== "catalyst-install-last-run/1")
|
|
248
|
+
throw new CliError("the saved setup record uses an unsupported version; keep it before trying again", "onboard-state-version", EXIT_REFUSED);
|
|
249
|
+
const startedAt = typeof value.startedAt === "string" ? value.startedAt : now.toISOString();
|
|
250
|
+
const steps = value.steps
|
|
251
|
+
.map((row) => normalizeStep(row, startedAt))
|
|
252
|
+
.filter((row) => row !== null);
|
|
253
|
+
const deduped = new Map();
|
|
254
|
+
for (const step of steps)
|
|
255
|
+
deduped.set(step.id, step);
|
|
256
|
+
const state = typeof value.state === "string" ? value.state : null;
|
|
257
|
+
const exit = typeof value.exit === "number"
|
|
258
|
+
? value.exit
|
|
259
|
+
: typeof value.exitCode === "number"
|
|
260
|
+
? value.exitCode
|
|
261
|
+
: null;
|
|
262
|
+
return {
|
|
263
|
+
schema: 1,
|
|
264
|
+
runId: typeof value.runId === "string" ? value.runId : randomUUID(),
|
|
265
|
+
installer: typeof value.installer === "string"
|
|
266
|
+
? value.installer
|
|
267
|
+
: typeof value.revision === "string"
|
|
268
|
+
? value.revision
|
|
269
|
+
: null,
|
|
270
|
+
cli: typeof value.cli === "string" ? value.cli : cliVersion,
|
|
271
|
+
tenant: typeof value.tenant === "string" ? value.tenant : null,
|
|
272
|
+
exit: state === "running" || state === "interrupted" || state === "stopped"
|
|
273
|
+
? null
|
|
274
|
+
: exit,
|
|
275
|
+
steps: [...deduped.values()],
|
|
276
|
+
...(typeof value.account === "string" ? { account: value.account } : {}),
|
|
277
|
+
...(typeof value.membershipId === "string"
|
|
278
|
+
? { membershipId: value.membershipId }
|
|
279
|
+
: {}),
|
|
280
|
+
...(typeof value.baseUrl === "string" ? { baseUrl: value.baseUrl } : {}),
|
|
281
|
+
...(value.scope === "step" || value.scope === "onboarding"
|
|
282
|
+
? { scope: value.scope }
|
|
283
|
+
: {}),
|
|
284
|
+
...(value.mode === "plan" || value.mode === "run"
|
|
285
|
+
? { mode: value.mode }
|
|
286
|
+
: {}),
|
|
287
|
+
...(typeof value.complete === "boolean"
|
|
288
|
+
? { complete: value.complete }
|
|
289
|
+
: {}),
|
|
290
|
+
operations: Object.fromEntries(Object.entries(object(value.operations) ?? {}).filter(([key, v]) => ONBOARD_STEPS.includes(key) &&
|
|
291
|
+
typeof v === "string" &&
|
|
292
|
+
/^[a-zA-Z0-9_.:-]{1,160}$/.test(v))),
|
|
293
|
+
changes: Array.isArray(value.changes)
|
|
294
|
+
? value.changes.flatMap((change) => {
|
|
295
|
+
const row = object(change);
|
|
296
|
+
return row &&
|
|
297
|
+
typeof row.kind === "string" &&
|
|
298
|
+
typeof row.label === "string" &&
|
|
299
|
+
typeof row.undo === "string"
|
|
300
|
+
? [{ kind: row.kind, label: row.label, undo: row.undo }]
|
|
301
|
+
: [];
|
|
302
|
+
})
|
|
303
|
+
: [],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function ensurePrivateDirectory(path) {
|
|
307
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
308
|
+
const stat = lstatSync(path);
|
|
309
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
310
|
+
throw new CliError(`setup state path ${path} must be a real directory`, "onboard-state-path", EXIT_REFUSED);
|
|
311
|
+
chmodSync(path, 0o700);
|
|
312
|
+
try {
|
|
313
|
+
writeFileSync(join(path, ".mode-check"), "", { mode: 0o600, flag: "wx" });
|
|
314
|
+
rmSync(join(path, ".mode-check"));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* existing state permissions are checked by the actual atomic write */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
export function writeOnboardJournal(path, journal) {
|
|
321
|
+
const directory = dirname(path);
|
|
322
|
+
ensurePrivateDirectory(directory);
|
|
323
|
+
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
324
|
+
try {
|
|
325
|
+
writeFileSync(temp, `${JSON.stringify(journal, null, 2)}\n`, {
|
|
326
|
+
mode: 0o600,
|
|
327
|
+
flag: "wx",
|
|
328
|
+
});
|
|
329
|
+
renameSync(temp, path);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
rmSync(temp, { force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function defaultAlive(pid) {
|
|
336
|
+
try {
|
|
337
|
+
process.kill(pid, 0);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
return object(err)?.code !== "ESRCH";
|
|
341
|
+
}
|
|
342
|
+
if (pid === process.pid)
|
|
343
|
+
return true;
|
|
344
|
+
const probe = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
345
|
+
encoding: "utf8",
|
|
346
|
+
timeout: 2_000,
|
|
347
|
+
});
|
|
348
|
+
if (probe.status !== 0)
|
|
349
|
+
return true;
|
|
350
|
+
const command = probe.stdout.trim();
|
|
351
|
+
return command.includes("catalyst") && command.includes("onboard");
|
|
352
|
+
}
|
|
353
|
+
function readLockOwner(path) {
|
|
354
|
+
try {
|
|
355
|
+
const raw = readFileSync(join(path, "owner.json"), "utf8");
|
|
356
|
+
const row = object(JSON.parse(raw));
|
|
357
|
+
if (row && Number.isSafeInteger(row.pid) && typeof row.token === "string")
|
|
358
|
+
return { pid: row.pid, token: row.token };
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
try {
|
|
362
|
+
const pid = Number(readFileSync(join(path, "pid"), "utf8").trim());
|
|
363
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
364
|
+
return { pid, token: "" };
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
/* incomplete stale lock */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
function removeStaleLock(lockPath, owner) {
|
|
373
|
+
try {
|
|
374
|
+
const before = lstatSync(lockPath);
|
|
375
|
+
if (!before.isDirectory() || before.isSymbolicLink())
|
|
376
|
+
return false;
|
|
377
|
+
const current = readLockOwner(lockPath);
|
|
378
|
+
if (current?.pid !== owner.pid || current?.token !== owner.token)
|
|
379
|
+
return false;
|
|
380
|
+
const stalePath = `${lockPath}.stale-${owner.pid}-${randomBytes(4).toString("hex")}`;
|
|
381
|
+
renameSync(lockPath, stalePath);
|
|
382
|
+
const moved = readLockOwner(stalePath);
|
|
383
|
+
if (moved?.pid !== owner.pid || moved?.token !== owner.token) {
|
|
384
|
+
try {
|
|
385
|
+
renameSync(stalePath, lockPath);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
/* another installer acquired the canonical path */
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
rmSync(stalePath, { recursive: true, force: false });
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function releaseOwnedLock(lockPath, owner) {
|
|
400
|
+
try {
|
|
401
|
+
const before = lstatSync(lockPath);
|
|
402
|
+
if (!before.isDirectory() || before.isSymbolicLink())
|
|
403
|
+
return;
|
|
404
|
+
const current = readLockOwner(lockPath);
|
|
405
|
+
if (current?.pid !== owner.pid || current.token !== owner.token)
|
|
406
|
+
return;
|
|
407
|
+
const releasePath = `${lockPath}.release-${owner.pid}-${randomBytes(4).toString("hex")}`;
|
|
408
|
+
renameSync(lockPath, releasePath);
|
|
409
|
+
const moved = readLockOwner(releasePath);
|
|
410
|
+
if (moved?.pid !== owner.pid || moved.token !== owner.token) {
|
|
411
|
+
try {
|
|
412
|
+
renameSync(releasePath, lockPath);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
/* another installer acquired the canonical path */
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
rmSync(releasePath, { recursive: true, force: false });
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
/* leave an uncertain lock in place so a later run can verify its owner */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function acquireLock(ctx, deps) {
|
|
426
|
+
const pid = deps.processId ?? process.pid;
|
|
427
|
+
const token = (deps.token ?? randomUUID)();
|
|
428
|
+
const path = onboardLockPath(ctx.home, ctx.env);
|
|
429
|
+
ensurePrivateDirectory(join(path, ".."));
|
|
430
|
+
try {
|
|
431
|
+
mkdirSync(path, { mode: 0o700 });
|
|
432
|
+
writeFileSync(join(path, "pid"), `${pid}\n`, { mode: 0o600, flag: "wx" });
|
|
433
|
+
const owner = { pid, token };
|
|
434
|
+
writeFileSync(join(path, "owner.json"), `${JSON.stringify(owner)}\n`, {
|
|
435
|
+
mode: 0o600,
|
|
436
|
+
flag: "wx",
|
|
437
|
+
});
|
|
438
|
+
return { path, owner };
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
if (object(err)?.code !== "EEXIST")
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
const lockStat = lstatSync(path);
|
|
445
|
+
if (!lockStat.isDirectory() || lockStat.isSymbolicLink())
|
|
446
|
+
throw new CliError(`setup lock at ${path} is not a real directory; inspect it before trying again`, "onboard-lock-path", EXIT_REFUSED);
|
|
447
|
+
const current = readLockOwner(path);
|
|
448
|
+
const handoff = ctx.env.CATALYST_INSTALL_LOCK_TOKEN;
|
|
449
|
+
if (current?.pid === pid && current.token !== "" && current.token === handoff)
|
|
450
|
+
return { path, owner: current };
|
|
451
|
+
const ownerIsAlive = current?.pid === pid
|
|
452
|
+
? false
|
|
453
|
+
: current
|
|
454
|
+
? (deps.isProcessAlive ?? defaultAlive)(current.pid)
|
|
455
|
+
: false;
|
|
456
|
+
if (current && ownerIsAlive)
|
|
457
|
+
throw new CliError(`setup is already running (pid ${current.pid}); wait for it to finish, then run the same command`, "onboard-locked", EXIT_FAILED);
|
|
458
|
+
if (!current)
|
|
459
|
+
throw new CliError("setup lock has no complete owner record; keep it until no setup process is running", "onboard-lock-incomplete", EXIT_REFUSED);
|
|
460
|
+
if (!removeStaleLock(path, current))
|
|
461
|
+
throw new CliError(`setup lock at ${path} could not be safely reclaimed; inspect it and remove it only if no setup process is running`, "onboard-lock-stale", EXIT_REFUSED);
|
|
462
|
+
return acquireLock(ctx, deps);
|
|
463
|
+
}
|
|
464
|
+
function releaseLock(lock) {
|
|
465
|
+
releaseOwnedLock(lock.path, lock.owner);
|
|
466
|
+
}
|
|
467
|
+
function journalStep(journal, id) {
|
|
468
|
+
return journal.steps.find((step) => step.id === id);
|
|
469
|
+
}
|
|
470
|
+
function setStep(journal, step) {
|
|
471
|
+
const index = journal.steps.findIndex((existing) => existing.id === step.id);
|
|
472
|
+
if (index === -1)
|
|
473
|
+
journal.steps.push(step);
|
|
474
|
+
else
|
|
475
|
+
journal.steps[index] = step;
|
|
476
|
+
}
|
|
477
|
+
function freshJournal(ctx, cliVersion, deps) {
|
|
478
|
+
const now = isoNow(ctx, deps);
|
|
479
|
+
return {
|
|
480
|
+
schema: 1,
|
|
481
|
+
runId: `${now.replace(/[-:.]/g, "").replace(/Z$/, "Z")}-${randomBytes(2).toString("hex")}`,
|
|
482
|
+
installer: null,
|
|
483
|
+
cli: cliVersion,
|
|
484
|
+
tenant: null,
|
|
485
|
+
exit: null,
|
|
486
|
+
steps: [],
|
|
487
|
+
changes: [],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
function planJournal(journal) {
|
|
491
|
+
return {
|
|
492
|
+
...journal,
|
|
493
|
+
exit: null,
|
|
494
|
+
mode: "plan",
|
|
495
|
+
complete: false,
|
|
496
|
+
steps: ONBOARD_STEPS.map((id) => journal.steps.find((step) => step.id === id) ?? {
|
|
497
|
+
id,
|
|
498
|
+
state: "pending",
|
|
499
|
+
}),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function printPlan(ctx, journal) {
|
|
503
|
+
const value = planJournal(journal);
|
|
504
|
+
ctx.stdout("Catalyst setup plan");
|
|
505
|
+
for (const step of value.steps)
|
|
506
|
+
ctx.stdout(` ${step.state === "done" ? "✓" : "·"} ${ONBOARD_TITLES[step.id]}`);
|
|
507
|
+
ctx.stdout(`Next: ${value.steps.find((step) => step.state === "pending")?.id ?? "ready"}`);
|
|
508
|
+
}
|
|
509
|
+
async function confirmContinue(ctx, args, deps) {
|
|
510
|
+
if (args.flags.yes === true)
|
|
511
|
+
return true;
|
|
512
|
+
if (!(deps.isTty ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)))()) {
|
|
513
|
+
ctx.stderr("No terminal is attached; using the default answer Yes. Pass --dry-run to inspect without changes.");
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
if (deps.confirm)
|
|
517
|
+
return deps.confirm("Continue? [Y/n] ");
|
|
518
|
+
try {
|
|
519
|
+
const input = createReadStream("/dev/tty");
|
|
520
|
+
const output = createWriteStream("/dev/tty");
|
|
521
|
+
const readline = createInterface({ input, output, terminal: true });
|
|
522
|
+
try {
|
|
523
|
+
const answer = (await readline.question("Continue? [Y/n] "))
|
|
524
|
+
.trim()
|
|
525
|
+
.toLowerCase();
|
|
526
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
readline.close();
|
|
530
|
+
input.destroy();
|
|
531
|
+
output.end();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function sanitizedResult(id, result, at) {
|
|
539
|
+
const row = normalizeStep({
|
|
540
|
+
id,
|
|
541
|
+
...result,
|
|
542
|
+
state: result.state === "refused" ? "failed" : result.state,
|
|
543
|
+
}, at);
|
|
544
|
+
row.at = at;
|
|
545
|
+
return row;
|
|
546
|
+
}
|
|
547
|
+
function stepSatisfied(step) {
|
|
548
|
+
return (step?.state === "done" ||
|
|
549
|
+
(step?.state === "skipped" &&
|
|
550
|
+
(step.id === "legacy" || step.reason === "member_scope")));
|
|
551
|
+
}
|
|
552
|
+
function requireMatchingIdentity(journal, identity) {
|
|
553
|
+
const account = journal.account ?? journal.tenant;
|
|
554
|
+
if ((account && (!identity || account !== identity.account)) ||
|
|
555
|
+
(journal.membershipId && journal.membershipId !== identity?.membershipId) ||
|
|
556
|
+
(journal.baseUrl && journal.baseUrl !== identity?.baseUrl))
|
|
557
|
+
throw new CliError("this setup belongs to another workspace or member; sign in with the original account to resume", "onboard-identity-mismatch", EXIT_REFUSED);
|
|
558
|
+
if (identity) {
|
|
559
|
+
journal.account = identity.account;
|
|
560
|
+
journal.tenant = identity.account;
|
|
561
|
+
journal.membershipId = identity.membershipId;
|
|
562
|
+
journal.baseUrl = identity.baseUrl;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** The receipt records progress; adapters verify the current state before any resumed action. */
|
|
566
|
+
export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.14.0") {
|
|
567
|
+
if (args.subcommand !== null || args.rest.length > 0)
|
|
568
|
+
throw new UsageError("onboard takes no positional arguments");
|
|
569
|
+
const only = typeof args.flags.only === "string"
|
|
570
|
+
? args.flags.only
|
|
571
|
+
: undefined;
|
|
572
|
+
const resume = typeof args.flags["resume-from"] === "string"
|
|
573
|
+
? args.flags["resume-from"]
|
|
574
|
+
: undefined;
|
|
575
|
+
const resumeFrom = resume === "install" ? "signin" : resume;
|
|
576
|
+
for (const id of [only, resumeFrom])
|
|
577
|
+
if (id && !ONBOARD_STEPS.includes(id))
|
|
578
|
+
throw new UsageError(`unknown onboarding step: ${id}`);
|
|
579
|
+
const statePath = onboardStatePath(ctx.home, ctx.env);
|
|
580
|
+
let journal = readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ??
|
|
581
|
+
freshJournal(ctx, cliVersion, deps);
|
|
582
|
+
if (args.flags["dry-run"] === true) {
|
|
583
|
+
if (args.json)
|
|
584
|
+
ctx.stdout(JSON.stringify(planJournal(journal)));
|
|
585
|
+
else
|
|
586
|
+
printPlan(ctx, journal);
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
// Check tenant binding before even a local cleanup mutation. Never overwrite a mismatched receipt.
|
|
590
|
+
let identity = null;
|
|
591
|
+
try {
|
|
592
|
+
if (deps.identity) {
|
|
593
|
+
identity = await deps.identity();
|
|
594
|
+
requireMatchingIdentity(journal, identity);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (error instanceof CliError) {
|
|
599
|
+
ctx.stderr(error.message);
|
|
600
|
+
return error.exitCode;
|
|
601
|
+
}
|
|
602
|
+
ctx.stderr("Could not verify your Catalyst membership. Setup was not changed.");
|
|
603
|
+
return EXIT_FAILED;
|
|
604
|
+
}
|
|
605
|
+
if (!args.json)
|
|
606
|
+
printPlan(ctx, journal);
|
|
607
|
+
const inheritedConsent = resume === "install" && Boolean(ctx.env.CATALYST_INSTALL_LOCK_TOKEN);
|
|
608
|
+
if (!inheritedConsent && !(await confirmContinue(ctx, args, deps))) {
|
|
609
|
+
ctx.stderr("Nothing was changed. Run the same command when ready.");
|
|
610
|
+
if (args.json)
|
|
611
|
+
ctx.stdout(JSON.stringify({ ...journal, complete: false }));
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
let lock;
|
|
615
|
+
try {
|
|
616
|
+
lock = acquireLock(ctx, deps);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
if (!(error instanceof CliError))
|
|
620
|
+
throw error;
|
|
621
|
+
ctx.stderr(error.message);
|
|
622
|
+
if (args.json)
|
|
623
|
+
ctx.stdout(JSON.stringify({ ...planJournal(journal), exit: error.exitCode }));
|
|
624
|
+
return error.exitCode;
|
|
625
|
+
}
|
|
626
|
+
const stop = new AbortController();
|
|
627
|
+
const signal = deps.signal
|
|
628
|
+
? AbortSignal.any([deps.signal, stop.signal])
|
|
629
|
+
: stop.signal;
|
|
630
|
+
const stepCtx = {
|
|
631
|
+
...ctx,
|
|
632
|
+
stdout: args.json ? ctx.stderr : ctx.stdout,
|
|
633
|
+
fetch: ((input, init) => ctx.fetch(input, {
|
|
634
|
+
...init,
|
|
635
|
+
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
|
636
|
+
})),
|
|
637
|
+
};
|
|
638
|
+
let current = null;
|
|
639
|
+
const interrupted = () => {
|
|
640
|
+
stop.abort();
|
|
641
|
+
if (current)
|
|
642
|
+
setStep(journal, {
|
|
643
|
+
id: current,
|
|
644
|
+
state: "failed",
|
|
645
|
+
reason: "interrupted",
|
|
646
|
+
at: isoNow(ctx, deps),
|
|
647
|
+
});
|
|
648
|
+
journal.exit = EXIT_WAITING;
|
|
649
|
+
journal.complete = false;
|
|
650
|
+
try {
|
|
651
|
+
writeOnboardJournal(statePath, journal);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
/* keep the last atomic receipt */
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
658
|
+
if (deps.bindSignals !== false)
|
|
659
|
+
for (const name of signals)
|
|
660
|
+
process.on(name, interrupted);
|
|
661
|
+
const finish = (code) => {
|
|
662
|
+
journal.exit = code;
|
|
663
|
+
journal.complete = !only && code === 0;
|
|
664
|
+
writeOnboardJournal(statePath, journal);
|
|
665
|
+
if (args.json)
|
|
666
|
+
ctx.stdout(JSON.stringify(journal));
|
|
667
|
+
else {
|
|
668
|
+
if (journal.complete)
|
|
669
|
+
ctx.stdout("Onboarding complete.");
|
|
670
|
+
else if (only && code === 0)
|
|
671
|
+
ctx.stdout(`${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.`);
|
|
672
|
+
else
|
|
673
|
+
ctx.stdout(`Setup still needs ${journal.steps.filter((step) => !stepSatisfied(step)).length} checks.`);
|
|
674
|
+
ctx.stdout("resume: catalyst onboard");
|
|
675
|
+
}
|
|
676
|
+
return code;
|
|
677
|
+
};
|
|
678
|
+
try {
|
|
679
|
+
journal =
|
|
680
|
+
readOnboardJournal(statePath, cliVersion, (deps.now ?? ctx.now)()) ??
|
|
681
|
+
journal;
|
|
682
|
+
if (deps.identity)
|
|
683
|
+
requireMatchingIdentity(journal, identity);
|
|
684
|
+
journal.cli = cliVersion;
|
|
685
|
+
journal.scope = only ? "step" : "onboarding";
|
|
686
|
+
journal.mode = "run";
|
|
687
|
+
for (const id of ONBOARD_STEPS)
|
|
688
|
+
if (!journalStep(journal, id))
|
|
689
|
+
setStep(journal, { id, state: "pending" });
|
|
690
|
+
journal.steps.sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id));
|
|
691
|
+
journal.operations ??= {};
|
|
692
|
+
let refused = false;
|
|
693
|
+
const needed = new Set();
|
|
694
|
+
const include = (id) => {
|
|
695
|
+
if (needed.has(id))
|
|
696
|
+
return;
|
|
697
|
+
needed.add(id);
|
|
698
|
+
for (const parent of ONBOARD_DEPENDENCIES[id] ?? [])
|
|
699
|
+
include(parent);
|
|
700
|
+
};
|
|
701
|
+
if (only)
|
|
702
|
+
include(only);
|
|
703
|
+
else
|
|
704
|
+
for (const id of ONBOARD_STEPS)
|
|
705
|
+
needed.add(id);
|
|
706
|
+
const fromIndex = resumeFrom ? ONBOARD_STEPS.indexOf(resumeFrom) : 0;
|
|
707
|
+
for (const id of ONBOARD_STEPS.filter((id) => needed.has(id))) {
|
|
708
|
+
if (signal.aborted)
|
|
709
|
+
return finish(EXIT_WAITING);
|
|
710
|
+
current = id;
|
|
711
|
+
// Member onboarding keeps administration outside its scope, with an explicit recorded reason.
|
|
712
|
+
if (identity?.role === "member" && ADMIN_STEPS.has(id)) {
|
|
713
|
+
setStep(journal, {
|
|
714
|
+
id,
|
|
715
|
+
state: "skipped",
|
|
716
|
+
reason: "member_scope",
|
|
717
|
+
at: isoNow(ctx, deps),
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const missing = (ONBOARD_DEPENDENCIES[id] ?? []).find((parent) => !stepSatisfied(journalStep(journal, parent)));
|
|
722
|
+
if (missing) {
|
|
723
|
+
setStep(journal, {
|
|
724
|
+
id,
|
|
725
|
+
state: "waiting",
|
|
726
|
+
reason: "prerequisite_not_ready",
|
|
727
|
+
at: isoNow(ctx, deps),
|
|
728
|
+
});
|
|
729
|
+
writeOnboardJournal(statePath, journal);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
let adapter = deps.adapters?.[id];
|
|
733
|
+
// Compatibility for the initial legacy adapter, which performs its own before/after checks.
|
|
734
|
+
if (!adapter && id === "legacy") {
|
|
735
|
+
let result = deps.runStep
|
|
736
|
+
? { state: "pending" }
|
|
737
|
+
: { state: "skipped", reason: "legacy_not_selected" };
|
|
738
|
+
adapter = {
|
|
739
|
+
check: async () => result,
|
|
740
|
+
act: deps.runStep
|
|
741
|
+
? async () => (result = await deps.runStep(id, stepCtx))
|
|
742
|
+
: undefined,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (!adapter) {
|
|
746
|
+
setStep(journal, {
|
|
747
|
+
id,
|
|
748
|
+
state: "waiting",
|
|
749
|
+
reason: "step_not_available_in_this_release",
|
|
750
|
+
at: isoNow(ctx, deps),
|
|
751
|
+
});
|
|
752
|
+
writeOnboardJournal(statePath, journal);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
let result = await adapter.check(stepCtx, journal);
|
|
757
|
+
if (result.state === "pending" &&
|
|
758
|
+
adapter.act &&
|
|
759
|
+
(!only || id === only) &&
|
|
760
|
+
ONBOARD_STEPS.indexOf(id) >= fromIndex) {
|
|
761
|
+
if (deps.identity) {
|
|
762
|
+
identity = await deps.identity();
|
|
763
|
+
requireMatchingIdentity(journal, identity);
|
|
764
|
+
}
|
|
765
|
+
journal.operations[id] ??= `${journal.runId}:${id}`;
|
|
766
|
+
setStep(journal, { id, state: "running", at: isoNow(ctx, deps) });
|
|
767
|
+
journal.exit = null;
|
|
768
|
+
writeOnboardJournal(statePath, journal);
|
|
769
|
+
const action = await adapter.act(stepCtx, journal);
|
|
770
|
+
if (action.state === "done") {
|
|
771
|
+
result = await adapter.check(stepCtx, journal);
|
|
772
|
+
if (result.state === "pending")
|
|
773
|
+
result = { state: "waiting", reason: "verification_pending" };
|
|
774
|
+
}
|
|
775
|
+
else
|
|
776
|
+
result = action;
|
|
777
|
+
if (id === "signin" && result.state === "done" && deps.identity) {
|
|
778
|
+
identity = await deps.identity();
|
|
779
|
+
requireMatchingIdentity(journal, identity);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (result.state === "refused")
|
|
783
|
+
refused = true;
|
|
784
|
+
if (result.state === "pending")
|
|
785
|
+
result = {
|
|
786
|
+
...result,
|
|
787
|
+
state: "waiting",
|
|
788
|
+
reason: result.reason ?? "action_required",
|
|
789
|
+
};
|
|
790
|
+
setStep(journal, sanitizedResult(id, result, isoNow(ctx, deps)));
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
if (error instanceof CliError && error.exitCode === EXIT_REFUSED)
|
|
794
|
+
refused = true;
|
|
795
|
+
const reason = error instanceof CliError &&
|
|
796
|
+
/^[a-z][a-z0-9_-]{1,63}$/.test(error.code)
|
|
797
|
+
? error.code.replaceAll("-", "_")
|
|
798
|
+
: "step_failed";
|
|
799
|
+
setStep(journal, {
|
|
800
|
+
id,
|
|
801
|
+
state: "failed",
|
|
802
|
+
reason,
|
|
803
|
+
at: isoNow(ctx, deps),
|
|
804
|
+
});
|
|
805
|
+
stepCtx.stderr(`✗ ${ONBOARD_TITLES[id]}: ${reason}. The safe reason is saved; run the same command to resume.`);
|
|
806
|
+
}
|
|
807
|
+
writeOnboardJournal(statePath, journal);
|
|
808
|
+
if (refused)
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
current = null;
|
|
812
|
+
const scope = only ? [only] : [...ONBOARD_STEPS];
|
|
813
|
+
const code = refused
|
|
814
|
+
? EXIT_REFUSED
|
|
815
|
+
: scope.some((id) => journalStep(journal, id)?.state === "failed")
|
|
816
|
+
? EXIT_FAILED
|
|
817
|
+
: scope.every((id) => stepSatisfied(journalStep(journal, id)))
|
|
818
|
+
? 0
|
|
819
|
+
: EXIT_WAITING;
|
|
820
|
+
return finish(code);
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
// Identity can change while the lock is held; stop before the next action.
|
|
824
|
+
if (error instanceof CliError) {
|
|
825
|
+
ctx.stderr(error.message);
|
|
826
|
+
return error.exitCode;
|
|
827
|
+
}
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
if (deps.bindSignals !== false)
|
|
832
|
+
for (const name of signals)
|
|
833
|
+
process.off(name, interrupted);
|
|
834
|
+
releaseLock(lock);
|
|
835
|
+
}
|
|
836
|
+
}
|