magus-cli 7.10.3 → 7.10.5
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/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +0 -1
- package/src/__tests__/blackbox-parity.test.ts +2 -2
- package/src/__tests__/blackbox-tool-state.test.ts +8 -18
- package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
- package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
- package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +38 -35
- package/src/__tests__/cli-tool-commands.test.ts +2 -2
- package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
- package/src/__tests__/cli-tools-adapter.test.ts +32 -22
- package/src/__tests__/cli-update-view.test.ts +124 -51
- package/src/__tests__/fixtures/bins.ts +9 -2
- package/src/__tests__/npm-package-name.test.ts +3 -2
- package/src/__tests__/profile-worktree-include.test.ts +212 -0
- package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
- package/src/__tests__/tool-probe.test.ts +231 -144
- package/src/__tests__/tool-rows-followups.test.ts +315 -0
- package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
- package/src/__tests__/tool-versions.test.ts +164 -0
- package/src/__tests__/toolchain.test.ts +0 -2
- package/src/__tests__/update-plan.test.ts +40 -9
- package/src/cli/update-view.ts +180 -76
- package/src/cli/update.ts +44 -37
- package/src/data/cli-tools.ts +5 -10
- package/src/data/tool-versions.ts +137 -0
- package/src/services/cli-tool-commands.ts +1 -2
- package/src/services/cli-tool-version.ts +10 -88
- package/src/services/profile-activate.ts +9 -0
- package/src/services/profile-live.ts +26 -0
- package/src/services/profile-skills.ts +14 -4
- package/src/services/profile-state.ts +18 -0
- package/src/services/resolver.ts +9 -15
- package/src/services/tool-probe.ts +537 -691
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -21
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -21
- package/src/ui/state/types.ts +0 -1
- 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
|
|
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
|
|
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
|
|
1208
|
+
probeTool(spec);
|
|
1215
1209
|
|
|
1216
1210
|
export async function applyBins(
|
|
1217
1211
|
plan: UpdatePlan,
|
|
@@ -1034,8 +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
|
-
|
|
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;
|
|
1039
1044
|
/** The exact pin, when the profile declares one. */
|
|
1040
1045
|
version?: string;
|
|
1041
1046
|
/** The version PATH runs, or null when absent / unreadable. */
|
|
@@ -1091,7 +1096,7 @@ export function planBinUpdates(
|
|
|
1091
1096
|
};
|
|
1092
1097
|
if (status.note) item.note = status.note;
|
|
1093
1098
|
if (status.adopted) item.adopted = status.adopted;
|
|
1094
|
-
if (
|
|
1099
|
+
if (f?.otherCopies) item.otherCopies = f.otherCopies;
|
|
1095
1100
|
if (f?.upgradeCommand) item.upgradeCommand = f.upgradeCommand;
|
|
1096
1101
|
if (bin.version) item.version = bin.version;
|
|
1097
1102
|
return item;
|
|
@@ -1193,14 +1198,18 @@ export function planHasWork(plan: UpdatePlan): boolean {
|
|
|
1193
1198
|
* NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
|
|
1194
1199
|
* this" is not evidence of drift, and most unknowns are structural and
|
|
1195
1200
|
* permanent rather than transient: a plugin whose marketplace publishes no
|
|
1196
|
-
* version (278 of the official ones), a tool
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
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.
|
|
1204
1213
|
*
|
|
1205
1214
|
* A `remove` DOES count. It used to be an `unknown` ("not offered"), and the
|
|
1206
1215
|
* argument above kept it out while nothing could act on it. `magus update` now
|
|
@@ -1226,6 +1235,16 @@ export function planIsBehind(plan: UpdatePlan): boolean {
|
|
|
1226
1235
|
);
|
|
1227
1236
|
}
|
|
1228
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
|
+
|
|
1229
1248
|
/** Counts for the closing summary line, in a stable order. */
|
|
1230
1249
|
export function summarizePlan(plan: UpdatePlan): {
|
|
1231
1250
|
installed: number;
|
|
@@ -1236,25 +1255,32 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
1236
1255
|
unknown: number;
|
|
1237
1256
|
/** Plugins that could not be checked — never a {@link isKeptDeprecated} one. */
|
|
1238
1257
|
unknownPlugins: number;
|
|
1258
|
+
/** Tools that could not be checked — never a {@link binHeldBack} one. */
|
|
1239
1259
|
unknownTools: number;
|
|
1240
1260
|
/**
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
* installer put on PATH (`external`). Determined facts, so not part of
|
|
1244
|
-
* `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`.
|
|
1245
1263
|
*/
|
|
1246
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;
|
|
1247
1275
|
} {
|
|
1248
1276
|
const count = (a: PluginUpdateAction) =>
|
|
1249
1277
|
plan.plugins.filter((p) => p.action === a).length;
|
|
1250
1278
|
const bins = (a: BinUpdateAction) =>
|
|
1251
1279
|
plan.bins.filter((b) => b.action === a).length;
|
|
1252
|
-
const
|
|
1253
|
-
const unknownPlugins = count("unknown") -
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const kept = keptPlugins + bins("external");
|
|
1257
|
-
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;
|
|
1258
1284
|
return {
|
|
1259
1285
|
installed: count("install") + bins("install"),
|
|
1260
1286
|
updated: count("update") + bins("upgrade"),
|
|
@@ -1264,5 +1290,7 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
1264
1290
|
unknownPlugins,
|
|
1265
1291
|
unknownTools,
|
|
1266
1292
|
kept,
|
|
1293
|
+
external: bins("external"),
|
|
1294
|
+
heldBack,
|
|
1267
1295
|
};
|
|
1268
1296
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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>
|
|
@@ -397,17 +402,6 @@ export function renderCliToolDetail(
|
|
|
397
402
|
</text>
|
|
398
403
|
</box>
|
|
399
404
|
) : null}
|
|
400
|
-
{/* A newer copy later on PATH, which nothing runs. */}
|
|
401
|
-
{status?.shadowed ? (
|
|
402
|
-
<box flexDirection="row">
|
|
403
|
-
<text fg={theme.colors.muted} flexShrink={0}>
|
|
404
|
-
{"Warning "}
|
|
405
|
-
</text>
|
|
406
|
-
<text fg={theme.colors.warning} minWidth={0}>
|
|
407
|
-
{wrapValue(`⚠ ${status.shadowed}`, width)}
|
|
408
|
-
</text>
|
|
409
|
-
</box>
|
|
410
|
-
) : null}
|
|
411
405
|
{status?.installed ? (
|
|
412
406
|
<box flexDirection="row">
|
|
413
407
|
<text fg={theme.colors.muted} flexShrink={0}>
|
|
@@ -567,7 +561,7 @@ export function renderCliToolDetail(
|
|
|
567
561
|
running binary; the menu still offers it, as an explicit
|
|
568
562
|
user action, and says so beside it. */}
|
|
569
563
|
{enter.kind === "update-anyway" &&
|
|
570
|
-
status?.reason === "
|
|
564
|
+
status?.reason === "owner-unknown" &&
|
|
571
565
|
status.note ? (
|
|
572
566
|
<text fg={theme.colors.warning}>
|
|
573
567
|
{wrapText(` ${status.note}`, width).join("\n")}
|
package/src/ui/state/types.ts
CHANGED
|
@@ -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
|
-
});
|