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
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Test builders for requirements, from the version-managed shape tests read
3
+ * best (`{ name, via, package, version }`), through the one bridge production
4
+ * uses (`managedOption`). Shared so no test hand-builds the `SelectedBin`
5
+ * shape its own way.
6
+ */
7
+
8
+ import { type CliTool, cliTools, isOwnedEntry } from "../../data/cli-tools.js";
9
+ import { ownedCliTool } from "../../services/cli-tool-commands.js";
10
+ import { managedOption } from "../../services/toolchain.js";
11
+ import type {
12
+ ManagedTool,
13
+ ResolvedBin,
14
+ SelectedBin,
15
+ } from "../../types/index.js";
16
+
17
+ /** A selected requirement whose one option (and choice) is `t`'s installer. */
18
+ export function selected(t: ManagedTool, root?: string): SelectedBin {
19
+ const option = managedOption(t);
20
+ return {
21
+ name: t.name,
22
+ check: (t.check ?? `${t.name} --version`).trim().split(/\s+/),
23
+ install: [option],
24
+ chosen: option,
25
+ ...(root ? { root } : {}),
26
+ };
27
+ }
28
+
29
+ /** {@link selected}, with provenance. */
30
+ export function resolved(t: ManagedTool, sources: string[]): ResolvedBin {
31
+ return { ...selected(t), sources };
32
+ }
33
+
34
+ /**
35
+ * A plugin-owned catalogue tool's requirement as its plugin declares it today:
36
+ * a bun install of the package named after the tool.
37
+ */
38
+ export async function ownedAsDeclared(
39
+ _pluginId: string,
40
+ name: string,
41
+ ): Promise<SelectedBin> {
42
+ return selected({ name, via: "bun", package: name });
43
+ }
44
+
45
+ /** The catalogue as CLI tools, owned entries resolved as {@link ownedAsDeclared}. */
46
+ export function testCatalogue(): CliTool[] {
47
+ return cliTools.map((entry) =>
48
+ isOwnedEntry(entry)
49
+ ? (ownedCliTool(
50
+ entry,
51
+ selected({ name: entry.name, via: "bun", package: entry.name }),
52
+ ) as CliTool)
53
+ : entry,
54
+ );
55
+ }
@@ -64,7 +64,6 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
64
64
  test('REQ-12a a problem makes the outcome "failed" with the fixed title and the problem in the message, even when the state reads ok', () => {
65
65
  const result: ProfileStartup = {
66
66
  need: needIn("ok"),
67
- notices: [],
68
67
  problems: ["boom"],
69
68
  };
70
69
 
@@ -79,7 +78,7 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
79
78
 
80
79
  test('REQ-12b no problems and a need still to answer gives "ask" carrying the same need object', () => {
81
80
  const need = needIn("inactive");
82
- const result: ProfileStartup = { need, notices: [], problems: [] };
81
+ const result: ProfileStartup = { need, problems: [] };
83
82
 
84
83
  const outcome = formatUpdateOutcome(result);
85
84
 
@@ -87,34 +86,12 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
87
86
  expect((outcome as { need?: unknown }).need).toBe(need);
88
87
  });
89
88
 
90
- test('REQ-12c no problems, state ok and a notice gives "updated" with one notice line, not the gate\'s working log', () => {
91
- const result: ProfileStartup = {
92
- need: needIn("ok"),
93
- notices: ["absorbed 1 edit"],
94
- problems: [],
95
- };
89
+ test('REQ-12c no problems and state ok gives "updated", and nothing to say about it', () => {
90
+ const result: ProfileStartup = { need: needIn("ok"), problems: [] };
96
91
 
97
92
  const outcome = formatUpdateOutcome(result);
98
93
 
99
- expect(outcome).toEqual({
100
- kind: "updated",
101
- notice: "Profile format updated",
102
- });
103
- });
104
-
105
- test('REQ-12d no problems, state ok and no notices gives the same "updated" notice', () => {
106
- const result: ProfileStartup = {
107
- need: needIn("ok"),
108
- notices: [],
109
- problems: [],
110
- };
111
-
112
- const outcome = formatUpdateOutcome(result);
113
-
114
- expect(outcome).toEqual({
115
- kind: "updated",
116
- notice: "Profile format updated",
117
- });
94
+ expect(outcome).toEqual({ kind: "updated" });
118
95
  });
119
96
  });
120
97
 
@@ -137,7 +114,6 @@ for (const stream of [process.stdin, process.stdout, process.stderr]) {
137
114
 
138
115
  const out = {
139
116
  state: null,
140
- notices: null,
141
117
  problems: null,
142
118
  outcome: null,
143
119
  error: null,
@@ -148,14 +124,13 @@ try {
148
124
  ? await startup.prepareProfileStartup(project, { convert: true })
149
125
  : await startup.prepareProfileStartup(project);
150
126
  out.state = prepared.need.state;
151
- out.notices = prepared.notices;
152
127
  out.problems = Array.isArray(prepared.problems) ? prepared.problems : null;
153
128
  const outcome = startup.formatUpdateOutcome(prepared);
154
129
  out.outcome =
155
130
  outcome.kind === "ask"
156
131
  ? { kind: "ask", needState: outcome.need.state }
157
132
  : outcome.kind === "updated"
158
- ? { kind: "updated", notice: outcome.notice }
133
+ ? { kind: "updated" }
159
134
  : { kind: outcome.kind, title: outcome.title, message: outcome.message };
160
135
  } catch (e) {
161
136
  out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
@@ -166,12 +141,11 @@ process.exit(0);
166
141
 
167
142
  interface StartupRun {
168
143
  state: ProfileStartupState | null;
169
- notices: string[] | null;
170
144
  /** Null when the result carried no `problems` array at all. */
171
145
  problems: string[] | null;
172
146
  outcome:
173
147
  | { kind: "ask"; needState: ProfileStartupState }
174
- | { kind: "updated"; notice: string }
148
+ | { kind: "updated" }
175
149
  | { kind: "failed"; title: string; message: string }
176
150
  | null;
177
151
  error: string | null;
@@ -31,10 +31,7 @@ import {
31
31
  prepareProfileState,
32
32
  } from "../services/profile-state.js";
33
33
  import type { ProfileManifest } from "../types/index.js";
34
- import {
35
- buildGitignoreModal,
36
- startupFixNotices,
37
- } from "../ui/hooks/useGitignoreModal.js";
34
+ import { buildGitignoreModal } from "../ui/hooks/useGitignoreModal.js";
38
35
 
39
36
  const MANIFEST: ProfileManifest = {
40
37
  version: 2,
@@ -155,11 +152,6 @@ describe("the startup fix untracks generated files in place", () => {
155
152
  expect(await fs.readFile(join(project, ".gitignore"), "utf8")).toContain(
156
153
  ".claude/settings.json",
157
154
  );
158
- // Untracking is unfinished until the user commits, so it is a warning.
159
- expect(startupFixNotices(result)).toContainEqual({
160
- text: "Stopped tracking .claude/settings.json — commit the staged removal",
161
- tone: "warning",
162
- });
163
155
  // Asked again on the next open: nothing generated is tracked any more.
164
156
  expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
165
157
  });
@@ -8,6 +8,7 @@ import type {
8
8
  InstalledPluginsRegistry,
9
9
  ResolvedClosure,
10
10
  } from "../types/index.js";
11
+ import { resolved } from "./fixtures/bins.js";
11
12
 
12
13
  function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
13
14
  return {
@@ -91,7 +92,7 @@ describe("summarizeClosure", () => {
91
92
  marketplaces: { magus: { source: "github", repo: "MadAppGang/magus" } },
92
93
  plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
93
94
  bins: [
94
- { name: "claudish", via: "bun", version: "1.2.0", sources: ["x"] },
95
+ resolved({ name: "claudish", via: "bun", version: "1.2.0" }, ["x"]),
95
96
  ],
96
97
  env: { required: ["FIGMA_TOKEN"], optional: [] },
97
98
  });
@@ -101,4 +102,17 @@ describe("summarizeClosure", () => {
101
102
  expect(lines.some((l) => l.includes("claudish@1.2.0"))).toBe(true);
102
103
  expect(lines.some((l) => l.startsWith("env"))).toBe(true);
103
104
  });
105
+
106
+ test("a refused pin is shown with the tool, marked as not applied", () => {
107
+ const bin = {
108
+ ...resolved({ name: "claudish", via: "brew", formula: "claudish" }, [
109
+ "profile:p.cliTools",
110
+ ]),
111
+ refusedPins: ["6.1.0"],
112
+ };
113
+ const lines = summarizeClosure(closure({ bins: [bin] }));
114
+ expect(
115
+ lines.some((l) => l.includes("claudish@6.1.0 (pin not applied)")),
116
+ ).toBe(true);
117
+ });
104
118
  });
@@ -57,7 +57,13 @@ function writePluginJson(dir: string, version: string, pin: string) {
57
57
  fs.mkdirSync(p, { recursive: true });
58
58
  fs.writeFileSync(
59
59
  path.join(p, "plugin.json"),
60
- JSON.stringify({ version, setup: { go: [pin] } }),
60
+ JSON.stringify({
61
+ version,
62
+ requires: {
63
+ schema: 1,
64
+ bin: [{ name: "x", install: [{ via: "release", tag: pin }] }],
65
+ },
66
+ }),
61
67
  );
62
68
  }
63
69
  function readPin(dir: string): { version: string; pin: string } {
@@ -67,7 +73,7 @@ function readPin(dir: string): { version: string; pin: string } {
67
73
  "utf8",
68
74
  ),
69
75
  );
70
- return { version: j.version, pin: j.setup.go[0] };
76
+ return { version: j.version, pin: j.requires.bin[0].install[0].tag };
71
77
  }
72
78
  /** Clone origin into the marketplace dir for `name`, at origin's current HEAD. */
73
79
  function installClone(name: string): string {
@@ -111,7 +117,7 @@ afterEach(() => {
111
117
  });
112
118
 
113
119
  describe("refreshRegisteredMarketplaces — the fix (CLAIM #1)", () => {
114
- it("fast-forwards a stale clone so a newly pinned setup.go becomes visible", async () => {
120
+ it("fast-forwards a stale clone so a newly pinned release tag becomes visible", async () => {
115
121
  const dir = installClone("magus");
116
122
  expect(readPin(dir).pin).toBe("x@latest"); // stale on-disk clone
117
123
  publishV2(); // a release lands upstream
@@ -51,10 +51,9 @@ const roundTrip: AppAction[] = [
51
51
  ];
52
52
 
53
53
  describe("models screen initial state", () => {
54
- test("starts idle, unfiltered, and with no message", () => {
54
+ test("starts idle, unfiltered, and not writing", () => {
55
55
  expect(initialState.models.data.status).toBe("idle");
56
56
  expect(initialState.models.searchQuery).toBe("");
57
- expect(initialState.models.status).toBeNull();
58
57
  expect(initialState.models.isApplying).toBe(false);
59
58
  // The mate slots are not drawn until something says they can be served. Starting
60
59
  // true would put three rows on screen before anything had been asked.
@@ -166,39 +165,6 @@ describe("models data state", () => {
166
165
  });
167
166
  });
168
167
 
169
- describe("models status line", () => {
170
- test("survives the screen unmounting, because it lives in app state", () => {
171
- // `Router` swaps the component type on a tab change, so a message held in
172
- // the screen's own useState is destroyed by 0 → 1 → 0 — and the message is
173
- // the only record of what the last apply did.
174
- const messaged = run([
175
- {
176
- type: "MODELS_STATUS_SET",
177
- status: { text: "Routed opus-lead", tone: "success" },
178
- },
179
- ]);
180
- expect(run(roundTrip, messaged).models.status?.text).toBe(
181
- "Routed opus-lead",
182
- );
183
- });
184
-
185
- test("is replaced by the next message, not appended to", () => {
186
- const state = run([
187
- { type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
188
- { type: "MODELS_STATUS_SET", status: { text: "two", tone: "error" } },
189
- ]);
190
- expect(state.models.status).toEqual({ text: "two", tone: "error" });
191
- });
192
-
193
- test("clearing removes it", () => {
194
- const state = run([
195
- { type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
196
- { type: "MODELS_STATUS_CLEAR" },
197
- ]);
198
- expect(state.models.status).toBeNull();
199
- });
200
- });
201
-
202
168
  describe("apply-in-flight state", () => {
203
169
  test("survives the screen unmounting", () => {
204
170
  // An apply rewrites the profile, re-materializes it and registers a hook.
@@ -297,11 +263,10 @@ describe("ModelsScreen keybindings", () => {
297
263
  expect(src.slice(apply, apply + 120)).toContain("handleApply()");
298
264
  });
299
265
 
300
- test("digits and Tab never reach the screen, so a message survives a tab round trip", () => {
301
- // The status line is cleared only by this screen's own bindings. Every
302
- // digit (0 included) and Tab navigate, and they belong to the global key
303
- // layer: no Models binding may claim one, or the message would be wiped
304
- // on the way OUT of the tab — the loss the state was moved to prevent.
266
+ test("digits and Tab never reach the screen", () => {
267
+ // Every digit (0 included) and Tab navigate, and they belong to the global
268
+ // key layer: a Models binding claiming one would make the tab unleavable
269
+ // by that key.
305
270
  for (const digit of "1234567890") {
306
271
  expect(
307
272
  matchBinding(SCREEN_BINDINGS.models, press(digit)),
@@ -319,6 +284,19 @@ describe("ModelsScreen keybindings", () => {
319
284
  expect(styles).not.toContain("/^[1-9]$/");
320
285
  });
321
286
 
287
+ test("no status line: a failure goes to the failure popup, a success says nothing", async () => {
288
+ // The TUI shows no status lines, notices or success messages; the screen
289
+ // reloads and shows state. The header row carries state only.
290
+ const src = stripComments(await fs.readFile(SCREEN, "utf8"));
291
+ expect(src).not.toContain("notice=");
292
+ expect(src).not.toContain("MODELS_STATUS");
293
+ expect(src).not.toMatch(/\bApplied\b/);
294
+ // Negative control: the failure path is really wired, so the guard is
295
+ // not passing over a screen that reports nothing at all.
296
+ expect(src).toContain("useFailurePopup()");
297
+ expect(src).toContain("stepFailureReport(");
298
+ });
299
+
322
300
  test("screen state is held in the reducer, never in useState", async () => {
323
301
  // The rule the two "survives unmounting" tests above enforce at runtime,
324
302
  // pinned at the source so a new piece of state cannot quietly opt out.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The plugin-dependency code paths never hand anything to a shell.
3
+ *
4
+ * Scoped, deliberately, to the files on the installer path: the one reader of
5
+ * `requires` (and the hook that runs it at session start), the executor, the
6
+ * runner, and the modules that feed them. Package names, check argv and script
7
+ * paths reach these from plugin manifests and profiles — data a marketplace or
8
+ * a teammate wrote — so here a shell is never acceptable. The test makes no
9
+ * claim about the rest of `src/`. A new file on the installer path joins
10
+ * {@link INSTALLER_PATH}.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import path from "node:path";
15
+ import fs from "fs-extra";
16
+
17
+ const SRC_DIR = path.join(import.meta.dir, "..");
18
+
19
+ /** Files on the installer path, relative to `src/`. */
20
+ const INSTALLER_PATH = [
21
+ "shared/plugin/hooks/lib/requires-probe.ts",
22
+ "shared/plugin/hooks/deps-check.ts",
23
+ "services/deps-plan.ts",
24
+ "services/deps-report.ts",
25
+ "services/doctor-bins.ts",
26
+ "utils/run.ts",
27
+ "cli/deps-view.ts",
28
+ "ui/adapters/depsAdapter.ts",
29
+ ];
30
+
31
+ /** Each way a string can reach a shell, as the line that would do it. */
32
+ const SHELL_PATTERNS: Array<[string, RegExp]> = [
33
+ ["a shell spawn option", /\bshell:\s*(true|["'`])/],
34
+ ["exec / execSync (string commands)", /\bexec(Sync)?\s*\(/],
35
+ [
36
+ "a string handed to spawn",
37
+ /(?<!Bun\.)\bspawn(Sync)?\s*\(\s*["'`][^"'`]*\s/,
38
+ ],
39
+ ["a string handed to Bun.spawn", /\bBun\.spawn(Sync)?\s*\(\s*["'`]/],
40
+ ["Bun's shell", /\$`|from\s+["']bun["'].*\$/],
41
+ ];
42
+
43
+ const isComment = (line: string) => {
44
+ const t = line.trim();
45
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
46
+ };
47
+
48
+ function shellUses(text: string): string[] {
49
+ const found: string[] = [];
50
+ text.split("\n").forEach((line, i) => {
51
+ if (isComment(line)) return;
52
+ for (const [what, re] of SHELL_PATTERNS) {
53
+ if (re.test(line)) found.push(`line ${i + 1}: ${what}`);
54
+ }
55
+ });
56
+ return found;
57
+ }
58
+
59
+ describe("the installer path never spawns a shell", () => {
60
+ for (const rel of INSTALLER_PATH) {
61
+ test(rel, async () => {
62
+ const text = await fs.readFile(path.join(SRC_DIR, rel), "utf8");
63
+ expect(shellUses(text)).toEqual([]);
64
+ });
65
+ }
66
+
67
+ test("the scan can fail", () => {
68
+ // Negative controls: each pattern fires on the line it exists for.
69
+ expect(shellUses("spawn(cmd, { shell: true });")).toHaveLength(1);
70
+ expect(shellUses(`spawn(cmd, { shell: "/bin/sh" });`)).toHaveLength(1);
71
+ expect(shellUses("execSync(`brew install ${f}`);")).toHaveLength(1);
72
+ expect(shellUses(`spawn("brew install tmux");`)).toHaveLength(1);
73
+ expect(shellUses(`Bun.spawn("brew install tmux");`)).toHaveLength(1);
74
+ // …and not on argv.
75
+ expect(shellUses(`spawn(exe, args, { stdio: "ignore" });`)).toEqual([]);
76
+ });
77
+ });
@@ -199,7 +199,7 @@ describe("the CLI Tools entry for magus", () => {
199
199
  test("install targets the package", () => {
200
200
  expect(cliToolInstall(magusEntry())).toEqual({
201
201
  cmd: "bun",
202
- args: ["install", "-g", PACKAGE],
202
+ args: ["add", "-g", PACKAGE],
203
203
  });
204
204
  });
205
205
 
@@ -251,10 +251,8 @@ describe("pins and probes resolve to the package", () => {
251
251
  test("a pinned version installs `<package>@<version>` and checks the command", () => {
252
252
  expect(cliToolToBin(COMMAND, PKG.version)).toMatchObject({
253
253
  name: COMMAND,
254
- via: "bun",
255
- package: PACKAGE,
256
- check: "magus --version",
257
- version: PKG.version,
254
+ check: ["magus", "--version"],
255
+ chosen: { via: "bun", package: PACKAGE, version: PKG.version },
258
256
  });
259
257
  });
260
258
 
@@ -262,11 +260,10 @@ describe("pins and probes resolve to the package", () => {
262
260
  const bin = cliToolToBin(COMMAND, "latest");
263
261
  expect(bin).toMatchObject({
264
262
  name: COMMAND,
265
- via: "bun",
266
- package: PACKAGE,
267
- check: "magus --version",
263
+ check: ["magus", "--version"],
264
+ chosen: { via: "bun", package: PACKAGE },
268
265
  });
269
- expect(bin.version).toBeUndefined();
266
+ expect(bin.chosen && "version" in bin.chosen).toBe(false);
270
267
  });
271
268
 
272
269
  test("the catalogue spec (the dist-tags probe input) names the package", () => {