@bli-cockpit/cli 0.2.28 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Getting Cockpit onto this machine and keeping it current: `cockpit install`,
3
+ * `cockpit update`, the self-update the scheduled sync calls, and the
4
+ * maintainer-only `cockpit release`.
5
+ *
6
+ * `release` rides along because it is the same subject from the other end — the
7
+ * npm package everything here installs. Split out of commands/local.ts
8
+ * (BLI-3104); moved verbatim, including every install-event step name.
9
+ */
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { readFile } from "node:fs/promises";
13
+ import { defaultExec, defaultInteractiveExec, writeExecOutput, writeLine } from "./cli-io.js";
14
+ import { assertCollectionRootPersisted } from "./collection-roots.js";
15
+ import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
16
+ import { installLocalCollector, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
17
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
18
+ export class SelfUpdateError extends Error {
19
+ result;
20
+ eacces;
21
+ constructor(result) {
22
+ super("npm install failed; Cockpit CLI was not refreshed.");
23
+ this.name = "SelfUpdateError";
24
+ this.result = result;
25
+ this.eacces = isNpmEaccesFailure(result.stderr);
26
+ }
27
+ }
28
+ export async function runInstall(command, io) {
29
+ const installEvents = [];
30
+ const finish = async (code) => {
31
+ await reportInstallEventsBestEffort({
32
+ homeDir: command.homeDir,
33
+ dashboardUrl: command.dashboardUrl,
34
+ command: "install",
35
+ events: installEvents,
36
+ json: command.json,
37
+ io,
38
+ });
39
+ return code;
40
+ };
41
+ const resolved = resolveInstallCommandRoots(command);
42
+ if (resolved.homeRootOptIn) {
43
+ addInstallEvent(installEvents, "home_root_optin", "ok");
44
+ }
45
+ const result = await installLocalCollector(resolved.command);
46
+ // Same invariant as the onboarding path: never report a successful install
47
+ // over a config that saved no usable collection root.
48
+ await assertCollectionRootPersisted(resolved.command.homeDir);
49
+ addInstallEvent(installEvents, "install", "ok");
50
+ if (command.json) {
51
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
52
+ return finish(0);
53
+ }
54
+ writeLine(io.stdout, "Cockpit local collector installed.");
55
+ writeLine(io.stdout, `Config: ${result.paths.config_file}`);
56
+ writeLine(io.stdout, `Session: ${result.paths.session_file}`);
57
+ writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
58
+ writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
59
+ return finish(0);
60
+ }
61
+ function resolveInstallCommandRoots(command) {
62
+ const detailed = normalizeRootsDetailed([command.repoRoot ?? process.cwd()], {
63
+ homeDir: command.homeDir,
64
+ allowHomeRoot: command.allowHomeRoot,
65
+ });
66
+ if (detailed.roots.length > 0) {
67
+ const root = detailed.roots[0];
68
+ return {
69
+ command: {
70
+ ...command,
71
+ repoRoot: root,
72
+ },
73
+ homeRootOptIn: Boolean(command.allowHomeRoot) &&
74
+ path.resolve(root) === path.resolve(command.homeDir ?? os.homedir()),
75
+ };
76
+ }
77
+ if (detailed.rejected.length > 0) {
78
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
79
+ }
80
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
81
+ }
82
+ export async function runUpdate(command, io) {
83
+ const installEvents = [];
84
+ const finish = async (code) => {
85
+ await reportInstallEventsBestEffort({
86
+ homeDir: command.homeDir,
87
+ dashboardUrl: command.dashboardUrl,
88
+ command: "update",
89
+ events: installEvents,
90
+ json: command.json,
91
+ io,
92
+ });
93
+ return code;
94
+ };
95
+ const exec = io.exec ?? defaultExec();
96
+ try {
97
+ await runSelfUpdate(io, { json: command.json });
98
+ }
99
+ catch (error) {
100
+ if (!(error instanceof SelfUpdateError))
101
+ throw error;
102
+ addInstallEvent(installEvents, "npm_install", "fail", error.eacces
103
+ ? "npm_install_eacces"
104
+ : "npm_install_failed");
105
+ if (command.json) {
106
+ writeLine(io.stdout, JSON.stringify({
107
+ status: "blocked",
108
+ step: "npm_install",
109
+ command: `npm ${selfUpdateInstallArgs().join(" ")}`,
110
+ exit_code: error.result.code,
111
+ }, null, 2));
112
+ }
113
+ else {
114
+ writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
115
+ if (error.eacces) {
116
+ writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
117
+ writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
118
+ }
119
+ }
120
+ return finish(error.result.code || 1);
121
+ }
122
+ addInstallEvent(installEvents, "npm_install", "ok");
123
+ if (!command.json) {
124
+ writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
125
+ }
126
+ const onboard = await exec("cockpit", [
127
+ "onboard",
128
+ ...updateOnboardArgs(command),
129
+ ]);
130
+ writeExecOutput(io, onboard, { stdout: true, stderr: true });
131
+ addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
132
+ return finish(onboard.code);
133
+ }
134
+ export async function runSelfUpdate(io, options = {}) {
135
+ const exec = io.exec ?? defaultExec();
136
+ const tag = options.tag ?? "latest";
137
+ if (!options.json) {
138
+ writeLine(io.stdout, `Updating Cockpit CLI from npm (${tag})...`);
139
+ }
140
+ const install = await exec("npm", selfUpdateInstallArgs(tag));
141
+ writeExecOutput(io, install, { stdout: !options.json, stderr: true });
142
+ if (install.code !== 0) {
143
+ throw new SelfUpdateError(install);
144
+ }
145
+ return { updated: true, version: LOCAL_COLLECTOR_VERSION };
146
+ }
147
+ function selfUpdateInstallArgs(tag = "latest") {
148
+ return [
149
+ "install",
150
+ "-g",
151
+ `@bli-cockpit/cli@${tag}`,
152
+ "--prefer-online",
153
+ ];
154
+ }
155
+ /** Re-runs `cockpit onboard` with the same setup flags the update carried. */
156
+ function updateOnboardArgs(command) {
157
+ const args = [];
158
+ if (command.homeDir)
159
+ args.push("--home", command.homeDir);
160
+ for (const root of updateCollectionRoots(command)) {
161
+ args.push("--workspace", root);
162
+ }
163
+ if (command.dashboardUrlExplicit) {
164
+ args.push("--dashboard-url", command.dashboardUrl);
165
+ }
166
+ if (command.claimedOwnerEmail)
167
+ args.push("--email", command.claimedOwnerEmail);
168
+ if (command.noAuth)
169
+ args.push("--no-auth");
170
+ if (command.deviceName)
171
+ args.push("--device-name", command.deviceName);
172
+ if (command.activeTicketId)
173
+ args.push("--ticket", command.activeTicketId);
174
+ if (command.branch)
175
+ args.push("--branch", command.branch);
176
+ if (command.pollIntervalMs !== undefined) {
177
+ args.push("--poll-interval-ms", String(command.pollIntervalMs));
178
+ }
179
+ if (command.timeoutMs !== undefined) {
180
+ args.push("--timeout-ms", String(command.timeoutMs));
181
+ }
182
+ if (command.maxDepth !== undefined)
183
+ args.push("--max-depth", String(command.maxDepth));
184
+ if (command.maxRepos !== undefined)
185
+ args.push("--max-repos", String(command.maxRepos));
186
+ if (command.allowHomeRoot)
187
+ args.push("--allow-home-root");
188
+ if (command.json)
189
+ args.push("--json");
190
+ return args;
191
+ }
192
+ function isNpmEaccesFailure(stderr) {
193
+ return /EACCES|permission denied/i.test(stderr);
194
+ }
195
+ function updateOnboardFailureCode(result) {
196
+ const output = `${result.stdout}\n${result.stderr}`;
197
+ if (output.includes(COLLECTION_ROOT_REQUIRED))
198
+ return COLLECTION_ROOT_REQUIRED;
199
+ if (/pairing|approval|device_pairing/i.test(output))
200
+ return "pairing_timeout";
201
+ if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
202
+ return "sync_blocked";
203
+ }
204
+ return "onboard_rerun_failed";
205
+ }
206
+ function updateCollectionRoots(command) {
207
+ const roots = command.collectionRoots?.length
208
+ ? command.collectionRoots
209
+ : command.repoRoot
210
+ ? [command.repoRoot]
211
+ : [];
212
+ const seen = new Set();
213
+ const deduped = [];
214
+ for (const root of roots) {
215
+ if (seen.has(root))
216
+ continue;
217
+ seen.add(root);
218
+ deduped.push(root);
219
+ }
220
+ return deduped;
221
+ }
222
+ export async function runRelease(command, io) {
223
+ const releaseRoot = await findPublicReleaseRoot(process.cwd());
224
+ if (!releaseRoot) {
225
+ writeLine(io.stderr, "cockpit release must be run inside the bli-cockpit repo checkout (missing publish:public script).");
226
+ return 1;
227
+ }
228
+ const exec = io.exec ?? defaultExec();
229
+ const gitReady = await prepareReleaseMainBranch(releaseRoot, exec, io);
230
+ if (!gitReady)
231
+ return 1;
232
+ writeLine(io.stdout, "Running Cockpit public package release...");
233
+ const npmArgs = ["--prefix", releaseRoot, "run", "publish:public"];
234
+ if (command.args.length > 0)
235
+ npmArgs.push("--", ...command.args);
236
+ const releaseExec = io.interactiveExec ?? defaultInteractiveExec();
237
+ const result = await releaseExec("npm", npmArgs);
238
+ writeExecOutput(io, result, { stdout: true, stderr: true });
239
+ return result.code;
240
+ }
241
+ /** Publishing happens from a clean, current `main` or it does not happen. */
242
+ async function prepareReleaseMainBranch(releaseRoot, exec, io) {
243
+ const branch = await exec("git", [
244
+ "-C",
245
+ releaseRoot,
246
+ "rev-parse",
247
+ "--abbrev-ref",
248
+ "HEAD",
249
+ ]);
250
+ writeExecOutput(io, branch, { stdout: false, stderr: true });
251
+ if (branch.code !== 0) {
252
+ writeLine(io.stderr, "BLOCKED: cockpit release could not read the current git branch.");
253
+ return false;
254
+ }
255
+ const currentBranch = branch.stdout.trim();
256
+ if (currentBranch !== "main") {
257
+ writeLine(io.stderr, `BLOCKED: cockpit release only publishes from main. Current branch is ${currentBranch || "unknown"}.`);
258
+ writeLine(io.stderr, "Merge the release changes, switch to main, then rerun `cockpit release`.");
259
+ return false;
260
+ }
261
+ const status = await exec("git", [
262
+ "-C",
263
+ releaseRoot,
264
+ "status",
265
+ "--porcelain",
266
+ ]);
267
+ writeExecOutput(io, status, { stdout: false, stderr: true });
268
+ if (status.code !== 0) {
269
+ writeLine(io.stderr, "BLOCKED: cockpit release could not inspect git status.");
270
+ return false;
271
+ }
272
+ if (status.stdout.trim()) {
273
+ writeLine(io.stderr, "BLOCKED: cockpit release requires a clean main checkout.");
274
+ writeLine(io.stderr, "Commit or discard local changes, then rerun `cockpit release`.");
275
+ return false;
276
+ }
277
+ writeLine(io.stdout, "Syncing main with git pull --ff-only...");
278
+ const pull = await exec("git", ["-C", releaseRoot, "pull", "--ff-only"]);
279
+ writeExecOutput(io, pull, { stdout: true, stderr: true });
280
+ if (pull.code !== 0) {
281
+ writeLine(io.stderr, "BLOCKED: git pull --ff-only failed; main is not safely current.");
282
+ return false;
283
+ }
284
+ return true;
285
+ }
286
+ /** Walks up for the checkout that owns the `publish:public` script. */
287
+ async function findPublicReleaseRoot(startDir) {
288
+ let current = path.resolve(startDir);
289
+ while (true) {
290
+ const packageJsonPath = path.join(current, "package.json");
291
+ try {
292
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
293
+ if (packageJson.scripts?.["publish:public"] !== undefined)
294
+ return current;
295
+ }
296
+ catch {
297
+ // Keep walking: nested packages may be missing package.json or have one
298
+ // without the release script.
299
+ }
300
+ const parent = path.dirname(current);
301
+ if (parent === current)
302
+ return null;
303
+ current = parent;
304
+ }
305
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Signing this machine in: which email to pair as, the email-OTP exchange that
3
+ * gets a short-lived access token, and pairing itself with a manual-approval
4
+ * fallback when the authenticated route is refused.
5
+ *
6
+ * Every failure here degrades to manual dashboard approval rather than
7
+ * stopping — a person who cannot receive the code must still be able to
8
+ * onboard. Split out of commands/local.ts (BLI-3104); moved verbatim.
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import { errorMessage, isInteractiveStdin, readLine, writeLine, yesByDefault, } from "./cli-io.js";
12
+ import { normalizeUrl } from "./local-args.js";
13
+ import { getCollectorRuntimePaths, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
14
+ const OTP_CODE_PROMPT = [
15
+ "Email code needed:",
16
+ " Check your latest Cockpit email for a 6- to 10-digit code.",
17
+ "What you can do:",
18
+ " 1) Paste the code here.",
19
+ " 2) No code yet: wait for the resend window, then rerun this command.",
20
+ " 3) Can't use email: rerun with --no-auth for manual approval.",
21
+ "Code: ",
22
+ ].join("\n");
23
+ const OTP_INVALID_MESSAGE = [
24
+ "Email code was not accepted:",
25
+ " Cockpit expects digits only, length 6 to 10.",
26
+ "What you can do:",
27
+ " 1) Rerun and paste the latest email code.",
28
+ " 2) Use manual approval: rerun this command with --no-auth --email <you@buildlaunchiterate.ca>",
29
+ ].join("\n");
30
+ /**
31
+ * Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
32
+ * `--email`. Only called when stdin is a TTY and not in --json mode. An empty
33
+ * answer or a non-email skips rather than failing, matching `optionalEmail`'s
34
+ * leniency (the approving admin's account then owns the device).
35
+ */
36
+ export async function promptOnboardEmail(io) {
37
+ const raw = await readLine(io, "What's your @buildlaunchiterate.ca email? (press enter to skip): ");
38
+ const answer = raw.trim().toLowerCase();
39
+ if (!answer)
40
+ return undefined;
41
+ if (!answer.includes("@")) {
42
+ writeLine(io.stderr, `"${answer}" is not an email; continuing without one — the approving admin's account will own this device.`);
43
+ return undefined;
44
+ }
45
+ return answer;
46
+ }
47
+ export async function resolveOnboardEmail(command, roots, config, io) {
48
+ if (command.claimedOwnerEmail)
49
+ return command.claimedOwnerEmail;
50
+ const inferred = await inferOnboardEmail(command.homeDir, roots, config);
51
+ const interactive = !command.json && isInteractiveStdin(io);
52
+ if (!inferred) {
53
+ return interactive ? promptOnboardEmail(io) : undefined;
54
+ }
55
+ if (inferred.source === "session") {
56
+ if (!command.json)
57
+ writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
58
+ return inferred.email;
59
+ }
60
+ if (!interactive)
61
+ return inferred.email;
62
+ const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
63
+ const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
64
+ if (typedEmail)
65
+ return typedEmail;
66
+ if (yesByDefault(answer)) {
67
+ return inferred.email;
68
+ }
69
+ return promptOnboardEmail(io);
70
+ }
71
+ export async function resolveInteractiveLoginEmail(command, io) {
72
+ if (command.claimedOwnerEmail)
73
+ return command.claimedOwnerEmail;
74
+ if (command.noAuth || command.json || !isInteractiveStdin(io)) {
75
+ return undefined;
76
+ }
77
+ return promptOnboardEmail(io);
78
+ }
79
+ export async function requestPairingAccessToken(input, io) {
80
+ return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
81
+ }
82
+ export async function requestPairingAccessTokenDetailed(input, io) {
83
+ if (!input.email ||
84
+ input.noAuth ||
85
+ input.json ||
86
+ !isInteractiveStdin(io)) {
87
+ return {
88
+ status: "skipped",
89
+ errorCode: authSkippedReason(input, io),
90
+ };
91
+ }
92
+ try {
93
+ const fetchImpl = io.fetch;
94
+ writeLine(io.stdout, `Signing in as ${input.email}.`);
95
+ const start = await postOtpStart(fetchImpl, input.dashboardUrl, input.email);
96
+ const resendAfter = typeof start.resend_after_seconds === "number"
97
+ ? start.resend_after_seconds
98
+ : 60;
99
+ writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
100
+ const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
101
+ if (!/^\d{6,10}$/.test(code)) {
102
+ throw new Error(OTP_INVALID_MESSAGE);
103
+ }
104
+ const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
105
+ if (typeof verified.access_token !== "string" || !verified.access_token) {
106
+ throw new Error("OTP verified but dashboard returned no access token.");
107
+ }
108
+ writeLine(io.stdout, `Signed in as ${input.email}.`);
109
+ return { status: "ok", accessToken: verified.access_token };
110
+ }
111
+ catch (error) {
112
+ writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
113
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
114
+ return { status: "fail", errorCode: classifyAuthError(error) };
115
+ }
116
+ }
117
+ function authSkippedReason(input, io) {
118
+ if (!input.email)
119
+ return "email_missing";
120
+ if (input.noAuth)
121
+ return "no_auth";
122
+ if (input.json)
123
+ return "json_mode";
124
+ if (!isInteractiveStdin(io))
125
+ return "non_interactive";
126
+ return "auth_skipped";
127
+ }
128
+ function classifyAuthError(error) {
129
+ const message = errorMessage(error);
130
+ if (/\b(otp|code|digit|invalid)\b/i.test(message))
131
+ return "otp_invalid";
132
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message)) {
133
+ return "network_or_auth";
134
+ }
135
+ return "auth_failed";
136
+ }
137
+ export async function pairLocalCollectorWithAuthFallback(options, io) {
138
+ try {
139
+ return await pairLocalCollector(options);
140
+ }
141
+ catch (error) {
142
+ if (!options.pairingAccessToken || !isPairingAuthFailure(error)) {
143
+ throw error;
144
+ }
145
+ writeLine(io.stderr, `Authenticated pairing failed: ${errorMessage(error)}`);
146
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
147
+ return pairLocalCollector({
148
+ ...options,
149
+ pairingAccessToken: undefined,
150
+ });
151
+ }
152
+ }
153
+ function isPairingAuthFailure(error) {
154
+ return /\b(auth|authorization|bearer|token|jwt|otp)\b/i.test(errorMessage(error));
155
+ }
156
+ async function postOtpStart(fetchImpl, dashboardUrl, email) {
157
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/start`, {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify({ email }),
161
+ });
162
+ const parsed = await readJsonResponse(response);
163
+ if (!response.ok) {
164
+ throw new Error(responseErrorMessage(parsed, "OTP start failed"));
165
+ }
166
+ return parsed;
167
+ }
168
+ async function postOtpVerify(fetchImpl, dashboardUrl, email, code) {
169
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/verify`, {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json" },
172
+ body: JSON.stringify({ email, code }),
173
+ });
174
+ const parsed = await readJsonResponse(response);
175
+ if (!response.ok) {
176
+ throw new Error(responseErrorMessage(parsed, "OTP verify failed"));
177
+ }
178
+ return parsed;
179
+ }
180
+ async function readJsonResponse(response) {
181
+ const text = await response.text();
182
+ if (!text)
183
+ return null;
184
+ try {
185
+ return JSON.parse(text);
186
+ }
187
+ catch {
188
+ return text;
189
+ }
190
+ }
191
+ function responseErrorMessage(parsed, fallback) {
192
+ if (parsed && typeof parsed === "object") {
193
+ const record = parsed;
194
+ if (typeof record["message"] === "string" && record["message"].trim()) {
195
+ return record["message"];
196
+ }
197
+ if (typeof record["error"] === "string" && record["error"].trim()) {
198
+ return record["error"];
199
+ }
200
+ }
201
+ return fallback;
202
+ }
203
+ /** Best guess at who this machine belongs to: paired session, then saved config, then git. */
204
+ async function inferOnboardEmail(homeDir, roots, config) {
205
+ const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
206
+ const sessionEmail = normalizeEmailForComparison(session?.email);
207
+ if (session?.session_state === "valid" && sessionEmail) {
208
+ return { email: sessionEmail, source: "session" };
209
+ }
210
+ const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
211
+ if (configEmail)
212
+ return { email: configEmail, source: "config" };
213
+ const gitEmail = await inferUniqueGitEmail(roots);
214
+ return gitEmail ? { email: gitEmail, source: "git" } : null;
215
+ }
216
+ /** Two roots disagreeing about `user.email` is no answer at all, so neither is used. */
217
+ async function inferUniqueGitEmail(roots) {
218
+ const emails = new Set();
219
+ for (const root of roots) {
220
+ const email = await readGitConfigEmail(root);
221
+ if (email)
222
+ emails.add(email);
223
+ }
224
+ return emails.size === 1 ? [...emails][0] ?? null : null;
225
+ }
226
+ async function readGitConfigEmail(root) {
227
+ return new Promise((resolve) => {
228
+ execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
229
+ if (error) {
230
+ resolve(null);
231
+ return;
232
+ }
233
+ const email = normalizeEmailForComparison(stdout);
234
+ resolve(email?.includes("@") ? email : null);
235
+ });
236
+ });
237
+ }
238
+ /** Reuse the paired session only when it is the same person on the same dashboard. */
239
+ export function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
240
+ if (session.session_state !== "valid")
241
+ return false;
242
+ const expectedEmail = normalizeEmailForComparison(claimedOwnerEmail);
243
+ if (expectedEmail && normalizeEmailForComparison(session.email) !== expectedEmail) {
244
+ return false;
245
+ }
246
+ const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
247
+ if (expectedDashboardUrl &&
248
+ normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
249
+ return false;
250
+ }
251
+ return true;
252
+ }
253
+ function normalizeEmailForComparison(value) {
254
+ const normalized = value?.trim().toLowerCase();
255
+ return normalized ? normalized : null;
256
+ }
257
+ export async function readOnboardSessionReuseCandidate(homeDir) {
258
+ const paths = getCollectorRuntimePaths(homeDir);
259
+ try {
260
+ return await readLocalCollectorSessionFile(paths);
261
+ }
262
+ catch {
263
+ return readLocalSessionReference(paths);
264
+ }
265
+ }
266
+ function normalizeUrlForComparison(value) {
267
+ return value ? normalizeUrl(value) : null;
268
+ }
@@ -0,0 +1,100 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
3
+ import { discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
4
+ export async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
5
+ // What the operator typed this run, else what they typed some previous run,
6
+ // else the built-in defaults (BLI-2362).
7
+ const limits = await resolveDiscoveryLimits(discovery, discovery.homeDir);
8
+ const maxWorktrees = limits.maxRepos;
9
+ const roots = Array.isArray(repoRoot)
10
+ ? repoRoot
11
+ : [repoRoot ?? process.cwd()];
12
+ const result = await discoverGitWorktreesInRootsWithStatus(roots, {
13
+ maxDepth: limits.maxDepth,
14
+ maxWorktrees,
15
+ });
16
+ if (io && result.unreadable_dirs.length > 0) {
17
+ // Never silently dropped: anything under these folders is missing from the
18
+ // scan, so say so even when the run otherwise succeeds.
19
+ writeLine(io.stderr, unreadableDirectoriesMessage(result.unreadable_dirs));
20
+ }
21
+ const worktrees = result.worktrees;
22
+ if (!result.complete) {
23
+ // Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
24
+ // cursor after a partial scan would mark the run as covering repos it
25
+ // never saw, permanently skipping their sessions — backfill can tolerate
26
+ // partial only because it keeps per-scope completion markers, and sync
27
+ // does not. The bug (BLI-2362) was that the refusal named no roots and
28
+ // gave no runnable command, so a big workspace just stayed red forever.
29
+ const message = incompleteDiscoveryMessage({
30
+ result,
31
+ roots,
32
+ maxDepth: limits.maxDepth,
33
+ maxRepos: maxWorktrees,
34
+ found: worktrees.length,
35
+ });
36
+ if (io)
37
+ writeLine(io.stderr, message);
38
+ throw new Error(message);
39
+ }
40
+ if (worktrees.length === 0 && !discovery.allowEmpty) {
41
+ throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
42
+ }
43
+ return worktrees;
44
+ }
45
+ /**
46
+ * Persists `--max-depth` / `--max-repos` when a command carried them, so the
47
+ * number survives into the background sync and the doctor's own sync — neither
48
+ * of which has anywhere to type one (BLI-2362). Best-effort: failing to record
49
+ * a preference must never fail the command the operator actually asked for.
50
+ */
51
+ export async function rememberDiscoveryLimits(command) {
52
+ const limits = command;
53
+ if (limits.maxDepth === undefined && limits.maxRepos === undefined)
54
+ return;
55
+ await saveDiscoveryLimits({ maxDepth: limits.maxDepth, maxRepos: limits.maxRepos }, limits.homeDir).catch(() => undefined);
56
+ }
57
+ /**
58
+ * Says which folders could not be opened, and therefore what the scan could not
59
+ * see. Reported without failing the run — an unreadable folder cannot be fixed
60
+ * by retrying, so blocking on one would strand the machine (BLI-2362).
61
+ */
62
+ function unreadableDirectoriesMessage(unreadable) {
63
+ return [
64
+ `WARNING: ${unreadable.length} folder(s) could not be opened, so anything inside them was not scanned:`,
65
+ ...unreadable.map((dir) => ` ${dir.path} (${dir.code})`),
66
+ "Collection continued for everything else. If a repo is missing from Cockpit,",
67
+ "check the permissions on the folders above.",
68
+ ].join("\n");
69
+ }
70
+ /**
71
+ * Names the roots that could not be covered and hands back a command that
72
+ * actually fixes it, with this machine's numbers already filled in.
73
+ */
74
+ function incompleteDiscoveryMessage(input) {
75
+ const { result, roots, maxDepth, maxRepos, found } = input;
76
+ const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
77
+ const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
78
+ const nextDepth = maxDepth + 3;
79
+ const nextRepos = Math.max(maxRepos * 2, found + 50);
80
+ const retry = [
81
+ "cockpit do-everything",
82
+ ...roots.map((root) => `--workspace ${root}`),
83
+ `--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
84
+ `--max-repos ${nextRepos}`,
85
+ ].join(" ");
86
+ return [
87
+ `Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
88
+ "It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
89
+ "",
90
+ "Could not fully scan:",
91
+ ...blocked.map((root) => ` ${root}`),
92
+ "",
93
+ `Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
94
+ "",
95
+ "Run this to raise the limits and try again:",
96
+ ` ${retry}`,
97
+ "",
98
+ "If that still stops, the folder is deeper or larger than expected — raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
99
+ ].join("\n");
100
+ }