magus-cli 7.7.0 → 7.9.0

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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -1,187 +1,172 @@
1
+ /**
2
+ * plugin-requires locates the plugin.json Claude Code loads and hands its
3
+ * `requires` to requires-probe; it parses nothing itself.
4
+ */
5
+
1
6
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2
8
  import { mkdtemp, rm } from "node:fs/promises";
3
9
  import { tmpdir } from "node:os";
4
10
  import { join } from "node:path";
5
11
  import fs from "fs-extra";
6
12
  import {
7
- parsePluginRequires,
8
13
  readPluginRequires,
14
+ resolvePluginRequires,
15
+ selectBins,
9
16
  } from "../services/plugin-requires.js";
17
+ import type { Requirement } from "../shared/plugin/hooks/lib/requires-probe.js";
10
18
 
11
- describe("parsePluginRequires", () => {
12
- test("parses a valid requires.bin and fills defaults", () => {
13
- const requires = parsePluginRequires({
14
- name: "terminal",
15
- requires: {
16
- bin: [
17
- { name: "tmux", via: "brew", formula: "tmux", check: "tmux -V" },
18
- { name: "tmux-mcp", via: "bun", package: "tmux-mcp" },
19
- ],
20
- },
21
- });
22
- expect(requires.bin).toHaveLength(2);
23
- const tmux = requires.bin!.find((b) => b.name === "tmux")!;
24
- expect(tmux.via).toBe("brew");
25
- expect(tmux.check).toBe("tmux -V");
26
- const mcp = requires.bin!.find((b) => b.name === "tmux-mcp")!;
27
- // check defaulted from the name
28
- expect(mcp.check).toBe("tmux-mcp --version");
29
- });
30
-
31
- test("drops entries with no name or an unknown installer", () => {
32
- const requires = parsePluginRequires({
33
- requires: {
34
- bin: [
35
- { via: "bun" }, // no name
36
- { name: "x", via: "cargo" }, // unknown installer
37
- { name: "ok", via: "npm" },
38
- ],
39
- },
40
- });
41
- expect(requires.bin).toHaveLength(1);
42
- expect(requires.bin![0]!.name).toBe("ok");
43
- });
19
+ const BUN: Requirement = {
20
+ name: "bun",
21
+ check: ["bun", "--version"],
22
+ install: [{ via: "bootstrap", tool: "bun" }],
23
+ };
24
+ const declares = (bin: Requirement[]) => ({
25
+ name: "p",
26
+ requires: { schema: 1, bin },
27
+ });
44
28
 
45
- test("no requires / non-array bin → empty object", () => {
46
- expect(parsePluginRequires({ name: "x" })).toEqual({});
47
- expect(parsePluginRequires({ requires: {} })).toEqual({});
48
- expect(parsePluginRequires({ requires: { bin: "nope" } })).toEqual({});
49
- expect(parsePluginRequires(null)).toEqual({});
50
- });
29
+ let dir: string;
30
+ beforeEach(async () => {
31
+ dir = await fs.realpath(await mkdtemp(join(tmpdir(), "plugreq-")));
32
+ });
33
+ afterEach(async () => {
34
+ await rm(dir, { recursive: true, force: true });
51
35
  });
52
36
 
53
- describe("parsePluginRequires — setup block (the established convention)", () => {
54
- test("terminal's setup: go module + required system tool", () => {
55
- // The real plugins/terminal/plugin.json shape.
56
- const r = parsePluginRequires({
57
- name: "terminal",
58
- setup: {
59
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
60
- required: ["tmux"],
61
- },
37
+ describe("readPluginRequires", () => {
38
+ test("parses .claude-plugin/plugin.json through requires-probe", async () => {
39
+ await fs.outputJson(
40
+ join(dir, ".claude-plugin", "plugin.json"),
41
+ declares([BUN]),
42
+ );
43
+ expect(await readPluginRequires(dir)).toEqual({
44
+ ok: true,
45
+ requirements: [BUN],
62
46
  });
63
- const mcp = r.bin!.find((b) => b.name === "tmux-mcp")!;
64
- expect(mcp.via).toBe("go");
65
- expect(mcp.module).toBe("github.com/MadAppGang/tmux-mcp");
66
- expect(mcp.version).toBe("latest");
67
- const tmux = r.bin!.find((b) => b.name === "tmux")!;
68
- expect(tmux.via).toBe("brew");
69
- expect(tmux.formula).toBe("tmux");
70
47
  });
71
48
 
72
- // Negative control for the /vN fix: basename(base) yields "v2" here, so
73
- // without the go-module helper this test FAILS on `name`.
74
- test("terminal's v2 pin: /v2 is the module's major version, not the binary", () => {
75
- const r = parsePluginRequires({
76
- name: "terminal",
49
+ test("a setup block is read by nothing: without requires the plugin declares nothing", async () => {
50
+ await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
51
+ name: "p",
77
52
  setup: { go: ["github.com/MadAppGang/tmux-mcp/v2@v2.0.0"] },
78
53
  });
79
- expect(r.bin).toEqual([
80
- {
81
- name: "tmux-mcp",
82
- via: "go",
83
- module: "github.com/MadAppGang/tmux-mcp/v2",
84
- version: "v2.0.0",
85
- check: "tmux-mcp --version",
86
- },
87
- ]);
88
- });
89
-
90
- // One manifest per spec: entries are keyed by binary name, so a v1 and a v2
91
- // pin of the same module in ONE setup would (correctly) collapse to one.
92
- test.each([
93
- [
94
- "github.com/MadAppGang/tmux-mcp@v1.7.1",
95
- {
96
- name: "tmux-mcp",
97
- module: "github.com/MadAppGang/tmux-mcp",
98
- version: "v1.7.1",
99
- },
100
- ],
101
- [
102
- "github.com/MadAppGang/tmux-mcp/v2",
103
- {
104
- name: "tmux-mcp",
105
- module: "github.com/MadAppGang/tmux-mcp/v2",
106
- version: undefined,
107
- },
108
- ],
109
- [
110
- "github.com/x/v2tool@v1.0.0",
111
- { name: "v2tool", module: "github.com/x/v2tool", version: "v1.0.0" },
112
- ],
113
- ])("go spec %s → name/module/version", (spec, expected) => {
114
- const r = parsePluginRequires({ setup: { go: [spec] } });
115
- expect(
116
- r.bin!.map((b) => ({
117
- name: b.name,
118
- module: b.module,
119
- version: b.version,
120
- })),
121
- ).toEqual([expected]);
122
- });
123
-
124
- test("browser-use's setup: pip packages", () => {
125
- const r = parsePluginRequires({
126
- name: "browser-use",
127
- setup: { pip: ["browser-use", "mcp"] },
54
+ expect(await readPluginRequires(dir)).toEqual({
55
+ ok: true,
56
+ requirements: [],
128
57
  });
129
- expect(r.bin!.map((b) => b.name).sort()).toEqual(["browser-use", "mcp"]);
130
- expect(r.bin!.every((b) => b.via === "pip")).toBe(true);
131
58
  });
132
59
 
133
- test("bun/npm specs split name@version", () => {
134
- const r = parsePluginRequires({
135
- setup: { bun: ["claudish@1.2.0"], npm: ["@scope/tool@3.0.0"] },
60
+ test("an invalid declaration is reported, never dropped to empty", async () => {
61
+ await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
62
+ name: "p",
63
+ requires: { schema: 1, bin: [{ name: "x", via: "brew" }] },
136
64
  });
137
- const claudish = r.bin!.find((b) => b.name === "claudish")!;
138
- expect(claudish.version).toBe("1.2.0");
139
- const scoped = r.bin!.find((b) => b.name === "tool")!;
140
- expect(scoped.package).toBe("@scope/tool");
141
- expect(scoped.version).toBe("3.0.0");
65
+ const r = await readPluginRequires(dir);
66
+ expect(r?.ok).toBe(false);
142
67
  });
143
68
 
144
- test("explicit requires.bin overrides a setup entry of the same name", () => {
145
- const r = parsePluginRequires({
146
- setup: { required: ["tmux"] }, // → brew
147
- requires: {
148
- bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }],
149
- },
150
- });
151
- const tmux = r.bin!.find((b) => b.name === "tmux")!;
152
- expect(tmux.via).toBe("go"); // requires.bin won
69
+ test("no manifest → null", async () => {
70
+ expect(await readPluginRequires(dir)).toBeNull();
153
71
  });
154
72
  });
155
73
 
156
- describe("readPluginRequires", () => {
157
- let dir: string;
158
- beforeEach(async () => {
159
- dir = await mkdtemp(join(tmpdir(), "plugreq-"));
160
- });
161
- afterEach(async () => {
162
- await rm(dir, { recursive: true, force: true });
163
- });
74
+ describe("resolvePluginRequires reads the copy Claude Code loads", () => {
75
+ test("the installed copy wins over a newer marketplace clone", async () => {
76
+ const root = join(dir, "plugins-root");
77
+ const installed = join(root, "cache", "fixture", "app", "1.0.0");
78
+ const clone = join(dir, "clone", "app");
79
+ await fs.outputJson(
80
+ join(installed, ".claude-plugin", "plugin.json"),
81
+ declares([BUN]),
82
+ );
83
+ await fs.outputJson(join(clone, ".claude-plugin", "plugin.json"), {
84
+ name: "app",
85
+ });
86
+ await fs.outputJson(join(root, "installed_plugins.json"), {
87
+ version: 2,
88
+ plugins: {
89
+ "app@fixture": [
90
+ {
91
+ scope: "project",
92
+ projectPath: dir,
93
+ installPath: installed,
94
+ version: "1.0.0",
95
+ installedAt: "2026-01-01T00:00:00.000Z",
96
+ lastUpdated: "2026-01-01T00:00:00.000Z",
97
+ },
98
+ ],
99
+ },
100
+ });
101
+ const found = await resolvePluginRequires("app@fixture", dir, {
102
+ root,
103
+ clone: async () => clone,
104
+ });
105
+ expect(found?.dir).toBe(installed);
106
+ expect(found?.parse).toEqual({ ok: true, requirements: [BUN] });
164
107
 
165
- test("reads plugin.json at the plugin root", async () => {
166
- await fs.writeJson(join(dir, "plugin.json"), {
167
- name: "p",
168
- requires: { bin: [{ name: "tmux", via: "brew" }] },
108
+ const notInstalled = await resolvePluginRequires("app@fixture", dir, {
109
+ root: join(dir, "no-registry"),
110
+ clone: async () => clone,
169
111
  });
170
- const r = await readPluginRequires(dir);
171
- expect(r.bin?.[0]?.name).toBe("tmux");
112
+ expect(notInstalled?.dir).toBe(clone);
172
113
  });
114
+ });
173
115
 
174
- test("falls back to .claude-plugin/plugin.json", async () => {
175
- await fs.ensureDir(join(dir, ".claude-plugin"));
176
- await fs.writeJson(join(dir, ".claude-plugin", "plugin.json"), {
177
- name: "p",
178
- requires: { bin: [{ name: "mnemex", via: "bun" }] },
179
- });
180
- const r = await readPluginRequires(dir);
181
- expect(r.bin?.[0]?.name).toBe("mnemex");
116
+ describe("selectBins", () => {
117
+ function pathWith(...tools: string[]): string {
118
+ const bin = join(dir, "bin");
119
+ mkdirSync(bin, { recursive: true });
120
+ for (const t of tools) {
121
+ writeFileSync(join(bin, t), "#!/bin/sh\nexit 0\n");
122
+ chmodSync(join(bin, t), 0o755);
123
+ }
124
+ return bin;
125
+ }
126
+
127
+ test("selects each requirement's first usable option, once, and records its root", () => {
128
+ const reqs: Requirement[] = [
129
+ BUN,
130
+ {
131
+ name: "claudish",
132
+ check: ["claudish", "--version"],
133
+ install: [{ via: "bun", package: "claudish", version: "6.0.0" }],
134
+ },
135
+ {
136
+ name: "jq",
137
+ check: ["jq", "--version"],
138
+ install: [{ via: "brew", formula: "jq" }],
139
+ },
140
+ {
141
+ name: "zsh",
142
+ check: ["zsh", "--version"],
143
+ install: [{ via: "apt", packages: ["zsh"] }],
144
+ },
145
+ ];
146
+ const selected = selectBins(reqs, "/plugin", pathWith("brew", "apt-get"));
147
+ expect(selected.map((b) => [b.name, b.chosen, b.root])).toEqual([
148
+ // Bun's installer needs curl and unzip, and this PATH has neither.
149
+ ["bun", null, "/plugin"],
150
+ [
151
+ "claudish",
152
+ { via: "bun", package: "claudish", version: "6.0.0" },
153
+ "/plugin",
154
+ ],
155
+ ["jq", { via: "brew", formula: "jq" }, "/plugin"],
156
+ ["zsh", { via: "apt", packages: ["zsh"] }, "/plugin"],
157
+ ]);
158
+ // The declaration itself is carried unchanged.
159
+ expect(selected[1]?.check).toEqual(["claudish", "--version"]);
182
160
  });
183
161
 
184
- test("missing manifest → empty", async () => {
185
- expect(await readPluginRequires(dir)).toEqual({});
162
+ test("a brew option is not selected without brew", () => {
163
+ const reqs: Requirement[] = [
164
+ {
165
+ name: "jq",
166
+ check: ["jq", "--version"],
167
+ install: [{ via: "brew", formula: "jq" }],
168
+ },
169
+ ];
170
+ expect(selectBins(reqs, undefined, pathWith())[0]?.chosen).toBeNull();
186
171
  });
187
172
  });
@@ -191,17 +191,11 @@ describe("isRelevantInstallation", () => {
191
191
  ).toBe(false);
192
192
  });
193
193
 
194
- test("two rootless paths DO match each other, as the original's compare does", () => {
195
- // `repoRoot(entry.projectPath) === repoRoot(originalCwd)` with both sides
196
- // null is true in the decompiled filter, so a row from any directory in no
197
- // repository competes when Claude Code runs from another such directory —
198
- // a scratch dir, a home directory, a plain non-git project.
199
- //
200
- // The port used to answer false here, which made magus name a
201
- // `loadedVersion` the loader would not pick in any non-git directory. It is
202
- // the loader's answer this module owes, not the more cautious one; what
203
- // magus may WRITE is a separate question, pinned in stale-load-repair.test.ts
204
- // ("a rootless row from another directory is relevant but NOT rewritten").
194
+ test("two rootless paths do NOT match each other (Claude Code 2.1.284)", () => {
195
+ // 2.1.284 guards the root comparison with `cwdRoot !== null`. Before it,
196
+ // two nulls compared equal and every rootless folder shared every other
197
+ // rootless folder's rows. Measured with `--debug-file` in a rootless folder:
198
+ // a row from an existing non-git folder did not load there.
205
199
  const loose = path.join(base, "loose");
206
200
  expect(
207
201
  isRelevantInstallation(
@@ -212,12 +206,57 @@ describe("isRelevantInstallation", () => {
212
206
  loose,
213
207
  repoRootForPath(loose),
214
208
  ),
209
+ ).toBe(false);
210
+ });
211
+
212
+ test("in a rootless folder its own row and USER rows still count", () => {
213
+ const loose = path.join(base, "loose");
214
+ const looseRoot = repoRootForPath(loose);
215
+ expect(looseRoot).toBeNull();
216
+ expect(
217
+ isRelevantInstallation(
218
+ entry({ version: "1.0.0", projectPath: loose }),
219
+ loose,
220
+ looseRoot,
221
+ ),
222
+ ).toBe(true);
223
+ expect(
224
+ isRelevantInstallation(
225
+ entry({ version: "1.0.0", scope: "user", projectPath: undefined }),
226
+ loose,
227
+ looseRoot,
228
+ ),
229
+ ).toBe(true);
230
+ });
231
+
232
+ test("a row's path is compared RAW, as the binary compares it: '/p/a/' is not '/p/a' in a rootless folder", () => {
233
+ // Normalising the row's path made this pair relevant — and in a rootless
234
+ // folder nothing else could, since the root comparison matches nothing —
235
+ // so the port named a row the loader rejects.
236
+ const loose = path.join(base, "loose");
237
+ const looseRoot = repoRootForPath(loose);
238
+ expect(looseRoot).toBeNull();
239
+ expect(
240
+ isRelevantInstallation(
241
+ entry({ version: "1.0.0", projectPath: `${loose}/` }),
242
+ loose,
243
+ looseRoot,
244
+ ),
245
+ ).toBe(false);
246
+ // The caller's directory stands in for the binary's own working
247
+ // directory, which is always absolute with no trailing slash.
248
+ expect(
249
+ isRelevantInstallation(
250
+ entry({ version: "1.0.0", projectPath: loose }),
251
+ `${loose}/`,
252
+ looseRoot,
253
+ ),
215
254
  ).toBe(true);
216
255
  });
217
256
 
218
257
  test("a rootless row is still irrelevant to a directory inside a repository", () => {
219
- // The null-matches-null rule must not leak the other way: `cwdRoot` is a
220
- // real root here, so a rootless row's null root does not equal it.
258
+ // `cwdRoot` is a real root here, so a rootless row's null root does not
259
+ // equal it.
221
260
  expect(
222
261
  isRelevantInstallation(
223
262
  entry({ version: "1.0.0", projectPath: path.join(base, "loose") }),
@@ -140,7 +140,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
140
140
  // match, and it cannot tell a move from a dual publication: claudish ships
141
141
  // to two marketplaces with both copies installed, and the row announced
142
142
  // "moved → magus" about a plugin that had not moved anywhere.
143
- test("an orphan is 'not offered', and never names a destination", () => {
143
+ test("an orphan is deprecated, and never names a destination", () => {
144
144
  const seo = info({
145
145
  id: "seo@magus",
146
146
  version: "1.8.0",
@@ -149,16 +149,15 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
149
149
  projectScope: { installed: true, version: "1.8.0" },
150
150
  });
151
151
  const row = rows([seo]).get(seo.id) as PluginPluginItem;
152
- expect(cliNote([seo], seo.id)).toBe(
153
- "not offered by any configured marketplace",
154
- );
152
+ expect(cliNote([seo], seo.id)).toBe("no longer published");
153
+ // The row's own words, not the note: the verdict and the key that acts.
155
154
  expect(pluginRowText(row).text).toBe(
156
- " v1.8.0 ? not offered by any configured marketplace",
155
+ " v1.8.0 deprecated — press a to remove",
157
156
  );
158
157
  expect(pluginRowText(row).text).not.toMatch(/moved/i);
159
158
  });
160
159
 
161
- test("an orphan from a marketplace we DID read is 'not offered'", () => {
160
+ test("an orphan from a marketplace we DID read is deprecated", () => {
162
161
  const gone = info({
163
162
  id: "gone@magus",
164
163
  version: "2.0.0",
@@ -168,14 +167,12 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
168
167
  });
169
168
  const row = rows([gone]).get(gone.id) as PluginPluginItem;
170
169
  expect(pluginRowText(row).text).toBe(
171
- ` v2.0.0 ? ${cliNote([gone], gone.id)}`,
172
- );
173
- expect(pluginRowText(row).text).toContain(
174
- "not offered by any configured marketplace",
170
+ " v2.0.0 deprecated — press a to remove",
175
171
  );
172
+ expect(row.status?.action).toBe("remove");
176
173
  });
177
174
 
178
- test("the old words are gone: no 'deprecated', no 'moved →'", () => {
175
+ test("the old words are gone: no 'moved →', and no marketplace wording", () => {
179
176
  const seo = info({
180
177
  id: "seo@magus",
181
178
  installedVersion: "1.8.0",
@@ -183,14 +180,14 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
183
180
  projectScope: { installed: true, version: "1.8.0" },
184
181
  });
185
182
  const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
186
- expect(text).not.toContain("deprecated");
183
+ expect(text).not.toMatch(/marketplace/i);
187
184
  expect(text).not.toContain("moved →");
188
185
  });
189
186
  });
190
187
 
191
- describe("pluginRowText — history badges combine with the state", () => {
192
- const updated = (over: Partial<PluginInfo>) =>
193
- info({
188
+ describe("pluginRowText — the current version and the state, no history", () => {
189
+ const row = (over: Partial<PluginInfo>) => {
190
+ const p = info({
194
191
  id: "terminal@magus",
195
192
  version: "3.3.1",
196
193
  installedVersion: "3.0.1",
@@ -198,40 +195,36 @@ describe("pluginRowText — history badges combine with the state", () => {
198
195
  author: { name: "MadAppGang" },
199
196
  ...over,
200
197
  });
198
+ return rows([p]).get(p.id) as PluginPluginItem;
199
+ };
201
200
 
202
- test("'updated' and a pending update are both shown", () => {
203
- // An external update moved 2.9.0 → 3.0.1 while 3.3.1 is already out.
204
- const p = updated({ recentlyUpdatedFrom: "2.9.0" });
205
- const row = rows([p]).get(p.id) as PluginPluginItem;
206
- const { text, segments } = pluginRowText(row);
207
- expect(text).toBe(" v2.9.0 → v3.0.1 updated → v3.3.1 available");
208
- expect(segments.map((s) => s.tone)).toEqual(["success", "warning"]);
201
+ test("a pending update: the installed version, then what is available", () => {
202
+ const { text, segments } = pluginRowText(row({}));
203
+ expect(text).toBe(" v3.0.1 → v3.3.1 available");
204
+ expect(segments.map((s) => s.tone)).toEqual(["muted", "warning"]);
209
205
  });
210
206
 
211
- test("'new' and a pending update are both shown", () => {
212
- const p = updated({ recentlyInstalled: true });
213
- const row = rows([p]).get(p.id) as PluginPluginItem;
214
- expect(pluginRowText(row).text).toBe(" v3.0.1 new → v3.3.1 available");
207
+ test("NEGATIVE CONTROL: no 'updated' or 'new' badge — the row has no memory of earlier versions", () => {
208
+ // The badge rendered "v1.8.0 → v1.7.4 updated → v1.8.0 available": a
209
+ // downgrade called "updated". A row now says nothing about what changed.
210
+ const text = pluginRowText(row({})).text;
211
+ expect(text).not.toContain("updated");
212
+ expect(text).not.toContain(" new");
215
213
  });
216
214
 
217
- test("'new' with no displayable version still says new", () => {
218
- const p = updated({
219
- recentlyInstalled: true,
215
+ test("an unknown version is not printed", () => {
216
+ const r = row({
220
217
  version: "0.0.0",
221
218
  installedVersion: "0.0.0",
222
219
  projectScope: { installed: true, version: "0.0.0" },
223
220
  });
224
- const row = rows([p]).get(p.id) as PluginPluginItem;
225
- expect(pluginRowText(row).text).toBe(" new");
221
+ expect(pluginRowText(r).text).not.toContain("v0.0.0");
226
222
  });
227
223
 
228
224
  test("a current row shows only its version", () => {
229
- const p = updated({
230
- version: "3.0.1",
231
- });
232
- const row = rows([p]).get(p.id) as PluginPluginItem;
233
- expect(row.status?.action).toBe("current");
234
- expect(pluginRowText(row).text).toBe(" v3.0.1");
225
+ const r = row({ version: "3.0.1" });
226
+ expect(r.status?.action).toBe("current");
227
+ expect(pluginRowText(r).text).toBe(" v3.0.1");
235
228
  });
236
229
  });
237
230
 
@@ -141,6 +141,13 @@ describe("the render enables what the profile's plugins depend on", () => {
141
141
  "lib@mkt",
142
142
  "shared@other",
143
143
  ]);
144
+ // Which plugin asked for each — what `magus update` names when a derived
145
+ // dependency is no longer published.
146
+ expect(closure.dependencies?.requiredBy).toEqual({
147
+ "lib@mkt": "app@mkt",
148
+ "core@mkt": "lib@mkt",
149
+ "shared@other": "core@mkt",
150
+ });
144
151
  await start(manifest, "only");
145
152
  expect(await enabled()).toEqual({
146
153
  "app@mkt": true,
@@ -60,7 +60,6 @@ const out = {
60
60
  described: null,
61
61
  options: null,
62
62
  updateFormatValue: startup.UPDATE_FORMAT_VALUE,
63
- notices: null,
64
63
  error: null,
65
64
  };
66
65
  try {
@@ -78,7 +77,6 @@ try {
78
77
  mode === "convert" ? { convert: true } : undefined,
79
78
  );
80
79
  out.need = { state: prepared.need.state };
81
- out.notices = prepared.notices;
82
80
  }
83
81
  } catch (e) {
84
82
  out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
@@ -93,7 +91,6 @@ interface OldFormatRun {
93
91
  described: { title: string; message: string } | null;
94
92
  options: Array<{ label: string; value: string; meta?: string }> | null;
95
93
  updateFormatValue: string;
96
- notices: string[] | null;
97
94
  error: string | null;
98
95
  }
99
96