@bli-cockpit/cli 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -45,13 +45,12 @@ The OTP proves you own an approved BLI mailbox. The JWT is used once to register
45
45
 
46
46
  ## Every command, what it does, and why it's called that
47
47
 
48
- You only need `onboard` once and `do-everything` whenever Edward asks the fleet to converge. The rest exist for
49
- recovery and maintenance.
48
+ On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough to sign in, choose roots, and converge the machine. `onboard` remains the named setup subset, and the rest exist for recovery and maintenance.
50
49
 
51
50
  | Command | What it does | Why it exists / why this name |
52
51
  |---|---|---|
53
52
  | `cockpit onboard` | The everything-command: signs you in (email code), registers this Mac, starts capture, uploads once, installs the 15-min background sync, and prints proof you're live. | You are boarding the crew. Run it once per machine; rerunning is always safe. |
54
- | `cockpit do-everything` / `cockpit fix` | Converges an already-onboarded Mac: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
53
+ | `cockpit do-everything` / `cockpit fix` | Converges a Mac from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
55
54
  | `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
56
55
  | `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this Mac). | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
57
56
  | `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
@@ -83,13 +82,14 @@ cockpit onboard --no-auth
83
82
 
84
83
  - `--email` skips the email prompt.
85
84
  - `--workspace` pins one collection root; repeat it for multiple unrelated roots.
86
- - `--allow-home-root` deliberately collects your whole home folder. Use it only on a company machine where that is intended.
85
+ - Interactive onboarding from your home folder asks whether to sync all projects on the machine. Answer `y` only on a company machine where collecting every current and future repo under `$HOME` is intended. `n` or Enter asks for the actual work folder; if no valid non-home folder is chosen, Cockpit captures nothing and tells you to rerun with `--workspace <path-to-your-work-folder>`.
86
+ - `--allow-home-root` is the headless/scripted form of that full-home opt-in.
87
87
  - `--device-name` changes only the human label shown in Cockpit.
88
88
  - `--dashboard-url` is for staging/custom dashboards only. Production is the default.
89
89
  - `--no-auth` forces the old manual approval queue.
90
90
  - `--repo` still works as a legacy alias for `--workspace`.
91
91
 
92
- `cockpit do-everything` is the normal fleet convergence command after onboarding. It exits non-zero only when a checked invariant remains red after its fix runs, or when a human action such as login/onboard-rerun is required.
92
+ `cockpit do-everything` is the normal fleet convergence command. On an interactive first run it uses the same email OTP login and root-picker flow as onboarding, then re-checks the machine. In headless, launchd, `--json`, or no-TTY runs it never prompts; missing auth or roots stay red with the repair text. `--dry-run` previews without writing auth or config.
93
93
 
94
94
  `cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
95
95
 
@@ -22,20 +22,28 @@ export async function runDoctorWithDeps(command, io, deps) {
22
22
  rows.push(checked);
23
23
  continue;
24
24
  }
25
- if (checked.hardStop) {
26
- rows.push(checked);
27
- break;
25
+ if (command.dryRun && invariant.fix) {
26
+ rows.push(dryRunPreview(checked));
27
+ continue;
28
+ }
29
+ const canFix = Boolean(invariant.fix) &&
30
+ (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context));
31
+ if (!canFix) {
32
+ rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
33
+ if (checked.hardStop)
34
+ break;
35
+ continue;
28
36
  }
