@catalyst-cloud/cli 0.15.6 → 0.16.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 +13 -0
- package/README.md +9 -6
- package/dist/args.js +26 -1
- package/dist/ask.js +56 -0
- package/dist/capabilities.js +3 -1
- package/dist/cli.js +10 -5
- package/dist/environment.js +1 -1
- package/dist/execution.js +2 -2
- package/dist/hosts.js +89 -0
- package/dist/legacy.js +12 -2
- package/dist/machine-inventory.js +37 -0
- package/dist/onboard-capacity.js +5 -6
- package/dist/onboard-github-copy.js +2 -0
- package/dist/onboard-github.js +2 -0
- package/dist/onboard-headless.js +2 -2
- package/dist/onboard-next.js +21 -12
- package/dist/onboard-project-cleanup.js +100 -0
- package/dist/onboard-returning.js +172 -0
- package/dist/onboard-runner.js +149 -34
- package/dist/onboard-runtime.js +26 -1
- package/dist/onboard-ui.js +67 -1
- package/dist/onboard-workflow.js +32 -7
- package/dist/onboard.js +9 -2
- package/dist/ready-copy.js +30 -24
- package/dist/ready.js +2 -2
- package/dist/secret.js +2 -2
- package/dist/setup-onboard-copy.js +37 -32
- package/dist/setup.js +4 -0
- package/dist/transport.js +18 -2
- package/package.json +8 -5
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -3
- package/skills/catalyst-onboard/references/connecting-this-machine.md +4 -4
- package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
- package/skills/catalyst-onboard/references/reading-ready.md +14 -14
- package/skills/catalyst-onboard/references/replacing-a-credential.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +5 -5
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -4
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -10
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +50 -27
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +2 -2
- package/skills/what-needs-me/references/raising-a-decision.md +12 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
- package/skills/what-needs-me/scripts/raise.mjs +10 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +3 -2
- package/skills/whats-happening/references/coding-accounts.md +1 -1
- package/skills/whats-happening/references/machines.md +9 -0
- package/skills/whats-happening/references/settings-and-where-they-live.md +8 -8
- package/skills/whats-happening/references/stages-and-mapping.md +1 -1
package/dist/onboard-runner.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
3
|
import { hostname, tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
4
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
6
5
|
import { loadConfig, normalizeBaseUrl, packageRoot } from "./config.js";
|
|
7
6
|
import { nativeEgressReady, NATIVE_EGRESS_DIR } from "./onboard-runner-egress.js";
|
|
8
7
|
import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboard-runner-cloud.js";
|
|
9
8
|
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
10
9
|
import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
|
|
11
|
-
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
10
|
+
import { readExistingOnboardJson, readOnboardTeamInventory, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
12
11
|
import { onboardStateRoot, } from "./onboard.js";
|
|
13
12
|
// "Run Catalyst's work on this machine": the self-hosted host from catalyst-cloud's deploy/self-host
|
|
14
13
|
// (a supervisor and a deadline watchdog under Compose), enrolled with the person's own owner or
|
|
@@ -133,6 +132,54 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
133
132
|
const supportedVm = platform === "darwin" && vm && host?.startsWith("unix:///");
|
|
134
133
|
return { arch, vm, ...(!nativeLocal && !supportedVm ? { unsupported: true } : {}) };
|
|
135
134
|
},
|
|
135
|
+
async installation(home, supervisorImage, signal) {
|
|
136
|
+
const listed = await docker(["ps", "--all", "--filter", `label=com.docker.compose.project=${RUNNER_PROJECT}`,
|
|
137
|
+
"--filter", "label=com.docker.compose.service=supervisor", "--format", "{{.ID}}"], { signal });
|
|
138
|
+
if (listed.code !== 0)
|
|
139
|
+
return "unverified";
|
|
140
|
+
const ids = listed.stdout.trim().split(/\s+/).filter(Boolean);
|
|
141
|
+
if (ids.length === 0)
|
|
142
|
+
return "missing";
|
|
143
|
+
if (ids.length !== 1 || !/^[0-9a-f]{12,64}$/.test(ids[0]))
|
|
144
|
+
return "unverified";
|
|
145
|
+
const read = await docker(["inspect", ids[0]], { signal });
|
|
146
|
+
if (read.code !== 0)
|
|
147
|
+
return "unverified";
|
|
148
|
+
try {
|
|
149
|
+
const rows = JSON.parse(read.stdout);
|
|
150
|
+
if (!Array.isArray(rows) || rows.length !== 1)
|
|
151
|
+
return "unverified";
|
|
152
|
+
const config = rows[0]?.Config;
|
|
153
|
+
const labels = config?.Labels;
|
|
154
|
+
const dir = labels?.["com.docker.compose.project.working_dir"];
|
|
155
|
+
if (config?.Image !== supervisorImage || labels?.["com.docker.compose.project"] !== RUNNER_PROJECT ||
|
|
156
|
+
labels?.["com.docker.compose.service"] !== "supervisor" || typeof dir !== "string" || !isAbsolute(dir) ||
|
|
157
|
+
labels?.["com.docker.compose.project.config_files"] !== join(dir, "compose.yaml"))
|
|
158
|
+
return "unverified";
|
|
159
|
+
const inHome = relative(realpathSync(home), realpathSync(dir));
|
|
160
|
+
if (inHome === ".." || inHome.startsWith("../") || isAbsolute(inHome) || !lstatSync(dir).isDirectory())
|
|
161
|
+
return "unverified";
|
|
162
|
+
for (const path of [join(dir, "compose.yaml"), join(dir, ".env")]) {
|
|
163
|
+
const stat = lstatSync(path);
|
|
164
|
+
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o022) !== 0)
|
|
165
|
+
return "unverified";
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(config.Env) || !config.Env.every((entry) => typeof entry === "string"))
|
|
168
|
+
return "unverified";
|
|
169
|
+
const names = config.Env.filter((entry) => entry.startsWith("CATALYST_HOST_NAME="));
|
|
170
|
+
const origins = config.Env.filter((entry) => entry.startsWith("CATALYST_MIRROR_URL="));
|
|
171
|
+
if (names.length !== 1 || origins.length !== 1)
|
|
172
|
+
return "unverified";
|
|
173
|
+
const hostName = names[0].slice("CATALYST_HOST_NAME=".length);
|
|
174
|
+
const baseUrl = origins[0].slice("CATALYST_MIRROR_URL=".length);
|
|
175
|
+
if (!HOST_NAME.test(hostName))
|
|
176
|
+
return "unverified";
|
|
177
|
+
return { dir, hostName, baseUrl: normalizeBaseUrl(baseUrl) };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return "unverified";
|
|
181
|
+
}
|
|
182
|
+
},
|
|
136
183
|
async imageArch(ref, signal) {
|
|
137
184
|
const read = await docker(["image", "inspect", "--format", "{{.Architecture}}", ref], { signal });
|
|
138
185
|
return read.code === 0 ? read.stdout.trim() || null : null;
|
|
@@ -148,7 +195,7 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
148
195
|
const anonymousEnv = { ...env, DOCKER_CONFIG: config };
|
|
149
196
|
delete anonymousEnv.DOCKER_CONTEXT;
|
|
150
197
|
delete anonymousEnv.DOCKER_AUTH_CONFIG;
|
|
151
|
-
return (await docker(["--host", host, "--config", config, "pull", "--quiet", ref], { env: anonymousEnv, signal, timeoutMs: 900_000 })).code === 0;
|
|
198
|
+
return ((await docker(["--host", host, "--config", config, "pull", "--quiet", ref], { env: anonymousEnv, signal, timeoutMs: 900_000 })).code === 0);
|
|
152
199
|
}
|
|
153
200
|
catch {
|
|
154
201
|
return false;
|
|
@@ -197,7 +244,9 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
197
244
|
];
|
|
198
245
|
return (await docker(args, { signal })).code === 0;
|
|
199
246
|
},
|
|
200
|
-
async nativeEgressStatus(nowMs, signal) {
|
|
247
|
+
async nativeEgressStatus(nowMs, signal) {
|
|
248
|
+
return nativeLocal && (await nativeEgressReady(nowMs, signal));
|
|
249
|
+
},
|
|
201
250
|
async socketGid() {
|
|
202
251
|
if (!nativeLocal)
|
|
203
252
|
return null;
|
|
@@ -226,7 +275,8 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
226
275
|
RUNNER_UID,
|
|
227
276
|
...paths,
|
|
228
277
|
];
|
|
229
|
-
return (await docker(args, { cwd: dir, signal, timeoutMs: 120_000 })).code ===
|
|
278
|
+
return ((await docker(args, { cwd: dir, signal, timeoutMs: 120_000 })).code ===
|
|
279
|
+
0);
|
|
230
280
|
},
|
|
231
281
|
async hasVolumeFile(dir, variable, signal) {
|
|
232
282
|
return ((await docker(supervisorShell(dir, `test -s "$${variable}"`), {
|
|
@@ -316,7 +366,8 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
316
366
|
},
|
|
317
367
|
async composeRunning(dir, signal) {
|
|
318
368
|
const read = await docker([...compose(dir), "ps", "--status", "running", "--services"], { cwd: dir, signal });
|
|
319
|
-
return read.code === 0 &&
|
|
369
|
+
return (read.code === 0 &&
|
|
370
|
+
read.stdout.split("\n").some((line) => line.trim() === "supervisor"));
|
|
320
371
|
},
|
|
321
372
|
};
|
|
322
373
|
}
|
|
@@ -350,7 +401,9 @@ function parseHosts(body) {
|
|
|
350
401
|
hostName: host.hostName,
|
|
351
402
|
team: host.team,
|
|
352
403
|
revoked: host.revokedAtMs !== null,
|
|
353
|
-
capacity: capability ?
|
|
404
|
+
capacity: capability ? capability.runtimeLive
|
|
405
|
+
? capability.placeableCapacity
|
|
406
|
+
: 0 : null,
|
|
354
407
|
failing: capability ? capability.failingRequired : [],
|
|
355
408
|
});
|
|
356
409
|
}
|
|
@@ -393,16 +446,15 @@ function writePrivate(path, text) {
|
|
|
393
446
|
/** Enrollment uses the stored host id and exact team/name. Two machines with one hostname must not
|
|
394
447
|
* share it: a random suffix, kept in `.env` from the first write on. */
|
|
395
448
|
export function runnerDefaultHostName() {
|
|
396
|
-
const
|
|
397
|
-
const base = `catalyst-${hostname().split(".")[0] ?? "host"}`
|
|
449
|
+
const base = (hostname().split(".")[0] ?? "machine")
|
|
398
450
|
.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
399
|
-
.slice(0,
|
|
400
|
-
|
|
401
|
-
return HOST_NAME.test(name) ? name : `catalyst-host-${suffix}`;
|
|
451
|
+
.slice(0, 63);
|
|
452
|
+
return HOST_NAME.test(base) ? base : "machine";
|
|
402
453
|
}
|
|
403
454
|
export function onboardRunnerAdapter(input = {}) {
|
|
404
455
|
const engine = input.engine ?? dockerRunnerEngine();
|
|
405
|
-
const sleep = input.sleep ??
|
|
456
|
+
const sleep = input.sleep ??
|
|
457
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
406
458
|
const waitMs = input.waitMs ?? 120_000;
|
|
407
459
|
const pollMs = input.pollMs ?? 5_000;
|
|
408
460
|
// One question per run: the engine checks again after acting, and the receipt row is "running"
|
|
@@ -412,6 +464,7 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
412
464
|
// only reports on it, so an unattended check never restarts a runner the person stopped.
|
|
413
465
|
let mayAct = false;
|
|
414
466
|
let acted = false;
|
|
467
|
+
let chosenName;
|
|
415
468
|
const selected = { selected: true };
|
|
416
469
|
async function decide(journal) {
|
|
417
470
|
if (decided !== undefined)
|
|
@@ -460,14 +513,6 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
460
513
|
], signal);
|
|
461
514
|
if ("reason" in support)
|
|
462
515
|
return waiting(support.reason, selected);
|
|
463
|
-
const teamId = selectedOnboardTeam(journal);
|
|
464
|
-
const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
|
|
465
|
-
if (!teamId || !teamKey)
|
|
466
|
-
return waiting("runner_context_unverified", selected);
|
|
467
|
-
const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
|
|
468
|
-
if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
|
|
469
|
-
return waiting(runnerSupport.reason, selected);
|
|
470
|
-
const cloud = !("reason" in runnerSupport);
|
|
471
516
|
let state;
|
|
472
517
|
try {
|
|
473
518
|
state = onboardStateRoot(ctx.home, ctx.env);
|
|
@@ -475,7 +520,41 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
475
520
|
catch {
|
|
476
521
|
return waiting("runner_directory_unavailable", selected);
|
|
477
522
|
}
|
|
478
|
-
|
|
523
|
+
let dir = join(state, "runner");
|
|
524
|
+
const installation = await engine.installation?.(ctx.home, ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor, signal);
|
|
525
|
+
if (installation === "unverified")
|
|
526
|
+
return waiting("runner_installation_unverified", selected);
|
|
527
|
+
let credential = null;
|
|
528
|
+
if (installation && installation !== "missing") {
|
|
529
|
+
if (installation.baseUrl !== normalizeBaseUrl(cfg.baseUrl))
|
|
530
|
+
return waiting("runner_identity_unverified", selected);
|
|
531
|
+
dir = installation.dir;
|
|
532
|
+
credential = await engine.enrollment(dir, signal);
|
|
533
|
+
if (!credential)
|
|
534
|
+
return waiting("runner_credential_unverified", selected);
|
|
535
|
+
if (!isUnenrolled(credential) && (credential.tenant !== account || credential.enrollmentKind !== "self_hosted"))
|
|
536
|
+
return waiting("runner_identity_unverified", selected);
|
|
537
|
+
}
|
|
538
|
+
let teamId = selectedOnboardTeam(journal);
|
|
539
|
+
if (!teamId && credential && !isUnenrolled(credential)) {
|
|
540
|
+
// Moving on retains the verified installation's project scope. Never choose the first team.
|
|
541
|
+
const teams = await readOnboardTeamInventory(ctx, signal);
|
|
542
|
+
if (!("reason" in teams)) {
|
|
543
|
+
const installedTeam = credential.team;
|
|
544
|
+
const matching = teams.teams.filter(team => team.key === installedTeam);
|
|
545
|
+
if (matching.length === 1)
|
|
546
|
+
teamId = matching[0].id;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
|
|
550
|
+
if (!teamId || !teamKey)
|
|
551
|
+
return waiting("runner_context_unverified", selected);
|
|
552
|
+
if (credential && !isUnenrolled(credential) && credential.team !== teamKey)
|
|
553
|
+
return waiting("runner_enrolled_for_other_team", selected);
|
|
554
|
+
const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
|
|
555
|
+
if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
|
|
556
|
+
return waiting(runnerSupport.reason, selected);
|
|
557
|
+
const cloud = !("reason" in runnerSupport);
|
|
479
558
|
const saved = readEnvFile(join(dir, ".env"));
|
|
480
559
|
const images = {
|
|
481
560
|
supervisor: ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor,
|
|
@@ -486,11 +565,12 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
486
565
|
return waiting("runner_image_unpinned", selected);
|
|
487
566
|
if (!info.vm && !(await engine.nativeEgressStatus?.(ctx.now().getTime(), signal)))
|
|
488
567
|
return waiting("runner_native_egress_setup_required", selected);
|
|
489
|
-
return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images
|
|
568
|
+
return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images,
|
|
569
|
+
...(installation && installation !== "missing" ? { installedName: installation.hostName } : {}) };
|
|
490
570
|
}
|
|
491
571
|
async function desiredEnv(p, joinToken) {
|
|
492
572
|
const saved = readEnvFile(join(p.dir, ".env")) ?? {};
|
|
493
|
-
const name = saved.CATALYST_HOST_NAME || (input.hostName ?? runnerDefaultHostName)();
|
|
573
|
+
const name = p.installedName || saved.CATALYST_HOST_NAME || chosenName || (input.hostName ?? runnerDefaultHostName)();
|
|
494
574
|
const gid = p.info.vm ? 0 : await engine.socketGid();
|
|
495
575
|
if (!HOST_NAME.test(name) || gid === null)
|
|
496
576
|
return null;
|
|
@@ -582,11 +662,22 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
582
662
|
return waiting("runner_docker_socket_unreadable", selected);
|
|
583
663
|
const name = want.CATALYST_HOST_NAME;
|
|
584
664
|
let enrolled = null;
|
|
585
|
-
const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host.
|
|
665
|
+
const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host.team === p.teamKey && !host.revoked);
|
|
586
666
|
let hosts = await listHosts(ctx, p, signal);
|
|
587
667
|
if (!hosts)
|
|
588
668
|
return waiting("runner_enrollment_unavailable", selected);
|
|
589
|
-
|
|
669
|
+
const existingCredential = await engine.enrollment(p.dir, signal);
|
|
670
|
+
if ((!existingCredential || isUnenrolled(existingCredential)) &&
|
|
671
|
+
hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
|
|
672
|
+
return waiting("runner_enrolled_for_other_team", {
|
|
673
|
+
...selected,
|
|
674
|
+
hostName: name,
|
|
675
|
+
});
|
|
676
|
+
if (existingCredential &&
|
|
677
|
+
!isUnenrolled(existingCredential) &&
|
|
678
|
+
hosts.some((host) => host.hostId === existingCredential.hostId &&
|
|
679
|
+
!host.revoked &&
|
|
680
|
+
host.team !== p.teamKey))
|
|
590
681
|
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
591
682
|
const running = await engine.composeRunning(p.dir, signal);
|
|
592
683
|
const changed = !saved || Object.entries(want).some(([key, value]) => saved[key] !== value);
|
|
@@ -607,8 +698,13 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
607
698
|
return waiting("runner_identity_unverified", hostEvidence);
|
|
608
699
|
if (enrolled && enrolled.team !== p.teamKey)
|
|
609
700
|
return waiting("runner_enrolled_for_other_team", hostEvidence);
|
|
610
|
-
if (enrolled
|
|
611
|
-
|
|
701
|
+
if (enrolled) {
|
|
702
|
+
const exact = hosts.find((host) => host.hostId === enrolled.hostId);
|
|
703
|
+
if (exact?.revoked)
|
|
704
|
+
return waiting("runner_enrollment_revoked", { ...hostEvidence, hostId: enrolled.hostId });
|
|
705
|
+
if (!exact || exact.team !== p.teamKey)
|
|
706
|
+
return waiting("runner_enrollment_unverified", hostEvidence);
|
|
707
|
+
}
|
|
612
708
|
const host = live(hosts);
|
|
613
709
|
if (host && host.capacity !== null)
|
|
614
710
|
break;
|
|
@@ -619,7 +715,7 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
619
715
|
await sleep(pollMs);
|
|
620
716
|
}
|
|
621
717
|
const host = live(hosts);
|
|
622
|
-
const evidence = { ...hostEvidence, hostId: host.hostId, capacity: host.capacity };
|
|
718
|
+
const evidence = { ...hostEvidence, hostName: host.hostName, hostId: host.hostId, capacity: host.capacity };
|
|
623
719
|
const orgKey = orgKeyFromFile(ctx);
|
|
624
720
|
if (orgKey === null)
|
|
625
721
|
return waiting("runner_org_key_file_invalid", evidence);
|
|
@@ -682,6 +778,17 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
682
778
|
catch {
|
|
683
779
|
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
684
780
|
}
|
|
781
|
+
if (!p.installedName &&
|
|
782
|
+
!readEnvFile(join(p.dir, ".env"))?.CATALYST_HOST_NAME &&
|
|
783
|
+
chosenName === undefined) {
|
|
784
|
+
const suggested = (input.hostName ?? runnerDefaultHostName)();
|
|
785
|
+
const name = input.chooseName
|
|
786
|
+
? await input.chooseName(suggested)
|
|
787
|
+
: suggested;
|
|
788
|
+
if (name === null || !HOST_NAME.test(name))
|
|
789
|
+
return waiting("runner_name_required", selected);
|
|
790
|
+
chosenName = name;
|
|
791
|
+
}
|
|
685
792
|
let want = await desiredEnv(p);
|
|
686
793
|
if (!want)
|
|
687
794
|
return waiting("runner_docker_socket_unreadable", selected);
|
|
@@ -725,14 +832,22 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
725
832
|
const hosts = await listHosts(ctx, p, signal);
|
|
726
833
|
if (!hosts)
|
|
727
834
|
return waiting("runner_enrollment_unavailable", selected);
|
|
728
|
-
if (
|
|
835
|
+
if (!enrolled &&
|
|
836
|
+
hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
|
|
729
837
|
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
730
838
|
if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
|
|
731
839
|
return waiting("runner_identity_unverified", { ...selected, hostName: name });
|
|
732
840
|
if (enrolled && enrolled.team !== p.teamKey)
|
|
733
841
|
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
734
|
-
if (hasCredential && !isUnenrolled(credential)
|
|
735
|
-
|
|
842
|
+
if (hasCredential && !isUnenrolled(credential)) {
|
|
843
|
+
if (!enrolled)
|
|
844
|
+
return waiting("runner_credential_unverified", { ...selected, hostName: name });
|
|
845
|
+
const exact = hosts.find((host) => host.hostId === enrolled.hostId);
|
|
846
|
+
if (exact?.revoked)
|
|
847
|
+
return waiting("runner_enrollment_revoked", { ...selected, hostName: name, hostId: enrolled.hostId });
|
|
848
|
+
if (!exact || exact.team !== p.teamKey)
|
|
849
|
+
return waiting("runner_enrollment_unverified", { ...selected, hostName: name });
|
|
850
|
+
}
|
|
736
851
|
// A lost volume also lost the secret that redeemed the old token. Mint a new token and match
|
|
737
852
|
// the new host id from the supervisor's credential, never an old advertisement with this name.
|
|
738
853
|
// A lost reply must retry the same token and retained secret: a new token would be
|
package/dist/onboard-runtime.js
CHANGED
|
@@ -8,6 +8,9 @@ import { cmdLegacy, findLegacy } from "./legacy.js";
|
|
|
8
8
|
import { personalConsentAdapter } from "./onboard-personal.js";
|
|
9
9
|
import { boundedOnboardSignin } from "./onboard-signin.js";
|
|
10
10
|
import { existingLinearAdapters } from "./onboard-existing.js";
|
|
11
|
+
import { returningWorkspaceAdapters } from "./onboard-returning.js";
|
|
12
|
+
import { archiveReturningProject } from "./onboard-project-cleanup.js";
|
|
13
|
+
import { CREATE_TEAM_CHOICE } from "./onboard-team-create.js";
|
|
11
14
|
import { linearWorkspaceAdapter } from "./onboard-workspace.js";
|
|
12
15
|
import { firstProjectAdapters } from "./onboard-projects.js";
|
|
13
16
|
import { onboardCapacityAdapter } from "./onboard-capacity.js";
|
|
@@ -175,8 +178,15 @@ export function createOnboardRuntime(args, ctx, hooks) {
|
|
|
175
178
|
? (message, run) => waitFor(message, run)
|
|
176
179
|
: undefined,
|
|
177
180
|
});
|
|
181
|
+
let newProjectRequested = false;
|
|
178
182
|
const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
|
|
179
|
-
? (teams, create) =>
|
|
183
|
+
? async (teams, create) => {
|
|
184
|
+
if (newProjectRequested) {
|
|
185
|
+
newProjectRequested = false;
|
|
186
|
+
return CREATE_TEAM_CHOICE;
|
|
187
|
+
}
|
|
188
|
+
return hooks.ui.chooseTeam(teams.map(team => ({ ...team })), create);
|
|
189
|
+
}
|
|
180
190
|
: undefined, hooks.ui?.nameNewTeam
|
|
181
191
|
? (question) => hooks.ui.nameNewTeam(question)
|
|
182
192
|
: undefined, (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)));
|
|
@@ -314,6 +324,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
|
|
|
314
324
|
? () => hooks.ui.chooseRunner()
|
|
315
325
|
: undefined,
|
|
316
326
|
engine: hooks.runnerEngine,
|
|
327
|
+
hostName: typeof args.flags["machine-name"] === "string"
|
|
328
|
+
? () => String(args.flags["machine-name"])
|
|
329
|
+
: undefined,
|
|
330
|
+
chooseName: interactiveWait &&
|
|
331
|
+
hooks.ui?.nameMachine &&
|
|
332
|
+
args.flags["machine-name"] === undefined
|
|
333
|
+
? (name) => hooks.ui.nameMachine(name)
|
|
334
|
+
: undefined,
|
|
317
335
|
sleep: hooks.sleep,
|
|
318
336
|
message: (text) => (hooks.ui ? hooks.ui.message(text) : ctx.stderr(text)),
|
|
319
337
|
}),
|
|
@@ -473,7 +491,14 @@ export function createOnboardRuntime(args, ctx, hooks) {
|
|
|
473
491
|
}
|
|
474
492
|
// Public auth discovery and the existing /me sign-in bootstrap establish identity before the
|
|
475
493
|
// tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
|
|
494
|
+
let reviewActive = false;
|
|
495
|
+
returningWorkspaceAdapters(adapters, args, hooks.ui?.reviewProjects?.bind(hooks.ui), text => hooks.ui ? hooks.ui.message(text) : ctx.stderr(text), () => { newProjectRequested = true; }, () => reviewActive, (project, stepCtx, journal, signal) => archiveReturningProject(project, stepCtx, journal, hooks.ui?.confirmProjectCleanup?.bind(hooks.ui), signal));
|
|
476
496
|
return {
|
|
497
|
+
beforeStep: async (id) => {
|
|
498
|
+
await hooks.beforeStep?.(id);
|
|
499
|
+
if (id === "linear.workspace")
|
|
500
|
+
reviewActive = true;
|
|
501
|
+
},
|
|
477
502
|
identity: (journal) => identity(ctx, journal),
|
|
478
503
|
...(hooks.stageSignin ? { stageSignin: hooks.stageSignin } : {}),
|
|
479
504
|
bindSignals: true,
|
package/dist/onboard-ui.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
|
|
1
2
|
import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
|
|
2
3
|
import { trackedPrompt } from "./setup-prompt-frame.js";
|
|
3
4
|
import { createOnboardInterrupts } from "./onboard-interrupts.js";
|
|
@@ -157,11 +158,13 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
157
158
|
return;
|
|
158
159
|
if (renderer &&
|
|
159
160
|
currentStep === "linear.adopt" &&
|
|
160
|
-
text.endsWith("
|
|
161
|
+
text.endsWith("has every required state and label"))
|
|
161
162
|
return;
|
|
162
163
|
if (renderer) {
|
|
163
164
|
if (currentStep &&
|
|
164
165
|
setupBrowserInstruction(currentStep, deps.baseUrl?.())) {
|
|
166
|
+
if (currentStep === "github.install" && text === GITHUB_CONFIRM_ACCESS)
|
|
167
|
+
renderer.detail(text);
|
|
165
168
|
if (/no browser opened|could not open a browser/i.test(text))
|
|
166
169
|
browserOpened = false;
|
|
167
170
|
return;
|
|
@@ -441,6 +444,49 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
441
444
|
...(answer === "signin" ? { signin: true } : {}),
|
|
442
445
|
};
|
|
443
446
|
},
|
|
447
|
+
async reviewProjects(projects) {
|
|
448
|
+
stop();
|
|
449
|
+
if (abort.signal.aborted)
|
|
450
|
+
return null;
|
|
451
|
+
const answer = await select({
|
|
452
|
+
...options,
|
|
453
|
+
message: "What should setup do with this workspace's projects?",
|
|
454
|
+
initialValue: "move-on",
|
|
455
|
+
options: [
|
|
456
|
+
{ value: "move-on", label: "Move on and finish this computer's setup", hint: "default" },
|
|
457
|
+
...projects.map(p => ({ value: `repair:${p.id}`, label: `Review or repair ${p.name} (${p.key})` })),
|
|
458
|
+
...projects.map(p => ({ value: `cleanup:${p.id}`, label: `Preview archiving ${p.name} (${p.key})` })),
|
|
459
|
+
{ value: "new", label: "Create a new Catalyst project" },
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
if (prompts.isCancel(answer)) {
|
|
463
|
+
abort.abort();
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
if (answer === "move-on" || answer === "new")
|
|
467
|
+
return answer;
|
|
468
|
+
if (typeof answer === "string" && answer.startsWith("cleanup:"))
|
|
469
|
+
return { cleanup: answer.slice(8) };
|
|
470
|
+
return typeof answer === "string" && answer.startsWith("repair:") ? { repair: answer.slice(7) } : null;
|
|
471
|
+
},
|
|
472
|
+
async confirmProjectCleanup(project, lines) {
|
|
473
|
+
stop();
|
|
474
|
+
for (const line of lines)
|
|
475
|
+
message(line);
|
|
476
|
+
if (abort.signal.aborted)
|
|
477
|
+
return false;
|
|
478
|
+
const answer = await select({ ...options,
|
|
479
|
+
message: `Archive ${project.name} after reviewing these changes?`,
|
|
480
|
+
initialValue: "keep",
|
|
481
|
+
options: [{ value: "keep", label: "Keep the project and finish this computer's setup" },
|
|
482
|
+
{ value: "archive", label: "Archive this project" }],
|
|
483
|
+
});
|
|
484
|
+
if (prompts.isCancel(answer)) {
|
|
485
|
+
abort.abort();
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
return answer === "archive";
|
|
489
|
+
},
|
|
444
490
|
async chooseTeam(teams, create) {
|
|
445
491
|
stop();
|
|
446
492
|
// Creating needs a name prompt; a port without one offers only the existing teams.
|
|
@@ -668,6 +714,23 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
668
714
|
}
|
|
669
715
|
return typeof answer === "string" ? answer : null;
|
|
670
716
|
},
|
|
717
|
+
async nameMachine(defaultName) {
|
|
718
|
+
if (abort.signal.aborted)
|
|
719
|
+
return null;
|
|
720
|
+
const answer = await text({
|
|
721
|
+
...options,
|
|
722
|
+
message: "Name for this machine",
|
|
723
|
+
initialValue: defaultName,
|
|
724
|
+
validate: (value) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(value ?? "")
|
|
725
|
+
? undefined
|
|
726
|
+
: "Use 1 to 63 letters, digits, dots, underscores or hyphens, starting with a letter or digit.",
|
|
727
|
+
});
|
|
728
|
+
if (typeof answer !== "string") {
|
|
729
|
+
abort.abort();
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
return answer;
|
|
733
|
+
},
|
|
671
734
|
async chooseRunner() {
|
|
672
735
|
if (abort.signal.aborted)
|
|
673
736
|
return null;
|
|
@@ -1028,6 +1091,8 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
1028
1091
|
? status()
|
|
1029
1092
|
: "waiting for you, up to 10 minutes");
|
|
1030
1093
|
renderer.action(browser.instruction);
|
|
1094
|
+
if (currentStep === "github.install")
|
|
1095
|
+
renderer.detail(GITHUB_CONFIRM_ACCESS);
|
|
1031
1096
|
if (browser.url)
|
|
1032
1097
|
renderer.detail(renderer.link(browser.url));
|
|
1033
1098
|
renderer.detail(renderer.dim(interactive ? "Ctrl-C skips this step." : "Ctrl-C stops setup."));
|
|
@@ -1109,6 +1174,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
1109
1174
|
delete ui.chooseFirstRepository;
|
|
1110
1175
|
delete ui.reviewSettings;
|
|
1111
1176
|
delete ui.chooseRunner;
|
|
1177
|
+
delete ui.nameMachine;
|
|
1112
1178
|
delete ui.chooseFirstTicket;
|
|
1113
1179
|
delete ui.retryTimedOut;
|
|
1114
1180
|
delete ui.confirmWorkflowAdoption;
|
package/dist/onboard-workflow.js
CHANGED
|
@@ -145,13 +145,36 @@ export function observeOnboardWorkflow(value, team, now) {
|
|
|
145
145
|
: {}),
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
|
-
const
|
|
148
|
+
const plainText = (text) => text
|
|
149
149
|
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g, "")
|
|
150
|
-
.trim()
|
|
151
|
-
|
|
150
|
+
.trim();
|
|
151
|
+
const label = (text) => plainText(text).slice(0, 80);
|
|
152
152
|
const listed = (names) => names.length > 10
|
|
153
153
|
? `${names.slice(0, 10).join(", ")}, and ${names.length - 10} more`
|
|
154
154
|
: names.join(", ");
|
|
155
|
+
function unfinishedLabelLines(plan) {
|
|
156
|
+
return [
|
|
157
|
+
...plan.labels.filter((row) => row.outcome === "refused").map((row) => {
|
|
158
|
+
const name = plainText(row.name);
|
|
159
|
+
const reason = plainText(row.reason ?? "Linear did not supply a reason.");
|
|
160
|
+
const guidance = name === "review-followup"
|
|
161
|
+
? "Optional for the cloud workflow; no action is needed to finish setup."
|
|
162
|
+
: [
|
|
163
|
+
name === "catalyst-not-an-ask"
|
|
164
|
+
? "Required for the cloud workflow. Without this label, work held by the ask-shape check cannot be released."
|
|
165
|
+
: "Only missing required labels block workflow readiness.",
|
|
166
|
+
"Ask a Linear workspace administrator to resolve the reported refusal and make this label available to the team, then run catalyst onboard.",
|
|
167
|
+
].join(" ");
|
|
168
|
+
return `Linear refused label: ${name}. ${reason} ${guidance}`;
|
|
169
|
+
}),
|
|
170
|
+
...(plan.labelsNotCreated ?? []).map((row) => {
|
|
171
|
+
const entry = object(row);
|
|
172
|
+
const name = typeof entry?.name === "string" ? plainText(entry.name) : "";
|
|
173
|
+
const reason = typeof entry?.reason === "string" ? plainText(entry.reason) : "";
|
|
174
|
+
return `Label not added: ${name || "Unnamed label"}. ${reason || "The server did not supply a reason; run catalyst onboard again to retry."}`;
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
155
178
|
/** The adopt preview in plain words: stages and labels it creates or keeps. Linear's names are data. */
|
|
156
179
|
export function adoptPlanLines(plan) {
|
|
157
180
|
const group = (rows, outcome) => rows.filter((row) => row.outcome === outcome).map((row) => label(row.name));
|
|
@@ -161,11 +184,10 @@ export function adoptPlanLines(plan) {
|
|
|
161
184
|
["Linear refused stages", group(plan.stages, "refused")],
|
|
162
185
|
["Create labels", group(plan.labels, "would-create")],
|
|
163
186
|
["Labels already present", group(plan.labels, "already-present")],
|
|
164
|
-
["Linear refused labels", group(plan.labels, "refused")],
|
|
165
187
|
]
|
|
166
188
|
.filter(([, names]) => names.length > 0)
|
|
167
189
|
.map(([title, names]) => `${title}: ${listed(names)}`)
|
|
168
|
-
.concat(plan.unfilledLoadBearing?.length ? [`Required work stages still missing: ${plan.unfilledLoadBearing.length}`] : [], (plan.provenanceGaps?.length ?? 0) + (plan.labelProvenanceGaps?.length ?? 0) ? ["Some existing states or labels have no recorded creator; setup keeps them"] : [], plan
|
|
190
|
+
.concat(plan.unfilledLoadBearing?.length ? [`Required work stages still missing: ${plan.unfilledLoadBearing.length}`] : [], (plan.provenanceGaps?.length ?? 0) + (plan.labelProvenanceGaps?.length ?? 0) ? ["Some existing states or labels have no recorded creator; setup keeps them"] : [], unfinishedLabelLines(plan));
|
|
169
191
|
}
|
|
170
192
|
/** The only adopt bodies setup sends: this team, and the approved mode and hash. */
|
|
171
193
|
export function adoptRequestAllowed(body, team, allow) {
|
|
@@ -391,7 +413,7 @@ export function onboardWorkflowVerificationAdapter(input = {}) {
|
|
|
391
413
|
: {}),
|
|
392
414
|
},
|
|
393
415
|
};
|
|
394
|
-
}, () => input.message?.(`${onboardTeamKey(journal) ?? "This team"}
|
|
416
|
+
}, () => input.message?.(`${onboardTeamKey(journal) ?? "This team"} has every required state and label`));
|
|
395
417
|
if (!adopts)
|
|
396
418
|
return { check };
|
|
397
419
|
// A local non-admin never gets here; a 403 means the seat changed after login.
|
|
@@ -440,7 +462,10 @@ export function onboardWorkflowVerificationAdapter(input = {}) {
|
|
|
440
462
|
}
|
|
441
463
|
const created = (rows) => rows.filter((row) => row.outcome === "created").length;
|
|
442
464
|
const stages = created(reply.stages), labels = created(reply.labels);
|
|
443
|
-
summary =
|
|
465
|
+
summary = [
|
|
466
|
+
`Applied the Catalyst workflow to ${key}: created ${stages} ${stages === 1 ? "stage" : "stages"} and ${labels} ${labels === 1 ? "label" : "labels"}.`,
|
|
467
|
+
...unfinishedLabelLines(reply),
|
|
468
|
+
].join("\n");
|
|
444
469
|
return { state: "done" };
|
|
445
470
|
}, () => input.message?.(summary));
|
|
446
471
|
// Only the server's own plan-stale answer plans again; a later identity refusal does not.
|
package/dist/onboard.js
CHANGED
|
@@ -268,6 +268,7 @@ function normalizeStep(value, fallbackAt) {
|
|
|
268
268
|
"membershipId",
|
|
269
269
|
"checkedAt",
|
|
270
270
|
"project",
|
|
271
|
+
"projectCount",
|
|
271
272
|
"repository",
|
|
272
273
|
"installation",
|
|
273
274
|
"ticket",
|
|
@@ -673,6 +674,7 @@ export function stepSatisfied(step) {
|
|
|
673
674
|
// Optional: a computer without the daily update still runs work.
|
|
674
675
|
step.id === "housekeeping" ||
|
|
675
676
|
step.reason === "member_scope" ||
|
|
677
|
+
step.reason === "returning_workspace_move_on" ||
|
|
676
678
|
(step.id === "daemon" && step.reason === "local_sync_not_selected") ||
|
|
677
679
|
(step.id === "runner" && step.reason === "runner_not_selected") ||
|
|
678
680
|
(step.id === "first-ticket" && step.reason === "first_ticket_skipped") ||
|
|
@@ -711,7 +713,8 @@ export function onboardRequiredStepsWaiting(journal) {
|
|
|
711
713
|
}
|
|
712
714
|
/** A full run that exited 0 with deferred steps still open: ready for work, not complete. */
|
|
713
715
|
export function onboardReadyForWork(journal, only) {
|
|
714
|
-
return !only && journal.exit === 0 && !journal.complete
|
|
716
|
+
return !only && journal.exit === 0 && !journal.complete &&
|
|
717
|
+
!journal.steps.some(step => step.reason === "returning_workspace_move_on");
|
|
715
718
|
}
|
|
716
719
|
/** A recorded step's next action; a prerequisite wait names the first unfinished step it needs. */
|
|
717
720
|
export function onboardStepAction(journal, step, baseUrl) {
|
|
@@ -1143,7 +1146,11 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
|
|
|
1143
1146
|
else if (deps.ui)
|
|
1144
1147
|
deps.ui.finish(journal, only);
|
|
1145
1148
|
else {
|
|
1146
|
-
if (journal.complete)
|
|
1149
|
+
if (journal.complete && journal.steps.some(step => step.reason === "returning_workspace_move_on")) {
|
|
1150
|
+
ctx.stdout("This computer is set up. Existing projects were kept in place.");
|
|
1151
|
+
ctx.stdout("Next: run catalyst ready to review their checks.");
|
|
1152
|
+
}
|
|
1153
|
+
else if (journal.complete)
|
|
1147
1154
|
ctx.stdout("Onboarding complete.");
|
|
1148
1155
|
else if (onboardReadyForWork(journal, only) &&
|
|
1149
1156
|
(deps.requiredSteps ?? []).every((id) => journalStep(journal, id)?.state === "done")) {
|