magus-cli 7.2.0 → 7.3.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.2.0",
3
+ "version": "7.3.1",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "magus-cli-darwin-arm64": "7.2.0",
68
- "magus-cli-darwin-x64": "7.2.0",
69
- "magus-cli-linux-x64": "7.2.0"
67
+ "magus-cli-darwin-arm64": "7.3.1",
68
+ "magus-cli-darwin-x64": "7.3.1",
69
+ "magus-cli-linux-x64": "7.3.1"
70
70
  }
71
71
  }
@@ -10,8 +10,10 @@
10
10
  * package.json (os/cpu restricted, no `bin` — the launcher execs it)
11
11
  * bin/magus (the compiled binary)
12
12
  *
13
- * The main `magus` package lists these as optionalDependencies; npm installs
14
- * only the one matching the host os/cpu, and bin/magus.js execs it.
13
+ * The main `magus-cli` package lists these as optionalDependencies; npm
14
+ * installs only the one matching the host os/cpu, and bin/magus.js execs it.
15
+ * (`magus-cli` is the npm package; `magus` is the command it installs. The
16
+ * bare `magus` name on npm is an unrelated package.)
15
17
  *
16
18
  * Hard constraint: magus pins @opentui 0.1.x — 0.4.x breaks --compile.
17
19
  *
@@ -51,6 +51,7 @@ function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
51
51
  pluginId: "x@magus",
52
52
  pinned: "latest",
53
53
  installed: "1.0.0",
54
+ loaded: "1.0.0",
54
55
  available: "1.0.0",
55
56
  target: null,
56
57
  action: "current",
@@ -318,6 +319,7 @@ describe("countPluginStates", () => {
318
319
  update: 2,
319
320
  repair: 1,
320
321
  current: 1,
322
+ "stale-load": 0,
321
323
  unknown: 1,
322
324
  });
323
325
  });
@@ -328,6 +330,7 @@ describe("countPluginStates", () => {
328
330
  update: 0,
329
331
  repair: 0,
330
332
  current: 0,
333
+ "stale-load": 0,
331
334
  unknown: 0,
332
335
  });
333
336
  });
@@ -15,6 +15,7 @@ import { existsSync } from "node:fs";
15
15
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
+ import { doctorExitCode } from "../cli/doctor.js";
18
19
  import { checkProject, fixDoctorIssues } from "../services/doctor.js";
19
20
  import type { DoctorResult } from "../services/doctor.js";
20
21
 
@@ -461,3 +462,33 @@ describe("doctor", () => {
461
462
  });
462
463
  });
463
464
  });
465
+
466
+ /**
467
+ * `magus doctor`'s exit code, and the one thing `--fix` must not silence.
468
+ *
469
+ * Stale plugin loads are printed by doctor and repaired by nothing it runs — the
470
+ * section says so itself. They used to be added to the same counter as the
471
+ * fixable problems, and the code was `problems > 0 && !fix ? 1 : 0`, so
472
+ * `magus doctor --fix` printed its red lines and exited 0. A CI or pre-flight
473
+ * step reading that exit code went green over a plugin whose sessions load a
474
+ * version nobody installed.
475
+ */
476
+ describe("doctorExitCode", () => {
477
+ it("exits 0 when nothing is wrong, with or without --fix", () => {
478
+ expect(doctorExitCode({ fixable: 0, unfixable: 0, fix: false })).toBe(0);
479
+ expect(doctorExitCode({ fixable: 0, unfixable: 0, fix: true })).toBe(0);
480
+ });
481
+
482
+ it("exits 1 for fixable problems only when --fix was NOT asked for", () => {
483
+ expect(doctorExitCode({ fixable: 3, unfixable: 0, fix: false })).toBe(1);
484
+ expect(doctorExitCode({ fixable: 3, unfixable: 0, fix: true })).toBe(0);
485
+ });
486
+
487
+ it("exits 1 for a stale plugin load EVEN WITH --fix", () => {
488
+ // The regression: --fix does not repair these, so suppressing the exit code
489
+ // reports success over output that says the opposite.
490
+ expect(doctorExitCode({ fixable: 0, unfixable: 1, fix: true })).toBe(1);
491
+ expect(doctorExitCode({ fixable: 0, unfixable: 1, fix: false })).toBe(1);
492
+ expect(doctorExitCode({ fixable: 2, unfixable: 1, fix: true })).toBe(1);
493
+ });
494
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The npm package name has one source of truth, and the self-upgrade builders
3
+ * read it.
4
+ *
5
+ * `MAGUS_NPM_PACKAGE` is `package.json` `name` (`magus-cli`). It is the npm
6
+ * package, NOT the command: the command is the `bin` key (`magus`), and the
7
+ * bare npm name belongs to an unrelated package. `selfUpgradeCommand` and
8
+ * `duplicateInstallRemoval` are the pure builders behind `magus upgrade`.
9
+ *
10
+ * Written black-box from the API contract. On the old tree this file fails at
11
+ * import, because none of these exports existed.
12
+ */
13
+
14
+ import { describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ type SelfUpgradeManager,
20
+ duplicateInstallRemoval,
21
+ selfUpgradeCommand,
22
+ } from "../cli/upgrade.js";
23
+ import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
24
+
25
+ // Read at test time: the single source of truth the requirements name.
26
+ const PKG = JSON.parse(
27
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
28
+ ) as { name: string; version: string; bin: Record<string, string> };
29
+
30
+ const COMMAND = "magus";
31
+ const PACKAGE = "magus-cli";
32
+ const MANAGERS: SelfUpgradeManager[] = ["bun", "npm"];
33
+
34
+ describe("MAGUS_NPM_PACKAGE", () => {
35
+ test("is package.json `name`", () => {
36
+ expect(typeof MAGUS_NPM_PACKAGE).toBe("string");
37
+ expect(MAGUS_NPM_PACKAGE).toBe(PKG.name);
38
+ expect(MAGUS_NPM_PACKAGE).toBe(PACKAGE);
39
+ });
40
+
41
+ test("is not the command", () => {
42
+ const commands = Object.keys(PKG.bin);
43
+ expect(commands).toContain(COMMAND);
44
+ expect(commands).not.toContain(MAGUS_NPM_PACKAGE);
45
+ expect(MAGUS_NPM_PACKAGE).not.toBe(COMMAND);
46
+ });
47
+ });
48
+
49
+ describe("selfUpgradeCommand", () => {
50
+ test("bun installs `<package>@latest` globally", () => {
51
+ expect(selfUpgradeCommand("bun")).toEqual({
52
+ cmd: "bun",
53
+ args: ["install", "-g", `${PACKAGE}@latest`],
54
+ });
55
+ });
56
+
57
+ test("npm installs `<package>@latest` globally", () => {
58
+ expect(selfUpgradeCommand("npm")).toEqual({
59
+ cmd: "npm",
60
+ args: ["install", "-g", `${PACKAGE}@latest`],
61
+ });
62
+ });
63
+
64
+ test("is pure: the answer does not depend on PATH", () => {
65
+ const expected = MANAGERS.map((m) => selfUpgradeCommand(m));
66
+ const emptyDir = mkdtempSync(join(tmpdir(), "magus-selfupgrade-path-"));
67
+ const savedPath = process.env.PATH;
68
+ try {
69
+ process.env.PATH = emptyDir;
70
+ const again = MANAGERS.map((m) => selfUpgradeCommand(m));
71
+ expect(again).toEqual(expected);
72
+ } finally {
73
+ process.env.PATH = savedPath;
74
+ rmSync(emptyDir, { recursive: true, force: true });
75
+ }
76
+ expect(MANAGERS.map((m) => selfUpgradeCommand(m))).toEqual(expected);
77
+ });
78
+
79
+ test("reads the package name from the single source", () => {
80
+ for (const m of MANAGERS) {
81
+ const { cmd, args } = selfUpgradeCommand(m);
82
+ expect(cmd).toBe(m);
83
+ expect(args[args.length - 1]).toBe(`${MAGUS_NPM_PACKAGE}@latest`);
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("duplicateInstallRemoval", () => {
89
+ test("bun names the package", () => {
90
+ expect(duplicateInstallRemoval("bun")).toBe(`bun uninstall -g ${PACKAGE}`);
91
+ });
92
+
93
+ test("npm names the package", () => {
94
+ expect(duplicateInstallRemoval("npm")).toBe(`npm uninstall -g ${PACKAGE}`);
95
+ });
96
+
97
+ test("carries no leading indentation (the caller indents)", () => {
98
+ for (const m of MANAGERS) {
99
+ const hint = duplicateInstallRemoval(m);
100
+ expect(hint).toBe(hint.trimStart());
101
+ }
102
+ });
103
+
104
+ test("reads the package name from the single source", () => {
105
+ for (const m of MANAGERS) {
106
+ const tokens = duplicateInstallRemoval(m).split(" ");
107
+ expect(tokens[0]).toBe(m);
108
+ expect(tokens[tokens.indexOf("-g") + 1]).toBe(MAGUS_NPM_PACKAGE);
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,280 @@
1
+ /**
2
+ * magus talks to npm by its PACKAGE name, never by its COMMAND name.
3
+ *
4
+ * The npm package is `package.json` `name` (`magus-cli`). The command users
5
+ * type is `magus`. The bare npm name belongs to an unrelated 2015 package
6
+ * whose latest release is 0.0.2, and npm never transfers names, so the split
7
+ * is permanent. Addressing npm by the command name made the update banner go
8
+ * silent and pointed the CLI Tools row at the wrong package.
9
+ *
10
+ * This file uses only exports that existed before the fix, so on the old tree
11
+ * it fails by VALUE (wrong URL, wrong packageName, wrong argv), not merely at
12
+ * import. Written black-box from the API contract.
13
+ *
14
+ * No network: every checkForUpdates call runs against a stubbed fetch, always
15
+ * with force = true, because the result is cached for the session.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { cliTools } from "../data/cli-tools.js";
22
+ import {
23
+ cliToolInstall,
24
+ cliToolUninstall,
25
+ cliToolUpdate,
26
+ } from "../services/cli-tool-commands.js";
27
+ import { cliToolToBin } from "../services/resolver.js";
28
+ import { specForCatalogTool } from "../services/tool-probe.js";
29
+ import {
30
+ checkForUpdates,
31
+ formatUpdateMessage,
32
+ getCurrentVersion,
33
+ } from "../services/version-check.js";
34
+
35
+ // Read at test time: the single source of truth the requirements name.
36
+ const PKG = JSON.parse(
37
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
38
+ ) as { name: string; version: string; bin: Record<string, string> };
39
+
40
+ /** The command users type. Also, unluckily, an unrelated npm package. */
41
+ const COMMAND = "magus";
42
+ /** The npm package, as the contract states it. */
43
+ const PACKAGE = "magus-cli";
44
+ const REGISTRY = "https://registry.npmjs.org";
45
+
46
+ function magusEntry() {
47
+ const entry = cliTools.find((t) => t.name === COMMAND);
48
+ if (!entry) throw new Error("cliTools has no entry named magus");
49
+ return entry;
50
+ }
51
+
52
+ function jsonResponse(body: unknown, status = 200): Response {
53
+ return new Response(JSON.stringify(body), {
54
+ status,
55
+ headers: { "content-type": "application/json" },
56
+ });
57
+ }
58
+
59
+ function urlOf(input: unknown): string {
60
+ return input instanceof Request ? input.url : String(input);
61
+ }
62
+
63
+ describe("checkForUpdates asks npm about the package, not the command", () => {
64
+ const realFetch = globalThis.fetch;
65
+ let urls: string[];
66
+
67
+ beforeEach(() => {
68
+ urls = [];
69
+ });
70
+
71
+ afterEach(() => {
72
+ globalThis.fetch = realFetch;
73
+ });
74
+
75
+ function stubFetch(answer: (url: string) => Response | Promise<Response>) {
76
+ globalThis.fetch = (async (input: unknown) => {
77
+ const url = urlOf(input);
78
+ urls.push(url);
79
+ return answer(url);
80
+ }) as typeof fetch;
81
+ }
82
+
83
+ test("fetches the registry `latest` document of package.json `name`, once", async () => {
84
+ stubFetch(() => jsonResponse({ version: "999.0.0" }));
85
+
86
+ await checkForUpdates(true);
87
+
88
+ expect(urls).toHaveLength(1);
89
+ expect(urls[0]).toBe(`${REGISTRY}/${PKG.name}/latest`);
90
+ expect(urls[0]).toBe(`${REGISTRY}/${PACKAGE}/latest`);
91
+ // The first path segment is the package, never the command.
92
+ const segment = new URL(urls[0]!).pathname.split("/")[1];
93
+ expect(segment).toBe(PKG.name);
94
+ expect(segment).not.toBe(COMMAND);
95
+ });
96
+
97
+ test("with the registry as observed (bare name at 0.0.2, package ahead), an update is reported", async () => {
98
+ // A registry that answers per package, the way npm does: the unrelated
99
+ // package sits at 0.0.2, the real one is ahead of the running version.
100
+ const registry: Record<string, string> = {
101
+ magus: "0.0.2",
102
+ [PKG.name]: "999.0.0",
103
+ };
104
+ stubFetch((url) => {
105
+ const name = new URL(url).pathname.split("/")[1] ?? "";
106
+ const version = registry[name];
107
+ return version
108
+ ? jsonResponse({ version })
109
+ : jsonResponse({ error: "Not found" }, 404);
110
+ });
111
+
112
+ const r = await checkForUpdates(true);
113
+
114
+ expect(r.currentVersion).toBe(PKG.version);
115
+ expect(r.latestVersion).toBe("999.0.0");
116
+ expect(r.updateAvailable).toBe(true);
117
+ });
118
+
119
+ test("a major release above the running version is a major update", async () => {
120
+ stubFetch(() => jsonResponse({ version: "999.0.0" }));
121
+
122
+ const r = await checkForUpdates(true);
123
+
124
+ expect(r.updateAvailable).toBe(true);
125
+ expect(r.updateType).toBe("major");
126
+ expect(r.latestVersion).toBe("999.0.0");
127
+ });
128
+
129
+ test("the unrelated package's 0.0.2 is not an update", async () => {
130
+ stubFetch(() => jsonResponse({ version: "0.0.2" }));
131
+
132
+ const r = await checkForUpdates(true);
133
+
134
+ expect(r.updateAvailable).toBe(false);
135
+ });
136
+
137
+ test("a non-ok registry response reports no update and keeps the running version", async () => {
138
+ stubFetch(() => jsonResponse({ error: "boom" }, 500));
139
+
140
+ const r = await checkForUpdates(true);
141
+
142
+ expect(r.latestVersion).toBe(r.currentVersion);
143
+ expect(r.updateAvailable).toBe(false);
144
+ });
145
+
146
+ test("a failed fetch reports no update and keeps the running version", async () => {
147
+ stubFetch(() => {
148
+ throw new Error("network down");
149
+ });
150
+
151
+ const r = await checkForUpdates(true);
152
+
153
+ expect(r.latestVersion).toBe(r.currentVersion);
154
+ expect(r.updateAvailable).toBe(false);
155
+ });
156
+
157
+ test("the running version is package.json `version`", () => {
158
+ expect(getCurrentVersion()).toBe(PKG.version);
159
+ });
160
+ });
161
+
162
+ describe("formatUpdateMessage", () => {
163
+ // Kept as a guard on the hint text; not V1 evidence (amendment L5).
164
+ test("names `magus upgrade` as the way to update", () => {
165
+ expect(
166
+ formatUpdateMessage({
167
+ currentVersion: "1.0.0",
168
+ latestVersion: "1.2.0",
169
+ updateAvailable: true,
170
+ updateType: "minor",
171
+ } as any),
172
+ ).toBe("Update available: v1.0.0 → v1.2.0 (minor). Run: magus upgrade");
173
+ });
174
+
175
+ test("is empty when no update is available", () => {
176
+ expect(
177
+ formatUpdateMessage({
178
+ currentVersion: "1.0.0",
179
+ latestVersion: "1.0.0",
180
+ updateAvailable: false,
181
+ } as any),
182
+ ).toBe("");
183
+ });
184
+ });
185
+
186
+ describe("the CLI Tools entry for magus", () => {
187
+ test("targets the npm package and keeps the command", () => {
188
+ const entry = magusEntry();
189
+
190
+ expect(entry.packageName).toBe(PKG.name);
191
+ expect(entry.packageName).toBe(PACKAGE);
192
+ // R6: everything that names the command is unchanged.
193
+ expect(entry.name).toBe(COMMAND);
194
+ expect(entry.displayName).toBe(COMMAND);
195
+ expect(entry.checkCommand).toBe("magus --version");
196
+ expect(entry.packageManager).toBe("bun");
197
+ });
198
+
199
+ test("install targets the package", () => {
200
+ expect(cliToolInstall(magusEntry())).toEqual({
201
+ cmd: "bun",
202
+ args: ["install", "-g", PACKAGE],
203
+ });
204
+ });
205
+
206
+ test("update targets `<package>@latest` with bun and with npm", () => {
207
+ const entry = magusEntry();
208
+ expect(cliToolUpdate(entry, "bun")).toEqual({
209
+ cmd: "bun",
210
+ args: ["install", "-g", `${PACKAGE}@latest`],
211
+ });
212
+ expect(cliToolUpdate(entry, "npm")).toEqual({
213
+ cmd: "npm",
214
+ args: ["install", "-g", `${PACKAGE}@latest`],
215
+ });
216
+ });
217
+
218
+ test("uninstall targets the package with bun and with npm", () => {
219
+ const entry = magusEntry();
220
+ expect(cliToolUninstall(entry, "bun")).toEqual({
221
+ cmd: "bun",
222
+ args: ["remove", "-g", PACKAGE],
223
+ });
224
+ expect(cliToolUninstall(entry, "npm")).toEqual({
225
+ cmd: "npm",
226
+ args: ["uninstall", "-g", PACKAGE],
227
+ });
228
+ });
229
+
230
+ test("no derived command addresses the bare command name on npm", () => {
231
+ const entry = magusEntry();
232
+ const commands = [
233
+ cliToolInstall(entry),
234
+ cliToolUpdate(entry, "bun"),
235
+ cliToolUpdate(entry, "npm"),
236
+ cliToolUninstall(entry, "bun"),
237
+ cliToolUninstall(entry, "npm"),
238
+ ];
239
+ for (const c of commands) {
240
+ expect(c).not.toBeNull();
241
+ const args = c!.args as string[];
242
+ const target = args[args.indexOf("-g") + 1] ?? "";
243
+ // `<package>` or `<package>@<tag>`, never the command name.
244
+ expect(target.split("@")[0]).toBe(PKG.name);
245
+ expect(args).not.toContain(COMMAND);
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("pins and probes resolve to the package", () => {
251
+ test("a pinned version installs `<package>@<version>` and checks the command", () => {
252
+ expect(cliToolToBin(COMMAND, PKG.version)).toMatchObject({
253
+ name: COMMAND,
254
+ via: "bun",
255
+ package: PACKAGE,
256
+ check: "magus --version",
257
+ version: PKG.version,
258
+ });
259
+ });
260
+
261
+ test("`latest` carries no version", () => {
262
+ const bin = cliToolToBin(COMMAND, "latest");
263
+ expect(bin).toMatchObject({
264
+ name: COMMAND,
265
+ via: "bun",
266
+ package: PACKAGE,
267
+ check: "magus --version",
268
+ });
269
+ expect(bin.version).toBeUndefined();
270
+ });
271
+
272
+ test("the catalogue spec (the dist-tags probe input) names the package", () => {
273
+ expect(specForCatalogTool(magusEntry(), null, undefined)).toMatchObject({
274
+ name: COMMAND,
275
+ via: "bun",
276
+ package: PACKAGE,
277
+ check: "magus --version",
278
+ });
279
+ });
280
+ });