magus-cli 7.10.2 → 7.10.4

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.
Files changed (39) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +0 -1
  3. package/src/__tests__/blackbox-parity.test.ts +2 -2
  4. package/src/__tests__/blackbox-tool-state.test.ts +8 -18
  5. package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
  6. package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
  7. package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
  8. package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
  9. package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
  10. package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +392 -0
  11. package/src/__tests__/cli-tool-commands.test.ts +2 -2
  12. package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
  13. package/src/__tests__/cli-tools-adapter.test.ts +32 -20
  14. package/src/__tests__/cli-update-view.test.ts +124 -51
  15. package/src/__tests__/fixtures/bins.ts +9 -2
  16. package/src/__tests__/npm-package-name.test.ts +3 -2
  17. package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
  18. package/src/__tests__/tool-probe.test.ts +231 -144
  19. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  20. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  21. package/src/__tests__/tool-versions.test.ts +164 -0
  22. package/src/__tests__/toolchain.test.ts +0 -2
  23. package/src/__tests__/update-plan.test.ts +40 -9
  24. package/src/cli/update-view.ts +182 -68
  25. package/src/cli/update.ts +44 -37
  26. package/src/data/cli-tools.ts +5 -10
  27. package/src/data/tool-versions.ts +137 -0
  28. package/src/services/cli-tool-commands.ts +1 -2
  29. package/src/services/cli-tool-version.ts +10 -88
  30. package/src/services/resolver.ts +9 -15
  31. package/src/services/tool-probe.ts +557 -424
  32. package/src/services/toolchain.ts +10 -4
  33. package/src/services/update-engine.ts +9 -15
  34. package/src/services/update-plan.ts +49 -18
  35. package/src/types/index.ts +0 -2
  36. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  37. package/src/ui/renderers/cliToolRenderers.tsx +15 -10
  38. package/src/ui/state/types.ts +0 -1
  39. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