29
- if (command.dryRun || !invariant.fix) {
30
- rows.push({
31
- ...checked,
32
- status: "needs_fix",
33
- message: `would fix: ${checked.message}`,
34
- });
37
+ if (!invariant.fix) {
38
+ rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
39
+ if (checked.hardStop)
40
+ break;
35
41
  continue;
36
42
  }
37
43
  const fixed = await invariant.fix(context, checked);
38
44
  rows.push({ ...fixed, fixed: fixed.status !== "fail" });
45
+ if (fixed.hardStop)
46
+ break;
39
47
  if (fixed.reexecExitCode !== undefined) {
40
48
  await maybeReportDoctorEvents(context, rows);
41
49
  writeDoctorOutput(command, io, rows);
@@ -49,8 +57,18 @@ export async function runDoctorWithDeps(command, io, deps) {
49
57
  function doctorInvariants() {
50
58
  return [
51
59
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
52
- { id: "authed", check: (context) => context.deps.readAuth(context) },
53
- { id: "roots-ok", check: (context) => context.deps.readRoots(context) },
60
+ {
61
+ id: "authed",
62
+ check: (context) => context.deps.readAuth(context),
63
+ fix: fixAuthState,
64
+ requiresInteractiveFix: true,
65
+ },
66
+ {
67
+ id: "roots-ok",
68
+ check: (context) => context.deps.readRoots(context),
69
+ fix: fixRootState,
70
+ requiresInteractiveFix: true,
71
+ },
54
72
  {
55
73
  id: "autostart-alive",
56
74
  check: (context) => context.deps.checkAutostart(context),
@@ -80,7 +98,9 @@ function defaultDoctorDeps(hooks) {
80
98
  reexecDoctor: reexecDoctor,
81
99
  reportInstallEvents: hooks.reportInstallEvents,
82
100
  readAuth: readAuthState,
101
+ runLogin: (context) => hooks.runLogin(context.command, context.io),
83
102
  readRoots: readRootState,
103
+ resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
84
104
  checkAutostart: checkAutostartState,
85
105
  fixAutostart: fixAutostartState,
86
106
  checkBackfill: checkBackfillState,
@@ -120,6 +140,18 @@ async function fixCliLatest(context, state) {
120
140
  reexecExitCode: code,
121
141
  };
122
142
  }
143
+ async function fixAuthState(context, state) {
144
+ const code = await context.deps.runLogin(context).catch(() => 1);
145
+ if (code !== 0)
146
+ return state;
147
+ const checked = await context.deps.readAuth(context);
148
+ return checked.status === "ok" ? checked : state;
149
+ }
150
+ async function fixRootState(context, state) {
151
+ await context.deps.resolveAndSaveRoots(context).catch(() => undefined);
152
+ const checked = await context.deps.readRoots(context);
153
+ return checked.status === "ok" ? checked : state;
154
+ }
123
155
  async function latestCliVersionFromNpm(context) {
124
156
  const exec = context.io.exec;
125
157
  if (!exec)
@@ -350,6 +382,18 @@ function fail(id, code, message) {
350
382
  function hardStop(id, code, message) {
351
383
  return { id, status: "fail", code, message, hardStop: true };
352
384
  }
385
+ function dryRunPreview(state) {
386
+ const preview = { ...state };
387
+ delete preview.hardStop;
388
+ return {
389
+ ...preview,
390
+ status: "needs_fix",
391
+ message: `would fix: ${oneLine(state.message)}`,
392
+ };
393
+ }
394
+ function isInteractiveDoctorFix(context) {
395
+ return !context.command.json && Boolean(context.io.stdin.isTTY);
396
+ }
353
397
  async function savedRoots() {
354
398
  const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
355
399
  return normalizeCollectionRoots(config?.default_repo_paths ?? []);
@@ -65,6 +65,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
65
65
  return await runDoctor(command, io, {
66
66
  reportInstallEvents: reportInstallEventsBestEffort,
67
67
  selfUpdate: runSelfUpdate,
68
+ runLogin: runDoctorLogin,
69
+ resolveAndSaveRoots: resolveAndSaveDoctorRoots,
68
70
  });
69
71
  case "login":
70
72
  return await runLogin(command, io);
@@ -1053,6 +1055,64 @@ function backgroundSyncLine(result) {
1053
1055
  }
1054
1056
  return result.status;
1055
1057
  }
1058
+ async function resolveOnboardingRootsForCommand(command, io) {
1059
+ const paths = getCollectorRuntimePaths(command.homeDir);
1060
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
1061
+ const interactive = !command.json && isInteractiveStdin(io);
1062
+ const rootsResult = await resolveOnboardingRoots({
1063
+ homeDir: command.homeDir,
1064
+ explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
1065
+ config: existingConfig,
1066
+ interactive,
1067
+ allowHomeRoot: command.allowHomeRoot,
1068
+ prompt: interactive ? onboardingRootPrompt(io) : undefined,
1069
+ });
1070
+ const collectionRoots = rootsResult.roots;
1071
+ const primaryRoot = collectionRoots[0];
1072
+ if (!primaryRoot) {
1073
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
1074
+ }
1075
+ const replaceRepoRoots = rootsResult.source === "prompt" &&
1076
+ !command.collectionRoots?.length &&
1077
+ (existingConfig?.default_repo_paths.length ?? 0) > 0;
1078
+ return {
1079
+ existingConfig,
1080
+ rootsResult,
1081
+ collectionRoots,
1082
+ primaryRoot,
1083
+ replaceRepoRoots,
1084
+ };
1085
+ }
1086
+ async function persistOnboardingRootConfig(command, resolution) {
1087
+ return installLocalCollector({
1088
+ homeDir: command.homeDir,
1089
+ repoRoot: resolution.primaryRoot,
1090
+ repoRoots: resolution.collectionRoots,
1091
+ replaceRepoRoots: resolution.replaceRepoRoots,
1092
+ dashboardUrl: command.dashboardUrl,
1093
+ deviceName: command.deviceName,
1094
+ });
1095
+ }
1096
+ async function runDoctorLogin(command, io) {
1097
+ return runLogin({
1098
+ kind: "login",
1099
+ dashboardUrl: command.dashboardUrl,
1100
+ json: command.json,
1101
+ noAuth: false,
1102
+ }, io);
1103
+ }
1104
+ async function resolveAndSaveDoctorRoots(command, io) {
1105
+ const rootCommand = {
1106
+ repoRoot: command.repoRoot,
1107
+ dashboardUrl: command.dashboardUrl,
1108
+ json: command.json,
1109
+ };
1110
+ const resolution = await resolveOnboardingRootsForCommand(rootCommand, io);
1111
+ await persistOnboardingRootConfig(rootCommand, resolution);
1112
+ if (!command.json) {
1113
+ writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
1114
+ }
1115
+ }
1056
1116
  async function runOnboard(command, io) {
1057
1117
  const installEvents = [];
1058
1118
  const finish = async (code) => {
@@ -1080,31 +1140,17 @@ async function runOnboard(command, io) {
1080
1140
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
1081
1141
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
1082
1142
  }
1083
- const paths = getCollectorRuntimePaths(command.homeDir);
1084
1143
  backfillHint = await onboardBackfillHint(command.homeDir);
1085
- const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
1086
- const interactive = !command.json && isInteractiveStdin(io);
1087
- rootsResult = await resolveOnboardingRoots({
1088
- homeDir: command.homeDir,
1089
- explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
1090
- config: existingConfig,
1091
- interactive,
1092
- allowHomeRoot: command.allowHomeRoot,
1093
- prompt: interactive ? onboardingRootPrompt(io) : undefined,
1094
- });
1144
+ const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
1145
+ const existingConfig = resolvedRoots.existingConfig;
1146
+ rootsResult = resolvedRoots.rootsResult;
1095
1147
  const collectionRoots = rootsResult.roots;
1096
- const primaryRoot = collectionRoots[0];
1097
- if (!primaryRoot) {
1098
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
1099
- }
1148
+ const primaryRoot = resolvedRoots.primaryRoot;
1100
1149
  const resolvedCommand = {
1101
1150
  ...command,
1102
1151
  repoRoot: primaryRoot,
1103
1152
  collectionRoots,
1104
1153
  };
1105
- const replaceRepoRoots = rootsResult.source === "prompt" &&
1106
- !command.collectionRoots?.length &&
1107
- (existingConfig?.default_repo_paths.length ?? 0) > 0;
1108
1154
  if (!command.json) {
1109
1155
  writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
1110
1156
  }
@@ -1112,14 +1158,7 @@ async function runOnboard(command, io) {
1112
1158
  addInstallEvent(installEvents, "home_root_optin", "ok");
1113
1159
  }
1114
1160
  const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
1115
- install = await installLocalCollector({
1116
- homeDir: command.homeDir,
1117
- repoRoot: primaryRoot,
1118
- repoRoots: collectionRoots,
1119
- replaceRepoRoots,
1120
- dashboardUrl: command.dashboardUrl,
1121
- deviceName: command.deviceName,
1122
- });
1161
+ install = await persistOnboardingRootConfig(command, resolvedRoots);
1123
1162
  addInstallEvent(installEvents, "install", "ok");
1124
1163
  if (!command.json) {
1125
1164
  writeLine(io.stdout, "1/5 Installed local collector.");
@@ -1,10 +1,15 @@
1
1
  import fs from "node:fs/promises";
2
+ import { realpathSync } from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
- import { discoverGitWorktrees } from "./repo-identity.js";
5
5
  import { normalizeCollectionRoots } from "./root-normalization.js";
6
6
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
7
- export const HOME_ROOT_CONSENT_PROMPT = "Collect your ENTIRE home folder? Any git repo you ever create under it will be collected automatically. Type 'everything' to confirm: ";
7
+ export function homeRootConsentPrompt(homeDirInput) {
8
+ const homeDir = path.resolve(homeDirInput ?? os.homedir());
9
+ return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed. [y/N]: `;
10
+ }
11
+ export const HOME_ROOT_DECLINE_PATH_PROMPT = "Okay — which folder should I sync? Enter the full path to your work directory: ";
12
+ export const HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE = "No folder chosen. Re-run: cockpit onboard --workspace <path-to-your-work-folder>";
8
13
  export async function resolveOnboardingRoots(options) {
9
14
  const explicitInput = options.explicitRoots ?? [];
10
15
  const explicit = normalizeRootsDetailed(explicitInput, {
@@ -19,10 +24,7 @@ export async function resolveOnboardingRoots(options) {
19
24
  if (!options.interactive) {
20
25
  throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(explicit.rejected[0], options)}`);
21
26
  }
22
- explainRejectedRoots(options, explicit.rejected);
23
- const homeOffer = await counterOfferHomeDirectoryRepos(options, explicit.rejected);
24
- if (homeOffer)
25
- return homeOffer;
27
+ explainRejectedRoots(options, withoutHomeRejections(explicit.rejected));
26
28
  const homeOptIn = await promptForHomeRootOptIn(options, explicit.rejected);
27
29
  if (homeOptIn)
28
30
  return homeOptIn;
@@ -45,10 +47,24 @@ export async function resolveOnboardingRoots(options) {
45
47
  }
46
48
  return promptForRoots(options, "Collection root(s), comma-separated: ");
47
49
  }
50
+ const cwd = path.resolve(options.cwd ?? process.cwd());
51
+ if (isHomeRoot(cwd, options.homeDir)) {
52
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
53
+ if (options.allowHomeRoot) {
54
+ return resolvedRoots(options, [homeDir], "cwd_likely_root", true);
55
+ }
56
+ if (!options.interactive) {
57
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${homeRootTutorial(homeDir)}`);
58
+ }
59
+ const homeOptIn = await promptForHomeRootOptIn(options, [
60
+ { input: homeDir, reason: "home_dir" },
61
+ ]);
62
+ if (homeOptIn)
63
+ return homeOptIn;
64
+ }
48
65
  if (!options.interactive) {
49
66
  throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
50
67
  }
51
- const cwd = path.resolve(options.cwd ?? process.cwd());
52
68
  const cwdRoot = likelyBliRootFromCwd(cwd);
53
69
  if (cwdRoot) {
54
70
  const confirmed = await requirePrompt(options).confirm(`Collect from ${cwdRoot}? [Y/n] `);
@@ -115,10 +131,7 @@ async function promptForRoots(options, message) {
115
131
  return resolvedRoots(options, detailed.roots, "prompt", true);
116
132
  }
117
133
  if (detailed.rejected.length > 0) {
118
- explainRejectedRoots(options, detailed.rejected);
119
- const homeOffer = await counterOfferHomeDirectoryRepos(options, detailed.rejected);
120
- if (homeOffer)
121
- return homeOffer;
134
+ explainRejectedRoots(options, withoutHomeRejections(detailed.rejected));
122
135
  const homeOptIn = await promptForHomeRootOptIn(options, detailed.rejected);
123
136
  if (homeOptIn)
124
137
  return homeOptIn;
@@ -164,6 +177,9 @@ export function rootRejectionExplanation(rejection, options = {}) {
164
177
  return filesystemRootTutorial(options.homeDir);
165
178
  }
166
179
  }
180
+ function withoutHomeRejections(rejections) {
181
+ return rejections.filter((rejection) => rejection.reason !== "home_dir");
182
+ }
167
183
  function rootRejectionPromptHint(rejection, options = {}) {
168
184
  switch (rejection.reason) {
169
185
  case "home_dir":
@@ -172,45 +188,63 @@ function rootRejectionPromptHint(rejection, options = {}) {
172
188
  return filesystemRootTutorial(options.homeDir);
173
189
  }
174
190
  }
175
- async function counterOfferHomeDirectoryRepos(options, rejections) {
176
- const homeRejection = rejections.find((rejection) => rejection.reason === "home_dir");
177
- if (!homeRejection)
178
- return null;
179
- const prompt = requirePrompt(options);
180
- const homeDir = path.resolve(options.homeDir ?? os.homedir());
181
- const discover = options.discoverGitWorktrees ?? discoverGitWorktrees;
182
- const worktrees = await discover(homeDir, {
183
- maxDepth: 3,
184
- maxWorktrees: 50,
185
- }).catch(() => []);
186
- const roots = normalizeCollectionRoots([...new Set(worktrees.map((worktree) => path.resolve(worktree.repo_root)))]);
187
- if (roots.length === 0)
188
- return null;
189
- const confirmed = await prompt.confirm([
190
- "Your home folder can't be a collection root by default:",
191
- ` I found ${roots.length} git repos under ${homeDir} that are safer to collect.`,
192
- "Discovered repos:",
193
- ...roots.map((root) => `- ${root}`),
194
- "What you can do:",
195
- " 1) Use these repos: answer y",
196
- " 2) Pick different folders: answer n, then paste specific paths",
197
- " 3) Collect everything anyway: answer n, then type everything at the next prompt",
198
- "Collect from these? [Y/n] ",
199
- ].join("\n"));
200
- return confirmed ? resolvedRoots(options, roots, "prompt", true) : null;
201
- }
202
191
  async function promptForHomeRootOptIn(options, rejections) {
203
192
  if (!rejections.some((rejection) => rejection.reason === "home_dir"))
204
193
  return null;
205
- const answer = await requirePrompt(options).input(HOME_ROOT_CONSENT_PROMPT);
206
- if (answer.trim().toLowerCase() !== "everything")
207
- return null;
208
- return {
209
- roots: [path.resolve(options.homeDir ?? os.homedir())],
210
- source: "prompt",
211
- confirmed: true,
212
- homeRootOptIn: true,
213
- };
194
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
195
+ const answer = await requirePrompt(options).input(homeRootConsentPrompt(homeDir));
196
+ if (isHomeRootYes(answer)) {
197
+ return {
198
+ roots: [homeDir],
199
+ source: "prompt",
200
+ confirmed: true,
201
+ homeRootOptIn: true,
202
+ };
203
+ }
204
+ return promptForDeclinedHomeRoot(options, homeDir);
205
+ }
206
+ function isHomeRootYes(raw) {
207
+ const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
208
+ return answer === "y" || answer === "yes";
209
+ }
210
+ async function promptForDeclinedHomeRoot(options, homeDir) {
211
+ const prompt = requirePrompt(options);
212
+ const cwd = path.resolve(options.cwd ?? process.cwd());
213
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
214
+ const answer = await prompt.input(HOME_ROOT_DECLINE_PATH_PROMPT);
215
+ const requestedRoot = answer.trim();
216
+ if (!requestedRoot) {
217
+ if (attempt < 2)
218
+ continue;
219
+ break;
220
+ }
221
+ const resolvedRoot = resolveRootInputPath(requestedRoot, homeDir, cwd);
222
+ if (isHomeRoot(resolvedRoot, homeDir))
223
+ break;
224
+ if (!(await directoryExists(resolvedRoot))) {
225
+ prompt.message?.(`That folder doesn't exist: ${resolvedRoot}`);
226
+ if (attempt < 2)
227
+ continue;
228
+ break;
229
+ }
230
+ const detailed = normalizeRootsDetailed([resolvedRoot], {
231
+ homeDir,
232
+ allowHomeRoot: options.allowHomeRoot,
233
+ });
234
+ if (detailed.roots.length > 0) {
235
+ return resolvedRoots(options, detailed.roots, "prompt", true);
236
+ }
237
+ explainRejectedRoots(options, withoutHomeRejections(detailed.rejected));
238
+ break;
239
+ }
240
+ prompt.message?.(HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE);
241
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE}`);
242
+ }
243
+ function resolveRootInputPath(input, homeDir, cwd) {
244
+ const expanded = input === "~" || input.startsWith("~/")
245
+ ? path.join(homeDir, input.slice(2))
246
+ : input;
247
+ return path.resolve(cwd, expanded);
214
248
  }
215
249
  function resolvedRoots(options, roots, source, confirmed) {
216
250
  const result = { roots, source, confirmed };
@@ -219,8 +253,21 @@ function resolvedRoots(options, roots, source, confirmed) {
219
253
  }
220
254
  return result;
221
255
  }
256
+ // Canonicalize through realpath so a symlink pointing at $HOME can't slip past
257
+ // the home-root guard on the decline path (e.g. `~/home-link -> /Users/me`).
258
+ // Non-existent paths (or realpath errors) fall back to the lexical resolve so
259
+ // behavior is unchanged for the retry branch and for tests using fake homedirs.
260
+ function canonicalPath(input) {
261
+ const resolved = path.resolve(input);
262
+ try {
263
+ return realpathSync(resolved);
264
+ }
265
+ catch {
266
+ return resolved;
267
+ }
268
+ }
222
269
  function isHomeRoot(root, homeDir) {
223
- return path.resolve(root) === path.resolve(homeDir ?? os.homedir());
270
+ return canonicalPath(root) === canonicalPath(homeDir ?? os.homedir());
224
271
  }
225
272
  function homeRootTutorial(homeDirInput) {
226
273
  const homeDir = path.resolve(homeDirInput ?? os.homedir());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {