@catalyst-cloud/cli 0.16.0 → 0.16.2
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 +6 -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-headless.js +2 -2
- package/dist/onboard-next.js +18 -10
- 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 +61 -0
- 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 +3 -3
- 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 +13 -13
- 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 +49 -26
- 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 +1 -1
- package/skills/what-needs-me/references/raising-a-decision.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +1 -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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
|
+
import { readExistingOnboardJson } from "./onboard-existing.js";
|
|
3
|
+
import { readReturningProjects } from "./onboard-returning.js";
|
|
4
|
+
const waiting = (reason) => ({ state: "waiting", reason });
|
|
5
|
+
const signature = (project) => JSON.stringify([...(project.repositories ?? [])].sort((a, b) => a.id.localeCompare(b.id)));
|
|
6
|
+
/** Archiving uses the existing reversible project action. Only the explicitly reviewed scope
|
|
7
|
+
* is changed, after fresh inventory and identity checks. A failed write is never assumed absent. */
|
|
8
|
+
export async function archiveReturningProject(project, ctx, journal, confirm, signal) {
|
|
9
|
+
const cfg = loadConfig(ctx.home);
|
|
10
|
+
const sameIdentity = () => {
|
|
11
|
+
const now = loadConfig(ctx.home);
|
|
12
|
+
return cfg?.user && now?.user && cfg.account === journal.account &&
|
|
13
|
+
cfg.user.id === journal.membershipId && now.account === cfg.account &&
|
|
14
|
+
now.user.id === cfg.user.id && ["owner", "admin"].includes(now.user.role) &&
|
|
15
|
+
normalizeBaseUrl(now.baseUrl) === journal.baseUrl && normalizeBaseUrl(cfg.baseUrl) === journal.baseUrl;
|
|
16
|
+
};
|
|
17
|
+
if (!sameIdentity() || !confirm)
|
|
18
|
+
return waiting("returning_project_cleanup_unverified");
|
|
19
|
+
const fresh = await readReturningProjects(ctx, journal, signal);
|
|
20
|
+
const selected = "projects" in fresh ? fresh.projects.find(p => p.id === project.id) : undefined;
|
|
21
|
+
if (!selected?.repositories?.length || selected.repositories.length > 64 || !sameIdentity())
|
|
22
|
+
return waiting("returning_project_cleanup_unverified");
|
|
23
|
+
const linked = async () => {
|
|
24
|
+
const names = new Set();
|
|
25
|
+
const boundedRead = signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
|
|
26
|
+
for (const row of selected.repositories) {
|
|
27
|
+
const read = await readExistingOnboardJson(ctx, `/api/v1/me/projects/${encodeURIComponent(row.id)}/repositories`, boundedRead);
|
|
28
|
+
if ("reason" in read || !read.body || typeof read.body !== "object")
|
|
29
|
+
return null;
|
|
30
|
+
const body = read.body;
|
|
31
|
+
if (!body.project || typeof body.project !== "object" ||
|
|
32
|
+
!("id" in body.project) || body.project.id !== row.id ||
|
|
33
|
+
!("teamId" in body.project) || body.project.teamId !== selected.id ||
|
|
34
|
+
!Array.isArray(body.repositories) || !body.repositories.length || body.repositories.length > 10_000)
|
|
35
|
+
return null;
|
|
36
|
+
const returnedNames = new Set();
|
|
37
|
+
for (const repository of body.repositories) {
|
|
38
|
+
if (!repository || typeof repository !== "object" || !("fullName" in repository) ||
|
|
39
|
+
typeof repository.fullName !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(repository.fullName))
|
|
40
|
+
return null;
|
|
41
|
+
names.add(repository.fullName);
|
|
42
|
+
returnedNames.add(repository.fullName);
|
|
43
|
+
}
|
|
44
|
+
if (!returnedNames.has(row.name))
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return [...names].sort();
|
|
48
|
+
};
|
|
49
|
+
const linkedNames = await linked();
|
|
50
|
+
if (!linkedNames || !sameIdentity())
|
|
51
|
+
return waiting("returning_project_cleanup_unverified");
|
|
52
|
+
const lines = [
|
|
53
|
+
`Archive ${selected.name} (${selected.key}) in Catalyst.`,
|
|
54
|
+
...linkedNames.map(name => `Repository affected: ${name}.`),
|
|
55
|
+
"Archiving removes this project’s repository registrations. Mirroring may stop where no other registration keeps a repository active.",
|
|
56
|
+
"The project and its repository links are kept so it can be reactivated. Linear tickets and GitHub repositories are kept.",
|
|
57
|
+
"New work may be held when these repositories are no longer registered for this project.",
|
|
58
|
+
];
|
|
59
|
+
if (!(await confirm(selected, lines)))
|
|
60
|
+
return { state: "done", reason: "returning_project_cleanup_declined" };
|
|
61
|
+
if (signal?.aborted || !sameIdentity())
|
|
62
|
+
return waiting("returning_project_cleanup_unverified");
|
|
63
|
+
const rechecked = await readReturningProjects(ctx, journal, signal);
|
|
64
|
+
const current = "projects" in rechecked ? rechecked.projects.find(p => p.id === selected.id) : undefined;
|
|
65
|
+
if (!current || signature(current) !== signature(selected) || !sameIdentity())
|
|
66
|
+
return waiting("returning_project_cleanup_changed");
|
|
67
|
+
const recheckedLinks = await linked();
|
|
68
|
+
if (!recheckedLinks || JSON.stringify(recheckedLinks) !== JSON.stringify(linkedNames) || !sameIdentity())
|
|
69
|
+
return waiting("returning_project_cleanup_changed");
|
|
70
|
+
const bearer = cfg?.key || (cfg?.auth && Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() > 30_000
|
|
71
|
+
? cfg.auth.accessToken : undefined);
|
|
72
|
+
if (!bearer)
|
|
73
|
+
return waiting("returning_project_cleanup_unverified");
|
|
74
|
+
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
|
|
75
|
+
for (const row of selected.repositories) {
|
|
76
|
+
if (bounded.aborted || !sameIdentity())
|
|
77
|
+
return waiting("returning_project_cleanup_unverified");
|
|
78
|
+
try {
|
|
79
|
+
const response = await ctx.fetch(`${journal.baseUrl}/api/v1/me/repositories/${encodeURIComponent(row.id)}/archive`, {
|
|
80
|
+
method: "POST", redirect: "error", signal: bounded,
|
|
81
|
+
headers: { authorization: `Bearer ${bearer}`, accept: "application/json", "content-type": "application/json" },
|
|
82
|
+
body: "{}",
|
|
83
|
+
});
|
|
84
|
+
if (response.status !== 200)
|
|
85
|
+
return waiting("returning_project_cleanup_unverified");
|
|
86
|
+
const body = await response.json();
|
|
87
|
+
const returned = body && typeof body === "object" && "repository" in body ? body.repository : null;
|
|
88
|
+
if (!returned || typeof returned !== "object" || !("id" in returned) || returned.id !== row.id ||
|
|
89
|
+
!("linearTeamId" in returned) || returned.linearTeamId !== selected.id ||
|
|
90
|
+
!("status" in returned) || returned.status !== "archived")
|
|
91
|
+
return waiting("returning_project_cleanup_unverified");
|
|
92
|
+
journal.changes.push({ kind: "project-archive", label: `Archived ${selected.name}: ${row.name}`,
|
|
93
|
+
undo: "Reactivate the project from its Catalyst project page." });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return waiting("returning_project_cleanup_unverified");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { state: "done" };
|
|
100
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
|
+
import { readExistingOnboardJson, readOnboardTeamInventory } from "./onboard-existing.js";
|
|
3
|
+
import { teamCheckCopy } from "./ready-copy.js";
|
|
4
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
5
|
+
const repository = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
6
|
+
/** Count configured teams across their repositories, rather than every visible Linear team. */
|
|
7
|
+
export async function readReturningProjects(ctx, journal, signal) {
|
|
8
|
+
const cfg = loadConfig(ctx.home);
|
|
9
|
+
if (!cfg?.user || cfg.account !== journal.account || cfg.user.id !== journal.membershipId ||
|
|
10
|
+
normalizeBaseUrl(cfg.baseUrl) !== journal.baseUrl)
|
|
11
|
+
return { reason: "returning_inventory_unverified" };
|
|
12
|
+
const contract = await readExistingOnboardJson(ctx, "/api/v1/agent/contract", signal);
|
|
13
|
+
if ("reason" in contract)
|
|
14
|
+
return { reason: "returning_inventory_unverified" };
|
|
15
|
+
const doc = object(contract.body);
|
|
16
|
+
if (object(doc?.account)?.id !== cfg.account || !Array.isArray(doc?.routes))
|
|
17
|
+
return { reason: "returning_inventory_unverified" };
|
|
18
|
+
const routes = doc.routes.map(object).filter(r => r?.method === "GET" && typeof r.path === "string" &&
|
|
19
|
+
/^\/api\/v1\/[A-Za-z0-9_/-]+\/tenant\/repositories$/.test(r.path));
|
|
20
|
+
if (routes.length !== 1)
|
|
21
|
+
return { reason: "returning_inventory_unverified" };
|
|
22
|
+
// The contract's mapped teams omit projects that have not adopted a workflow yet. Use the
|
|
23
|
+
// complete project inventory, including paused and archived rows, and group active rows by team.
|
|
24
|
+
const read = await readExistingOnboardJson(ctx, routes[0].path, signal);
|
|
25
|
+
if ("reason" in read)
|
|
26
|
+
return { reason: "returning_inventory_unverified" };
|
|
27
|
+
const list = object(read.body);
|
|
28
|
+
if (list?.canManage !== true || !Array.isArray(list.repositories) || list.repositories.length > 10_000)
|
|
29
|
+
return { reason: "returning_inventory_unverified" };
|
|
30
|
+
const inventory = await readOnboardTeamInventory(ctx, signal);
|
|
31
|
+
const configured = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const clean = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
|
|
34
|
+
for (const value of list.repositories) {
|
|
35
|
+
const row = object(value);
|
|
36
|
+
if (typeof row?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(row.id) || seen.has(row.id) ||
|
|
37
|
+
typeof row.linearTeamId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(row.linearTeamId) ||
|
|
38
|
+
typeof row.name !== "string" || !row.name.trim() ||
|
|
39
|
+
!(row.linearTeamKey === null || typeof row.linearTeamKey === "string") ||
|
|
40
|
+
!["active", "paused", "archived"].includes(String(row.status)) ||
|
|
41
|
+
typeof row.githubRepoOwner !== "string" || typeof row.githubRepoName !== "string" ||
|
|
42
|
+
!repository.test(`${row.githubRepoOwner}/${row.githubRepoName}`))
|
|
43
|
+
return { reason: "returning_inventory_unverified" };
|
|
44
|
+
seen.add(row.id);
|
|
45
|
+
if (row.status === "archived")
|
|
46
|
+
continue;
|
|
47
|
+
const savedProject = configured.find(p => p.id === row.linearTeamId);
|
|
48
|
+
const linked = { id: row.id, name: `${row.githubRepoOwner}/${row.githubRepoName}`, status: row.status };
|
|
49
|
+
if (savedProject) {
|
|
50
|
+
savedProject.repositories.push(linked);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const team = "reason" in inventory ? undefined : inventory.teams.find(t => t.id === row.linearTeamId);
|
|
54
|
+
configured.push({ id: row.linearTeamId, key: team?.key ?? clean(row.linearTeamKey ?? ""),
|
|
55
|
+
name: team?.name ?? clean(row.name), status: "Readiness could not be checked. Run catalyst ready to check it.", repositories: [linked] });
|
|
56
|
+
}
|
|
57
|
+
// Bound the whole readiness summary, including a workspace with many projects.
|
|
58
|
+
const deadline = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => deadline.abort(), 30_000);
|
|
60
|
+
const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
61
|
+
try {
|
|
62
|
+
for (let start = 0; start < configured.length && !bounded.aborted; start += 4) {
|
|
63
|
+
await Promise.all(configured.slice(start, start + 4).map(async (project) => {
|
|
64
|
+
const read = await readExistingOnboardJson(ctx, `/api/v1/agent/tenant/readiness?team=${encodeURIComponent(project.id)}`, bounded);
|
|
65
|
+
if ("reason" in read)
|
|
66
|
+
return;
|
|
67
|
+
const row = object(object(read.body)?.readiness);
|
|
68
|
+
if (row?.teamId !== project.id || !["ready", "degraded", "blocked"].includes(String(row.status)) ||
|
|
69
|
+
typeof row.checkedAt !== "number" || !Number.isFinite(row.checkedAt) || row.checkedAt < 0 || row.checkedAt > ctx.now().getTime() ||
|
|
70
|
+
!Array.isArray(row.checks) || !row.checks.length || row.checks.length > 100)
|
|
71
|
+
return;
|
|
72
|
+
const checks = row.checks.map(object);
|
|
73
|
+
if (!checks.every(c => c && typeof c.id === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(c.id) &&
|
|
74
|
+
["pass", "fail", "unknown"].includes(String(c.state))) ||
|
|
75
|
+
new Set(checks.map(c => c.id)).size !== checks.length)
|
|
76
|
+
return;
|
|
77
|
+
const unfinished = checks.filter(c => c.state !== "pass");
|
|
78
|
+
if (row.status === "ready" && !unfinished.length)
|
|
79
|
+
project.status = "Ready";
|
|
80
|
+
else if (unfinished.length)
|
|
81
|
+
project.status = unfinished.map(c => teamCheckCopy(cfg.baseUrl, project.key, c.id, c.state, null).line
|
|
82
|
+
.replace(`team ${project.key}: `, "")).join(" ");
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
for (const project of configured) {
|
|
90
|
+
const paused = project.repositories?.filter(r => r.status === "paused") ?? [];
|
|
91
|
+
if (paused.length)
|
|
92
|
+
project.status = `Paused: ${paused.map(r => r.name).join(", ")}. ${project.status === "Ready" ? "Readiness checks pass." : project.status}`;
|
|
93
|
+
}
|
|
94
|
+
return { projects: configured };
|
|
95
|
+
}
|
|
96
|
+
const PROJECT_STEPS = new Set([
|
|
97
|
+
"linear.workspace", "linear.personal", "linear.team", "linear.adopt", "linear.automations",
|
|
98
|
+
"github.install", "github.personal", "github.repos", "projects", "settings", "values", "capacity", "first-ticket", "ready",
|
|
99
|
+
]);
|
|
100
|
+
/** Scope is enforced in the adapters and receipt, so moving on cannot perform project writes. */
|
|
101
|
+
export function returningWorkspaceAdapters(adapters, args, review, message, newProject, active = () => true, cleanup) {
|
|
102
|
+
if (args.flags.team !== undefined || args.flags.only !== undefined || args.flags.headless === true)
|
|
103
|
+
return;
|
|
104
|
+
let mode = "unread";
|
|
105
|
+
let count = 0;
|
|
106
|
+
let cleanupProject;
|
|
107
|
+
const scope = async (ctx, journal, signal, act = false) => {
|
|
108
|
+
if (!active())
|
|
109
|
+
return null;
|
|
110
|
+
if (mode === "unread") {
|
|
111
|
+
if (!["owner", "admin"].includes(loadConfig(ctx.home)?.user?.role ?? "")) {
|
|
112
|
+
mode = "continue";
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const inventory = await readReturningProjects(ctx, journal, signal);
|
|
116
|
+
if ("reason" in inventory)
|
|
117
|
+
mode = "unknown";
|
|
118
|
+
else {
|
|
119
|
+
count = inventory.projects.length;
|
|
120
|
+
if (!count)
|
|
121
|
+
mode = "continue";
|
|
122
|
+
else {
|
|
123
|
+
message(`This workspace already has ${count} Catalyst project${count === 1 ? "" : "s"}.`);
|
|
124
|
+
for (const p of inventory.projects)
|
|
125
|
+
message(`${p.name} (${p.key}): ${p.status}`);
|
|
126
|
+
const choice = review ? await review(inventory.projects) : "move-on";
|
|
127
|
+
if (!choice || signal?.aborted)
|
|
128
|
+
return { state: "waiting", reason: "interrupted" };
|
|
129
|
+
if (choice === "move-on")
|
|
130
|
+
mode = "move-on";
|
|
131
|
+
else if (choice === "new") {
|
|
132
|
+
mode = "continue";
|
|
133
|
+
newProject();
|
|
134
|
+
}
|
|
135
|
+
else if ("cleanup" in choice) {
|
|
136
|
+
cleanupProject = inventory.projects.find(p => p.id === choice.cleanup);
|
|
137
|
+
mode = cleanupProject ? "cleanup" : "unknown";
|
|
138
|
+
}
|
|
139
|
+
else if (inventory.projects.some(p => p.id === choice.repair)) {
|
|
140
|
+
args.flags.team = choice.repair;
|
|
141
|
+
mode = "continue";
|
|
142
|
+
}
|
|
143
|
+
else
|
|
144
|
+
mode = "unknown";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (mode === "cleanup") {
|
|
149
|
+
if (!act)
|
|
150
|
+
return { state: "pending", reason: "returning_project_cleanup_requested" };
|
|
151
|
+
if (!cleanup || !cleanupProject)
|
|
152
|
+
return { state: "waiting", reason: "returning_project_cleanup_unverified" };
|
|
153
|
+
const result = await cleanup(cleanupProject, ctx, journal, signal);
|
|
154
|
+
if (result.state !== "done")
|
|
155
|
+
return result;
|
|
156
|
+
mode = "move-on";
|
|
157
|
+
}
|
|
158
|
+
if (mode === "unknown")
|
|
159
|
+
return { state: "waiting", reason: "returning_inventory_unverified" };
|
|
160
|
+
return mode === "move-on" ? { state: "skipped", reason: "returning_workspace_move_on", evidence: { projectCount: count } } : null;
|
|
161
|
+
};
|
|
162
|
+
for (const id of PROJECT_STEPS) {
|
|
163
|
+
const adapter = adapters[id];
|
|
164
|
+
if (!adapter)
|
|
165
|
+
continue;
|
|
166
|
+
adapters[id] = {
|
|
167
|
+
...adapter,
|
|
168
|
+
check: async (ctx, journal, signal) => (mode === "unread" && id !== "linear.workspace" ? null : await scope(ctx, journal, signal)) ?? adapter.check(ctx, journal, signal),
|
|
169
|
+
...(adapter.act ? { act: async (ctx, journal, signal) => (mode === "unread" && id !== "linear.workspace" ? null : await scope(ctx, journal, signal, true)) ?? adapter.act(ctx, journal, signal) } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
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,
|