@@ -193,7 +193,6 @@ const pinned = (version: string | undefined) => (version ? { version } : {});
193
193
  export function managedTool(bin: SelectedBin): ManagedTool | null {
194
194
  const option = bin.chosen;
195
195
  if (!option) return null;
196
- const check = bin.check.join(" ");
197
196
  switch (option.via) {
198
197
  case "bun":
199
198
  case "npm":
@@ -202,7 +201,6 @@ export function managedTool(bin: SelectedBin): ManagedTool | null {
202
201
  name: bin.name,
203
202
  via: option.via,
204
203
  package: option.package,
205
- check,
206
204
  ...pinned(option.version),
207
205
  };
208
206
  case "go":
@@ -210,7 +208,6 @@ export function managedTool(bin: SelectedBin): ManagedTool | null {
210
208
  name: bin.name,
211
209
  via: "go",
212
210
  module: option.package,
213
- check,
214
211
  ...pinned(option.version),
215
212
  };
216
213
  case "brew":
@@ -218,7 +215,7 @@ export function managedTool(bin: SelectedBin): ManagedTool | null {
218
215
  // version-managed view does not speak: its declaration manages it.
219
216
  return option.cask !== undefined
220
217
  ? null
221
- : { name: bin.name, via: "brew", formula: option.formula, check };
218
+ : { name: bin.name, via: "brew", formula: option.formula };
222
219
  case "apt":
223
220
  case "release":
224
221
  case "bootstrap":
@@ -281,6 +278,15 @@ export function managedInstallCommand(t: ManagedTool): Command {
281
278
  return { cmd, args };
282
279
  }
283
280
 
281
+ /**
282
+ * The command that releases a brew pin, for a tool brew reports behind but will
283
+ * not move (`held-back`). It is the one fix the user can run for that row, so
284
+ * `magus update` shows it rather than running it: unpinning is the user's call.
285
+ */
286
+ export function brewUnpinCommand(t: ManagedTool): Command {
287
+ return { cmd: "brew", args: ["unpin", t.formula ?? t.name] };
288
+ }
289
+
284
290
  /**
285
291
  * The command that advances an ALREADY-INSTALLED binary to the newest version.
286
292
  *
@@ -1177,7 +1177,7 @@ function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
1177
1177
  return `the installer exited 0 but ${item.name} is still not on PATH`;
1178
1178
  }
1179
1179
  if (after.managed === false) {
1180
- return `installed, but PATH still runs ${after.resolvedPath ?? "another copy"}`;
1180
+ return "installed, but another copy still runs first";
1181
1181
  }
1182
1182
  if (
1183
1183
  item.action === "upgrade" &&
@@ -1190,19 +1190,6 @@ function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
1190
1190
  return null;
1191
1191
  }
1192
1192
 
1193
- /**
1194
- * The read-back probe, and the ONLY caller that lets the probe run a tool's
1195
- * `check` command.
1196
- *
1197
- * Planning never does: `check` comes from a marketplace `plugin.json`, and
1198
- * `--dry-run` / `--check` must execute nothing a catalogue supplied. Here the
1199
- * installer for this very tool has just run with the user's consent, and the
1200
- * probe's own narrowing means the only argv that can run is the tool asking
1201
- * itself its version (`tool-probe.ts`, `versionFromCheck`). Without it a tool
1202
- * whose installer keeps no readable record reads back as "version unknown",
1203
- * which R6 counts as changed — the safe answer, but never "no change — still
1204
- * 3.7c", the one the user needs.
1205
- */
1206
1193
  /** How long one `magus update` installer may run before it is stopped. */
1207
1194
  const INSTALLER_TIMEOUT_MS = 15 * 60_000;
1208
1195
 
@@ -1210,8 +1197,15 @@ const INSTALLER_TIMEOUT_MS = 15 * 60_000;
1210
1197
  const runInstaller = async (command: Command): Promise<boolean> =>
1211
1198
  succeeded(await runArgv(command, { timeoutMs: INSTALLER_TIMEOUT_MS }));
1212
1199
 
1200
+ /**
1201
+ * The read-back probe: the same probe planning runs, so the version after an
1202
+ * install is read exactly as the plan read it — the installer's own record,
1203
+ * else magus's own version command for the tool (`data/tool-versions.ts`).
1204
+ * Never a plugin's `check` string. A tool with neither reads back as "version
1205
+ * unknown", which R6 counts as changed: the safe answer.
1206
+ */
1213
1207
  const REAL_READ_BACK = (spec: ManagedBin): Promise<ToolFacts> =>
1214
- probeTool(spec, undefined, { allowCheckCommand: true });
1208
+ probeTool(spec);
1215
1209
 
1216
1210
  export async function applyBins(
1217
1211
  plan: UpdatePlan,
@@ -1034,6 +1034,13 @@ export interface BinUpdateItem {
1034
1034
  * the same value the TUI's row runs on Enter.
1035
1035
  */
1036
1036
  upgradeCommand?: Command;
1037
+ /**
1038
+ * {@link ToolFacts.otherCopies}: how many other copies of the tool sit
1039
+ * later on PATH, which nothing runs. Counted in the CLI header, never
1040
+ * described: which copy to keep is the user's call, and magus has no
1041
+ * command to offer for it.
1042
+ */
1043
+ otherCopies?: number;
1037
1044
  /** The exact pin, when the profile declares one. */
1038
1045
  version?: string;
1039
1046
  /** The version PATH runs, or null when absent / unreadable. */
@@ -1089,6 +1096,7 @@ export function planBinUpdates(
1089
1096
  };
1090
1097
  if (status.note) item.note = status.note;
1091
1098
  if (status.adopted) item.adopted = status.adopted;
1099
+ if (f?.otherCopies) item.otherCopies = f.otherCopies;
1092
1100
  if (f?.upgradeCommand) item.upgradeCommand = f.upgradeCommand;
1093
1101
  if (bin.version) item.version = bin.version;
1094
1102
  return item;
@@ -1190,14 +1198,18 @@ export function planHasWork(plan: UpdatePlan): boolean {
1190
1198
  * NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
1191
1199
  * this" is not evidence of drift, and most unknowns are structural and
1192
1200
  * permanent rather than transient: a plugin whose marketplace publishes no
1193
- * version (278 of the official ones), a tool on PATH but installed by a
1194
- * different npm, `go` absent, a private registry. A gate that exits 1 on those
1195
- * can never pass, so it protects nothing and is switched off. The cost is that
1196
- * an item whose check merely FAILED this run — a rate-limited catalog fetch, a
1197
- * timed-out probe — also passes, so the exit code alone must never be read as
1198
- * "everything verified": the caller prints how many items went unchecked
1199
- * (`summarizePlan`'s `unknownPlugins`/`unknownTools`, rendered by
1200
- * `cli/update-view.ts`), and every unknown row carries its own reason above.
1201
+ * version (278 of the official ones), a tool whose installer timed out, `go`
1202
+ * absent, a private registry. A gate that exits 1 on those can never pass, so
1203
+ * it protects nothing and is switched off. The cost is that an item whose check
1204
+ * merely FAILED this run — a rate-limited catalog fetch, a timed-out probe —
1205
+ * also passes, so the exit code alone must never be read as "everything
1206
+ * verified": the caller prints how many items went unchecked (`summarizePlan`'s
1207
+ * `unknownPlugins`/`unknownTools`, rendered by `cli/update-view.ts`).
1208
+ *
1209
+ * A tool on PATH that another installer put there is not unknown at all: it is
1210
+ * `external`, found and correctly left alone, and is not drift either — nothing
1211
+ * magus could run would change it. A held-back tool ({@link binHeldBack}) is
1212
+ * behind, but pinned by the user, so it does not fail the gate either.
1201
1213
  *
1202
1214
  * A `remove` DOES count. It used to be an `unknown` ("not offered"), and the
1203
1215
  * argument above kept it out while nothing could act on it. `magus update` now
@@ -1223,6 +1235,16 @@ export function planIsBehind(plan: UpdatePlan): boolean {
1223
1235
  );
1224
1236
  }
1225
1237
 
1238
+ /**
1239
+ * A tool brew reports behind but holds back with a pin. It is `unknown` to the
1240
+ * classifier — nothing magus runs can move it — but it is not unchecked, so
1241
+ * every count and label calls it "held back", and its row shows the one fix the
1242
+ * user can run.
1243
+ */
1244
+ export function binHeldBack(item: BinUpdateItem): boolean {
1245
+ return item.action === "unknown" && item.reason === "held-back";
1246
+ }
1247
+
1226
1248
  /** Counts for the closing summary line, in a stable order. */
1227
1249
  export function summarizePlan(plan: UpdatePlan): {
1228
1250
  installed: number;
@@ -1233,25 +1255,32 @@ export function summarizePlan(plan: UpdatePlan): {
1233
1255
  unknown: number;
1234
1256
  /** Plugins that could not be checked — never a {@link isKeptDeprecated} one. */
1235
1257
  unknownPlugins: number;
1258
+ /** Tools that could not be checked — never a {@link binHeldBack} one. */
1236
1259
  unknownTools: number;
1237
1260
  /**
1238
- * Left as they are, on purpose. Plugins no longer published by their
1239
- * marketplace and kept (moved, or still required), and tools another
1240
- * installer put on PATH (`external`). Determined facts, so not part of
1241
- * `unknown`.
1261
+ * No longer published by their marketplace, and kept on purpose: moved, or
1262
+ * still required. A determined fact, so not part of `unknown`.
1242
1263
  */
1243
1264
  kept: number;
1265
+ /**
1266
+ * Tools on PATH that the declared installer does not have: installed another
1267
+ * way, found, and left alone. A determined fact, so not part of `unknown`.
1268
+ */
1269
+ external: number;
1270
+ /**
1271
+ * Tools brew reports behind but will not move, because they are pinned. brew
1272
+ * DID check them, so not part of `unknown`; the user's fix is `brew unpin`.
1273
+ */
1274
+ heldBack: number;
1244
1275
  } {
1245
1276
  const count = (a: PluginUpdateAction) =>
1246
1277
  plan.plugins.filter((p) => p.action === a).length;
1247
1278
  const bins = (a: BinUpdateAction) =>
1248
1279
  plan.bins.filter((b) => b.action === a).length;
1249
- const keptPlugins = plan.plugins.filter(isKeptDeprecated).length;
1250
- const unknownPlugins = count("unknown") - keptPlugins;
1251
- // A tool another installer put on PATH is left as it is: settled, like a
1252
- // kept plugin, and never "could not be checked".
1253
- const kept = keptPlugins + bins("external");
1254
- const unknownTools = bins("unknown");
1280
+ const kept = plan.plugins.filter(isKeptDeprecated).length;
1281
+ const unknownPlugins = count("unknown") - kept;
1282
+ const heldBack = plan.bins.filter(binHeldBack).length;
1283
+ const unknownTools = bins("unknown") - heldBack;
1255
1284
  return {
1256
1285
  installed: count("install") + bins("install"),
1257
1286
  updated: count("update") + bins("upgrade"),
@@ -1261,5 +1290,7 @@ export function summarizePlan(plan: UpdatePlan): {
1261
1290
  unknownPlugins,
1262
1291
  unknownTools,
1263
1292
  kept,
1293
+ external: bins("external"),
1294
+ heldBack,
1264
1295
  };
1265
1296
  }
@@ -468,8 +468,6 @@ export interface ManagedTool {
468
468
  formula?: string;
469
469
  /** Module path for `go install` (defaults to `name`). */
470
470
  module?: string;
471
- /** The check, as one display line, e.g. "tmux -V" (defaults to `<name> --version`). */
472
- check?: string;
473
471
  /** Optional version pin (real for bun/npm/pip; advisory for brew/go). */
474
472
  version?: string;
475
473
  }
@@ -29,7 +29,6 @@ import {
29
29
  type ToolReason,
30
30
  type ToolStatus,
31
31
  classifyTool,
32
- installerWord,
33
32
  } from "../../services/tool-probe.js";
34
33
  import { binCommandForState } from "../../services/toolchain.js";
35
34
  import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
@@ -214,7 +213,8 @@ export interface CliToolEnterAction {
214
213
  * pane beside it said "Update to v0.95.0". All three read this now.
215
214
  *
216
215
  * An `unknown` row still offers the detected method's command: an explicit
217
- * user action, not a plan, and the detail pane shows why beside it.
216
+ * user action, not a plan, and the detail pane shows why beside it. An
217
+ * `external` row offers nothing: it is installed another way.
218
218
  */
219
219
  export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
220
220
  const { status, updateCommand, installCommand } = row;
@@ -236,6 +236,14 @@ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
236
236
  // Both commands come from the row, which built them from the SAME rule its
237
237
  // state came from — a declared tool's pin included.
238
238
  const command = updateCommand ?? installCommand;
239
+ // Installed another way: nothing magus could run is the user's fix, and
240
+ // falling through to `reinstall` would install a second copy through the
241
+ // installer that does not own it.
242
+ if (status.state === "external") return null;
243
+ // Held back by the installer (a brew pin): nothing magus runs can move it,
244
+ // so `update anyway` would be a no-op. The fix is the user's — the row's
245
+ // note, like the CLI's `brew unpin <formula>`, says what it is.
246
+ if (status.reason === "held-back") return null;
239
247
  if (status.state === "upgrade") {
240
248
  return {
241
249
  kind: "update",
@@ -247,9 +255,7 @@ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
247
255
  command,
248
256
  };
249
257
  }
250
- // Another installer's file, or one magus could not place: still offered,
251
- // as an explicit choice, with the note beside it (see the renderer).
252
- if (status.state === "unknown" || status.state === "external") {
258
+ if (status.state === "unknown") {
253
259
  return {
254
260
  kind: "update-anyway",
255
261
  label: "update anyway",
@@ -276,21 +282,19 @@ export interface CliToolStatusText {
276
282
  /**
277
283
  * The row's short word for WHY an `unknown` could not be checked.
278
284
  *
279
- * A label, never the note. The note is a sentence with a path in it ("on PATH
280
- * at /Users/…, which npm … does not own — …"); a list row has room for a
281
- * version, a method tag and a few words, so printing the note there cut it off
282
- * mid-path on every machine. The full note stays in the detail pane's "Why"
283
- * line, the same string `magus update` prints.
285
+ * A label, never the note. The note is a sentence; a list row has room for a
286
+ * version, a method tag and a few words, so printing the note there cut it
287
+ * off on every machine. The full note stays in the detail pane's "Why" line.
284
288
  *
285
289
  * Every label is at most 19 cells, so the row suffix (" ? " + label) stays
286
- * within 24. Keyed by reason alone.
290
+ * within 24. Keyed by reason alone. A held-back tool never reaches here: it was
291
+ * checked, so its row says "held back" without the `?` (see
292
+ * {@link cliToolStatusText}).
287
293
  */
288
294
  function unknownLabel(reason: ToolReason): string {
289
295
  switch (reason) {
290
- // Only the undetermined case: a file known to be another installer's
291
- // is `external`, not `unknown`.
292
- case "not-managed":
293
- return "owner not known";
296
+ case "owner-unknown":
297
+ return "owner unknown";
294
298
  case "version-unreadable":
295
299
  return "version unreadable";
296
300
  case "latest-unavailable":
@@ -305,12 +309,14 @@ function unknownLabel(reason: ToolReason): string {
305
309
  return "pin not installable";
306
310
  case "not-probed":
307
311
  return "not checked";
308
- // Never `unknown`: these reasons plan work or report `current`.
312
+ // Never `unknown`: these reasons plan work, report `current`, or are
313
+ // `external`.
309
314
  case "absent":
310
315
  case "up-to-date":
311
316
  case "behind":
312
317
  case "at-pin":
313
318
  case "behind-pin":
319
+ case "installed-elsewhere":
314
320
  return "not checked";
315
321
  }
316
322
  }
@@ -324,15 +330,14 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
324
330
  if (row.checking || !status) return { suffix: " ...", tone: "muted" };
325
331
  switch (status.state) {
326
332
  case "unknown":
333
+ // Behind and pinned: brew checked it, so no `?` — the same two words
334
+ // `magus update` uses.
335
+ if (status.reason === "held-back")
336
+ return { suffix: " held back", tone: "warning" };
327
337
  return {
328
338
  suffix: ` ? ${unknownLabel(status.reason)}`,
329
339
  tone: "warning",
330
340
  };
331
- case "external":
332
- return {
333
- suffix: ` · outside ${installerWord(row.spec.via)}`,
334
- tone: "muted",
335
- };
336
341
  case "upgrade":
337
342
  return {
338
343
  suffix: status.target
@@ -340,6 +345,8 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
340
345
  : " update available",
341
346
  tone: "warning",
342
347
  };
348
+ case "external":
349
+ return { suffix: " installed elsewhere", tone: "muted" };
343
350
  case "install":
344
351
  case "current":
345
352
  return { suffix: "", tone: "muted" };
@@ -362,7 +369,9 @@ export function countToolRows(rows: readonly CliToolsRow[]): {
362
369
  continue;
363
370
  }
364
371
  if (row.status?.state === "upgrade") updates++;
365
- if (row.status?.state === "unknown") unverified++;
372
+ // A held-back tool was checked: brew says it is behind and pinned.
373
+ if (row.status?.state === "unknown" && row.status.reason !== "held-back")
374
+ unverified++;
366
375
  }
367
376
  return { installed, updates, unverified };
368
377
  }
@@ -247,11 +247,16 @@ export function renderCliToolRow(
247
247
  } else if (status?.state === "upgrade") {
248
248
  icon = "*";
249
249
  iconColor = theme.colors.warning;
250
+ } else if (status?.state === "unknown" && status.reason === "held-back") {
251
+ // Checked, behind, and pinned: no `?`, the same mark `magus update` uses.
252
+ icon = "‖";
253
+ iconColor = theme.colors.warning;
250
254
  } else if (status?.state === "unknown") {
251
255
  icon = "?";
252
256
  iconColor = theme.colors.warning;
253
257
  } else if (status?.state === "external") {
254
- // Installed, and left alone on purpose: present, not a finding.
258
+ // Installed another way: present and fine, but not magus's to move — a
259
+ // muted dot, never the green of a tool magus keeps up to date.
255
260
  icon = "●";
256
261
  iconColor = theme.colors.muted;
257
262
  } else {
@@ -358,7 +363,12 @@ export function renderCliToolDetail(
358
363
  ) : state === "upgrade" ? (
359
364
  <text fg={theme.colors.warning}>{"● Update available"}</text>
360
365
  ) : state === "external" ? (
361
- <text fg={theme.colors.muted}>{"● Installed, kept as is"}</text>
366
+ // Never "Up to date": magus did not check it against anything.
367
+ <text fg={theme.colors.muted} flexShrink={0}>
368
+ {"● Installed elsewhere"}
369
+ </text>
370
+ ) : state === "unknown" && status.reason === "held-back" ? (
371
+ <text fg={theme.colors.warning}>{"‖ Held back"}</text>
362
372
  ) : state === "unknown" ? (
363
373
  // A version that was read but cannot be checked for updates is
364
374
  // still a version: say "installed", and let Why say the rest.
@@ -371,17 +381,12 @@ export function renderCliToolDetail(
371
381
  <text fg={theme.colors.success}>{"● Up to date"}</text>
372
382
  )}
373
383
  </box>
374
- {(state === "unknown" || state === "external") && status?.note ? (
384
+ {state === "unknown" && status?.note ? (
375
385
  <box flexDirection="row">
376
386
  <text fg={theme.colors.muted} flexShrink={0}>
377
387
  {"Why "}
378
388
  </text>
379
- <text
380
- fg={
381
- state === "external" ? theme.colors.muted : theme.colors.warning
382
- }
383
- minWidth={0}
384
- >
389
+ <text fg={theme.colors.warning} minWidth={0}>
385
390
  {wrapValue(status.note, width)}
386
391
  </text>
387
392
  </box>
@@ -556,7 +561,7 @@ export function renderCliToolDetail(
556
561
  running binary; the menu still offers it, as an explicit
557
562
  user action, and says so beside it. */}
558
563
  {enter.kind === "update-anyway" &&
559
- status?.reason === "not-managed" &&
564
+ status?.reason === "owner-unknown" &&
560
565
  status.note ? (
561
566
  <text fg={theme.colors.warning}>
562
567
  {wrapText(` ${status.note}`, width).join("\n")}
@@ -258,7 +258,6 @@ export interface CliTool {
258
258
  displayName: string;
259
259
  description: string;
260
260
  installCommand: string;
261
- checkCommand: string;
262
261
  homebrewFormula?: string;
263
262
  npmPackage?: string;
264
263
  installedVersion?: string;
@@ -1,239 +0,0 @@
1
- /**
2
- * Blind tests — contract C9 (requirement R9.3).
3
- *
4
- * Written from requirements.md and architecture.md only. Each test builds the
5
- * on-disk shape a package manager leaves behind (a bin symlink into a global
6
- * node_modules package) inside its own temp dir.
7
- */
8
- import { afterEach, describe, expect, test } from "bun:test";
9
- import {
10
- chmodSync,
11
- mkdirSync,
12
- mkdtempSync,
13
- realpathSync,
14
- rmSync,
15
- symlinkSync,
16
- writeFileSync,
17
- } from "node:fs";
18
- import os from "node:os";
19
- import path from "node:path";
20
-
21
- const load = () => import("../../services/cli-tool-version.js");
22
-
23
- const dirs: string[] = [];
24
- afterEach(() => {
25
- for (const dir of dirs.splice(0))
26
- rmSync(dir, { recursive: true, force: true });
27
- });
28
-
29
- function temp(): string {
30
- // realpath so relative symlinks are computed against the real location.
31
- const dir = realpathSync(
32
- mkdtempSync(path.join(os.tmpdir(), "magus-blind-c9-")),
33
- );
34
- dirs.push(dir);
35
- return dir;
36
- }
37
-
38
- function writeJson(file: string, value: unknown): void {
39
- mkdirSync(path.dirname(file), { recursive: true });
40
- writeFileSync(file, JSON.stringify(value, null, 2));
41
- }
42
-
43
- function writeBin(file: string): void {
44
- mkdirSync(path.dirname(file), { recursive: true });
45
- writeFileSync(file, "#!/usr/bin/env node\nconsole.log('hi');\n");
46
- chmodSync(file, 0o755);
47
- }
48
-
49
- function link(target: string, at: string, relative = false): void {
50
- mkdirSync(path.dirname(at), { recursive: true });
51
- symlinkSync(relative ? path.relative(path.dirname(at), target) : target, at);
52
- }
53
-
54
- describe("C9 — readPackageVersionForBin", () => {
55
- test("R9.3 npm global layout: bin/magus -> ../lib/node_modules/magus-cli/bin/magus.js reads magus-cli's version", async () => {
56
- const { readPackageVersionForBin } = await load();
57
- const prefix = temp();
58
- const pkg = path.join(prefix, "lib", "node_modules", "magus-cli");
59
- writeJson(path.join(pkg, "package.json"), {
60
- name: "magus-cli",
61
- version: "7.3.0",
62
- });
63
- writeBin(path.join(pkg, "bin", "magus.js"));
64
- link(
65
- path.join(pkg, "bin", "magus.js"),
66
- path.join(prefix, "bin", "magus"),
67
- true,
68
- );
69
- expect(readPackageVersionForBin(path.join(prefix, "bin", "magus"))).toBe(
70
- "7.3.0",
71
- );
72
- });
73
-
74
- test("R9.3 bun global layout with a scoped package (Claude Code) reads its version", async () => {
75
- const { readPackageVersionForBin } = await load();
76
- const root = temp();
77
- const pkg = path.join(
78
- root,
79
- ".bun",
80
- "install",
81
- "global",
82
- "node_modules",
83
- "@anthropic-ai",
84
- "claude-code",
85
- );
86
- writeJson(path.join(pkg, "package.json"), {
87
- name: "@anthropic-ai/claude-code",
88
- version: "2.1.300",
89
- });
90
- writeBin(path.join(pkg, "cli.js"));
91
- link(path.join(pkg, "cli.js"), path.join(root, ".bun", "bin", "claude"));
92
- expect(
93
- readPackageVersionForBin(path.join(root, ".bun", "bin", "claude")),
94
- ).toBe("2.1.300");
95
- });
96
-
97
- test("R9.3 resolves a two-hop symlink chain before walking up", async () => {
98
- const { readPackageVersionForBin } = await load();
99
- const root = temp();
100
- const pkg = path.join(root, "store", "node_modules", "@charmland", "crush");
101
- writeJson(path.join(pkg, "package.json"), {
102
- name: "@charmland/crush",
103
- version: "0.9.1",
104
- });
105
- writeBin(path.join(pkg, "bin", "crush"));
106
- link(path.join(pkg, "bin", "crush"), path.join(root, "hop", "crush"));
107
- link(
108
- path.join(root, "hop", "crush"),
109
- path.join(root, "bin", "crush"),
110
- true,
111
- );
112
- expect(readPackageVersionForBin(path.join(root, "bin", "crush"))).toBe(
113
- "0.9.1",
114
- );
115
- });
116
-
117
- test("R9.3 walks up from the binary's REAL location, not from where the symlink sits", async () => {
118
- const { readPackageVersionForBin } = await load();
119
- const root = temp();
120
- // The symlink lives inside an unrelated package; that package must not answer.
121
- writeJson(path.join(root, "host", "package.json"), {
122
- name: "host",
123
- version: "0.0.1",
124
- });
125
- const pkg = path.join(
126
- root,
127
- "global",
128
- "node_modules",
129
- "@charmland",
130
- "crush",
131
- );
132
- writeJson(path.join(pkg, "package.json"), {
133
- name: "@charmland/crush",
134
- version: "0.9.1",
135
- });
136
- writeBin(path.join(pkg, "bin", "crush"));
137
- link(
138
- path.join(pkg, "bin", "crush"),
139
- path.join(root, "host", "bin", "crush"),
140
- );
141
- expect(
142
- readPackageVersionForBin(path.join(root, "host", "bin", "crush")),
143
- ).toBe("0.9.1");
144
- });
145
-
146
- test("R9.3 a binary given by its real path (no symlink) reads its package's version", async () => {
147
- const { readPackageVersionForBin } = await load();
148
- const root = temp();
149
- const pkg = path.join(root, "node_modules", "tool");
150
- writeJson(path.join(pkg, "package.json"), {
151
- name: "tool",
152
- version: "1.2.3",
153
- });
154
- writeBin(path.join(pkg, "dist", "bin", "tool.js"));
155
- expect(
156
- readPackageVersionForBin(path.join(pkg, "dist", "bin", "tool.js")),
157
- ).toBe("1.2.3");
158
- });
159
-
160
- test("R9.3 skips package.json files missing name or version and keeps walking up", async () => {
161
- const { readPackageVersionForBin } = await load();
162
- const root = temp();
163
- const pkg = path.join(root, "node_modules", "tool");
164
- writeJson(path.join(pkg, "package.json"), {
165
- name: "tool",
166
- version: "4.5.6",
167
- });
168
- // Nearer package.json files that do not qualify:
169
- writeJson(path.join(pkg, "dist", "package.json"), { type: "module" });
170
- writeJson(path.join(pkg, "dist", "esm", "package.json"), {
171
- name: "tool-esm",
172
- });
173
- writeJson(path.join(pkg, "dist", "esm", "bin", "package.json"), {
174
- version: "0.0.0",
175
- });
176
- writeBin(path.join(pkg, "dist", "esm", "bin", "tool.js"));
177
- link(
178
- path.join(pkg, "dist", "esm", "bin", "tool.js"),
179
- path.join(root, "bin", "tool"),
180
- );
181
- expect(readPackageVersionForBin(path.join(root, "bin", "tool"))).toBe(
182
- "4.5.6",
183
- );
184
- });
185
-
186
- test("R9.3 the nearest qualifying package.json wins over an enclosing project's", async () => {
187
- const { readPackageVersionForBin } = await load();
188
- const root = temp();
189
- writeJson(path.join(root, "package.json"), {
190
- name: "workspace",
191
- version: "9.9.9",
192
- });
193
- const pkg = path.join(root, "node_modules", "tool");
194
- writeJson(path.join(pkg, "package.json"), {
195
- name: "tool",
196
- version: "1.0.0",
197
- });
198
- writeBin(path.join(pkg, "bin", "tool.js"));
199
- expect(readPackageVersionForBin(path.join(pkg, "bin", "tool.js"))).toBe(
200
- "1.0.0",
201
- );
202
- });
203
-
204
- test("R9.3 returns null when no package.json with name and version is found", async () => {
205
- const { readPackageVersionForBin } = await load();
206
- const root = temp();
207
- writeBin(path.join(root, "bin", "lonely"));
208
- expect(
209
- readPackageVersionForBin(path.join(root, "bin", "lonely")),
210
- ).toBeNull();
211
- });
212
-
213
- test("R9.3 returns null (does not throw) for a path that does not exist", async () => {
214
- const { readPackageVersionForBin } = await load();
215
- const root = temp();
216
- expect(
217
- readPackageVersionForBin(path.join(root, "bin", "missing")),
218
- ).toBeNull();
219
- });
220
-
221
- test("R9.3 returns null (does not throw) for a dangling symlink", async () => {
222
- const { readPackageVersionForBin } = await load();
223
- const root = temp();
224
- link(path.join(root, "gone", "tool.js"), path.join(root, "bin", "tool"));
225
- expect(readPackageVersionForBin(path.join(root, "bin", "tool"))).toBeNull();
226
- });
227
-
228
- test("R9.3 returns null (does not throw) when the only package.json is unreadable JSON", async () => {
229
- const { readPackageVersionForBin } = await load();
230
- const root = temp();
231
- const pkg = path.join(root, "node_modules", "broken");
232
- mkdirSync(pkg, { recursive: true });
233
- writeFileSync(path.join(pkg, "package.json"), "{ not json");
234
- writeBin(path.join(pkg, "bin", "broken.js"));
235
- expect(
236
- readPackageVersionForBin(path.join(pkg, "bin", "broken.js")),
237
- ).toBeNull();
238
- });
239
- });