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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +45 -26
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1178 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +896 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +740 -0
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +24 -18
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +320 -54
- package/src/cli/install.ts +55 -71
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +55 -12
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +88 -384
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +35 -37
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +263 -59
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +345 -45
- package/src/services/update-plan.ts +296 -36
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +60 -16
- package/src/ui/App.tsx +81 -171
- package/src/ui/adapters/cliToolsAdapter.ts +252 -12
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +260 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +199 -98
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +315 -259
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/plugin-setup.ts +0 -965
- package/src/services/version-snapshot.ts +0 -189
- package/src/utils/go-module.ts +0 -36
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
mergeBins,
|
|
5
|
+
pinOfBin,
|
|
5
6
|
resolveAllProfiles,
|
|
6
7
|
resolveExtends,
|
|
7
8
|
resolveProfile,
|
|
8
9
|
} from "../services/resolver.js";
|
|
9
10
|
import type {
|
|
10
|
-
|
|
11
|
+
ManagedTool,
|
|
11
12
|
ProfileManifest,
|
|
12
13
|
ProfileManifestEntry,
|
|
13
14
|
} from "../types/index.js";
|
|
15
|
+
import { ownedAsDeclared, selected } from "./fixtures/bins.js";
|
|
14
16
|
|
|
15
|
-
// A binResolver that returns canned
|
|
16
|
-
// resolver tests hermetic (no marketplace cache / filesystem).
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
// A binResolver that returns canned requirements per plugin — keeps the
|
|
18
|
+
// resolver tests hermetic (no marketplace cache / filesystem). A catalogue
|
|
19
|
+
// tool a plugin owns resolves as that plugin declares it today.
|
|
20
|
+
function fakeBinResolver(table: Record<string, ManagedTool[]>) {
|
|
21
|
+
return async (pluginId: string) =>
|
|
22
|
+
(table[pluginId] ?? []).map((t) => selected(t));
|
|
21
23
|
}
|
|
24
|
+
const hermetic = (table: Record<string, ManagedTool[]> = {}) => ({
|
|
25
|
+
binResolver: fakeBinResolver(table),
|
|
26
|
+
ownedResolver: ownedAsDeclared,
|
|
27
|
+
});
|
|
22
28
|
|
|
23
29
|
function manifestOf(
|
|
24
30
|
profiles: Record<string, ProfileManifestEntry>,
|
|
@@ -26,44 +32,60 @@ function manifestOf(
|
|
|
26
32
|
return { version: 2, profiles };
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
describe("
|
|
30
|
-
test("
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
package: "claudish",
|
|
41
|
-
};
|
|
42
|
-
const result = dedupeBins([
|
|
43
|
-
{ bin: claudishPin, source: "profile:x.cliTools" },
|
|
44
|
-
{ bin: claudishLoose, source: "plugin:foo@magus" },
|
|
35
|
+
describe("mergeBins — precedence by order", () => {
|
|
36
|
+
test("a profile pin absorbs a later declaration of the name; it adds only provenance", () => {
|
|
37
|
+
const result = mergeBins([
|
|
38
|
+
{
|
|
39
|
+
bin: selected({ name: "claudish", via: "bun", version: "1.2.0" }),
|
|
40
|
+
source: "profile:x.cliTools",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
bin: selected({ name: "claudish", via: "npm" }),
|
|
44
|
+
source: "plugin:foo@magus",
|
|
45
|
+
},
|
|
45
46
|
]);
|
|
46
47
|
expect(result).toHaveLength(1);
|
|
47
|
-
expect(result[0]!.via).toBe("bun");
|
|
48
|
-
expect(result[0]
|
|
48
|
+
expect(result[0]!.chosen?.via).toBe("bun");
|
|
49
|
+
expect(pinOfBin(result[0]!)).toBe("1.2.0");
|
|
49
50
|
expect(result[0]!.sources).toEqual([
|
|
50
51
|
"profile:x.cliTools",
|
|
51
52
|
"plugin:foo@magus",
|
|
52
53
|
]);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
test("
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
test("identical declarations merge; different plugin declarations of one name stay two", () => {
|
|
57
|
+
const jq = selected({ name: "jq", via: "brew", formula: "jq" });
|
|
58
|
+
const result = mergeBins([
|
|
59
|
+
{ bin: jq, source: "plugin:dev@magus" },
|
|
60
|
+
{ bin: { ...jq }, source: "plugin:code-search@magus" },
|
|
61
|
+
{
|
|
62
|
+
bin: selected({
|
|
63
|
+
name: "jq",
|
|
64
|
+
via: "brew",
|
|
65
|
+
formula: "jq",
|
|
66
|
+
check: "jq -V",
|
|
67
|
+
}),
|
|
68
|
+
source: "plugin:other@elsewhere",
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
expect(result).toHaveLength(2);
|
|
72
|
+
expect(result[0]!.sources).toEqual([
|
|
73
|
+
"plugin:dev@magus",
|
|
74
|
+
"plugin:code-search@magus",
|
|
75
|
+
]);
|
|
76
|
+
expect(result[1]!.sources).toEqual(["plugin:other@elsewhere"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a check that reads the plugin root never merges across plugins", () => {
|
|
80
|
+
const env = {
|
|
81
|
+
...selected({ name: "env", via: "bun" }),
|
|
82
|
+
check: ["${CLAUDE_PLUGIN_ROOT}/x"],
|
|
61
83
|
};
|
|
62
|
-
const result =
|
|
63
|
-
{ bin:
|
|
64
|
-
{ bin:
|
|
84
|
+
const result = mergeBins([
|
|
85
|
+
{ bin: { ...env, root: "/a" }, source: "plugin:a@m" },
|
|
86
|
+
{ bin: { ...env, root: "/b" }, source: "plugin:b@m" },
|
|
65
87
|
]);
|
|
66
|
-
expect(result
|
|
88
|
+
expect(result).toHaveLength(2);
|
|
67
89
|
});
|
|
68
90
|
});
|
|
69
91
|
|
|
@@ -161,8 +183,10 @@ describe("resolveProfile", () => {
|
|
|
161
183
|
},
|
|
162
184
|
});
|
|
163
185
|
|
|
164
|
-
const closure = await resolveProfile(
|
|
165
|
-
|
|
186
|
+
const closure = await resolveProfile(
|
|
187
|
+
manifest,
|
|
188
|
+
"frontend",
|
|
189
|
+
hermetic({
|
|
166
190
|
"terminal@magus": [
|
|
167
191
|
{ name: "tmux", via: "brew", formula: "tmux" },
|
|
168
192
|
// terminal ALSO wants claudish, but the profile pinned it →
|
|
@@ -170,7 +194,7 @@ describe("resolveProfile", () => {
|
|
|
170
194
|
{ name: "claudish", via: "npm", package: "claudish" },
|
|
171
195
|
],
|
|
172
196
|
}),
|
|
173
|
-
|
|
197
|
+
);
|
|
174
198
|
|
|
175
199
|
expect(closure.plugins).toEqual({
|
|
176
200
|
"terminal@magus": "4.1.0",
|
|
@@ -179,12 +203,13 @@ describe("resolveProfile", () => {
|
|
|
179
203
|
expect(closure.env.required).toEqual(["FIGMA_ACCESS_TOKEN"]);
|
|
180
204
|
|
|
181
205
|
const claudish = closure.bins.find((b) => b.name === "claudish")!;
|
|
182
|
-
|
|
183
|
-
expect(claudish.
|
|
206
|
+
// claudish@magus's own declaration (bun), pinned by the profile — not npm.
|
|
207
|
+
expect(claudish.chosen?.via).toBe("bun");
|
|
208
|
+
expect(pinOfBin(claudish)).toBe("1.2.0");
|
|
184
209
|
expect(claudish.sources[0]).toContain("cliTools");
|
|
185
210
|
|
|
186
211
|
const tmux = closure.bins.find((b) => b.name === "tmux")!;
|
|
187
|
-
expect(tmux.via).toBe("brew");
|
|
212
|
+
expect(tmux.chosen?.via).toBe("brew");
|
|
188
213
|
expect(tmux.sources).toContain("plugin:terminal@magus");
|
|
189
214
|
});
|
|
190
215
|
|
|
@@ -213,9 +238,7 @@ describe("resolveAllProfiles — union", () => {
|
|
|
213
238
|
},
|
|
214
239
|
});
|
|
215
240
|
|
|
216
|
-
const union = await resolveAllProfiles(manifest,
|
|
217
|
-
binResolver: fakeBinResolver({}),
|
|
218
|
-
});
|
|
241
|
+
const union = await resolveAllProfiles(manifest, hermetic());
|
|
219
242
|
|
|
220
243
|
expect(union.plugins["dev@magus"]).toBe("2.9.0"); // pin beat latest
|
|
221
244
|
expect(union.plugins["terminal@magus"]).toBe("4.1.0");
|
|
@@ -224,7 +247,9 @@ describe("resolveAllProfiles — union", () => {
|
|
|
224
247
|
"DATABASE_URL",
|
|
225
248
|
"FIGMA_ACCESS_TOKEN",
|
|
226
249
|
]);
|
|
227
|
-
expect(union.bins.find((b) => b.name === "mnemex")?.via).toBe(
|
|
250
|
+
expect(union.bins.find((b) => b.name === "mnemex")?.chosen?.via).toBe(
|
|
251
|
+
"bun",
|
|
252
|
+
);
|
|
228
253
|
expect(union.conflicts).toBeUndefined(); // latest-vs-pin isn't a conflict
|
|
229
254
|
});
|
|
230
255
|
|
|
@@ -260,16 +285,68 @@ describe("resolveAllProfiles — union", () => {
|
|
|
260
285
|
a: { name: "A", cliTools: { claudish: "1.2.0" } },
|
|
261
286
|
b: { name: "B", cliTools: { claudish: "1.5.0" } },
|
|
262
287
|
});
|
|
263
|
-
const union = await resolveAllProfiles(manifest,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
expect(union.bins.find((x) => x.name === "claudish")?.version).toBe(
|
|
267
|
-
"1.5.0",
|
|
268
|
-
);
|
|
288
|
+
const union = await resolveAllProfiles(manifest, hermetic());
|
|
289
|
+
const claudish = union.bins.find((x) => x.name === "claudish");
|
|
290
|
+
expect(claudish && pinOfBin(claudish)).toBe("1.5.0");
|
|
269
291
|
expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
|
|
270
292
|
});
|
|
271
293
|
});
|
|
272
294
|
|
|
295
|
+
describe("a pin on a tool its plugin installs without a version", () => {
|
|
296
|
+
// claudish@magus as it declares claudish off Homebrew: its pinned release.
|
|
297
|
+
const RELEASE = {
|
|
298
|
+
via: "release" as const,
|
|
299
|
+
repo: "MadAppGang/claudish",
|
|
300
|
+
tag: "v10.3.0",
|
|
301
|
+
asset: "claudish-{os}-{arch}",
|
|
302
|
+
binary: "claudish",
|
|
303
|
+
sha256: { linux_amd64: "a".repeat(64) },
|
|
304
|
+
};
|
|
305
|
+
const ownedRelease = async (_pluginId: string, name: string) => ({
|
|
306
|
+
name,
|
|
307
|
+
check: [name, "--version"],
|
|
308
|
+
install: [RELEASE],
|
|
309
|
+
chosen: RELEASE,
|
|
310
|
+
});
|
|
311
|
+
const opts = {
|
|
312
|
+
binResolver: fakeBinResolver({}),
|
|
313
|
+
ownedResolver: ownedRelease,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
test("is kept as refused: the release stays what installs, and no pin is invented", async () => {
|
|
317
|
+
const closure = await resolveProfile(
|
|
318
|
+
manifestOf({ p: { name: "P", cliTools: { claudish: "6.1.0" } } }),
|
|
319
|
+
"p",
|
|
320
|
+
opts,
|
|
321
|
+
);
|
|
322
|
+
const claudish = closure.bins.find((b) => b.name === "claudish");
|
|
323
|
+
expect(claudish?.chosen).toEqual(RELEASE);
|
|
324
|
+
expect(claudish && pinOfBin(claudish)).toBeUndefined();
|
|
325
|
+
expect(claudish?.refusedPins).toEqual(["6.1.0"]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("every profile's refused pin survives the union", async () => {
|
|
329
|
+
const union = await resolveAllProfiles(
|
|
330
|
+
manifestOf({
|
|
331
|
+
a: { name: "A", cliTools: { claudish: "6.1.0" } },
|
|
332
|
+
b: { name: "B", cliTools: { claudish: "7.0.0" } },
|
|
333
|
+
}),
|
|
334
|
+
opts,
|
|
335
|
+
);
|
|
336
|
+
const claudish = union.bins.find((b) => b.name === "claudish");
|
|
337
|
+
expect(claudish?.refusedPins).toEqual(["6.1.0", "7.0.0"]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("latest is not a pin, so nothing is refused", async () => {
|
|
341
|
+
const closure = await resolveProfile(
|
|
342
|
+
manifestOf({ p: { name: "P", cliTools: { claudish: "latest" } } }),
|
|
343
|
+
"p",
|
|
344
|
+
opts,
|
|
345
|
+
);
|
|
346
|
+
expect(closure.bins[0]?.refusedPins).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
273
350
|
describe("marketplaces derived from plugin ids", () => {
|
|
274
351
|
const noBins = { binResolver: fakeBinResolver({}) };
|
|
275
352
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runArgv` — the one runner: argv only, output captured and tailed, never
|
|
3
|
+
* written to the terminal, bounded by SIGTERM then SIGKILL.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
OUTPUT_TAIL_BYTES,
|
|
9
|
+
runArgv,
|
|
10
|
+
runFailureReason,
|
|
11
|
+
succeeded,
|
|
12
|
+
} from "../utils/run.js";
|
|
13
|
+
|
|
14
|
+
const sh = (script: string) => ({ cmd: "/bin/sh", args: ["-c", script] });
|
|
15
|
+
|
|
16
|
+
describe("runArgv", () => {
|
|
17
|
+
let out: ReturnType<typeof spyOn>;
|
|
18
|
+
let err: ReturnType<typeof spyOn>;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
out = spyOn(process.stdout, "write");
|
|
21
|
+
err = spyOn(process.stderr, "write");
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
out.mockRestore();
|
|
25
|
+
err.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("captures stdout and stderr together, ANSI stripped, and never writes to the terminal", async () => {
|
|
29
|
+
const chunks: string[] = [];
|
|
30
|
+
const r = await runArgv(
|
|
31
|
+
sh("printf 'to-out\\n'; printf '\\033[31mto-err\\033[0m\\n' >&2"),
|
|
32
|
+
{ timeoutMs: 5_000, onOutput: (c) => chunks.push(c) },
|
|
33
|
+
);
|
|
34
|
+
expect(r.exitCode).toBe(0);
|
|
35
|
+
expect(succeeded(r)).toBe(true);
|
|
36
|
+
expect(r.outputTail).toContain("to-out");
|
|
37
|
+
expect(r.outputTail).toContain("to-err");
|
|
38
|
+
expect(r.outputTail).not.toContain("\u001b[");
|
|
39
|
+
expect(chunks.join("")).toContain("to-out");
|
|
40
|
+
expect(out).not.toHaveBeenCalled();
|
|
41
|
+
expect(err).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("keeps only the last 16 KiB of output", async () => {
|
|
45
|
+
const r = await runArgv(
|
|
46
|
+
sh(
|
|
47
|
+
"i=0; while [ $i -lt 2000 ]; do echo line-$i-xxxxxxxxxxxxxxxx; i=$((i+1)); done",
|
|
48
|
+
),
|
|
49
|
+
{ timeoutMs: 10_000 },
|
|
50
|
+
);
|
|
51
|
+
expect(Buffer.byteLength(r.outputTail)).toBeLessThanOrEqual(
|
|
52
|
+
OUTPUT_TAIL_BYTES,
|
|
53
|
+
);
|
|
54
|
+
expect(r.outputTail).toContain("line-1999-");
|
|
55
|
+
expect(r.outputTail).not.toContain("line-0-");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("an argument is one argument: nothing reaches a shell", async () => {
|
|
59
|
+
const r = await runArgv(
|
|
60
|
+
{ cmd: "/bin/echo", args: ["a b; echo injected"] },
|
|
61
|
+
{ timeoutMs: 5_000 },
|
|
62
|
+
);
|
|
63
|
+
expect(r.outputTail.trim()).toBe("a b; echo injected");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("stdin is not the terminal", async () => {
|
|
67
|
+
const r = await runArgv(sh("cat; echo done"), { timeoutMs: 5_000 });
|
|
68
|
+
expect(r.outputTail.trim()).toBe("done");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a missing executable is a spawn error, not a throw", async () => {
|
|
72
|
+
const r = await runArgv(
|
|
73
|
+
{ cmd: "/nonexistent/tool", args: [] },
|
|
74
|
+
{ timeoutMs: 5_000 },
|
|
75
|
+
);
|
|
76
|
+
expect(r.exitCode).toBeNull();
|
|
77
|
+
expect(r.spawnError).toContain("ENOENT");
|
|
78
|
+
expect(succeeded(r)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("over the limit: SIGTERM, then SIGKILL once the grace runs out", async () => {
|
|
82
|
+
const started = Date.now();
|
|
83
|
+
const r = await runArgv(sh("trap '' TERM; while :; do sleep 0.05; done"), {
|
|
84
|
+
timeoutMs: 200,
|
|
85
|
+
killGraceMs: 300,
|
|
86
|
+
});
|
|
87
|
+
expect(r.timedOut).toBe(true);
|
|
88
|
+
expect(r.signal).toBe("SIGKILL");
|
|
89
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(450);
|
|
90
|
+
expect(runFailureReason(r)).toStartWith("timed out");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a child that honours SIGTERM ends there", async () => {
|
|
94
|
+
const r = await runArgv(sh("exec sleep 10"), { timeoutMs: 200 });
|
|
95
|
+
expect(r.timedOut).toBe(true);
|
|
96
|
+
expect(r.signal).toBe("SIGTERM");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the failure reason is the installer's last line", async () => {
|
|
100
|
+
const r = await runArgv(
|
|
101
|
+
sh("echo progress; echo 'error: no such formula' >&2; exit 3"),
|
|
102
|
+
{
|
|
103
|
+
timeoutMs: 5_000,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
expect(runFailureReason(r)).toBe("exit 3: error: no such formula");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -303,7 +303,9 @@ describe("repairStaleLoad — the backup", () => {
|
|
|
303
303
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
|
304
304
|
|
|
305
305
|
expect(result.ok).toBe(false);
|
|
306
|
-
expect(result.error).
|
|
306
|
+
expect(result.error).toBe(
|
|
307
|
+
"could not save a backup before changing anything, so nothing was changed",
|
|
308
|
+
);
|
|
307
309
|
expect(result.rewritten).toBe(0);
|
|
308
310
|
expect(result.backup).toBeNull();
|
|
309
311
|
expect(await fs.readFile(registryFile, "utf8")).toBe(before);
|
|
@@ -328,7 +330,7 @@ describe("repairStaleLoad — refusals", () => {
|
|
|
328
330
|
const result = await repairStaleLoad(PLUGIN, "0.7.0", here, root);
|
|
329
331
|
|
|
330
332
|
expect(result.ok).toBe(false);
|
|
331
|
-
expect(result.error).
|
|
333
|
+
expect(result.error).toBe("v0.7.0 is not downloaded — run magus install");
|
|
332
334
|
expect(result.backup).toBeNull();
|
|
333
335
|
expect(await fs.readFile(registryFile, "utf8")).toBe(before);
|
|
334
336
|
expect(await backups()).toEqual([]);
|
|
@@ -340,7 +342,7 @@ describe("repairStaleLoad — refusals", () => {
|
|
|
340
342
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
|
341
343
|
|
|
342
344
|
expect(result.ok).toBe(false);
|
|
343
|
-
expect(result.error).
|
|
345
|
+
expect(result.error).toBe("it is not installed on this machine");
|
|
344
346
|
});
|
|
345
347
|
|
|
346
348
|
test("a string that is not a plugin id is refused", async () => {
|
|
@@ -360,7 +362,9 @@ describe("repairStaleLoad — refusals", () => {
|
|
|
360
362
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
|
361
363
|
|
|
362
364
|
expect(result.ok).toBe(false);
|
|
363
|
-
expect(result.error).
|
|
365
|
+
expect(result.error).toBe(
|
|
366
|
+
"Claude Code's list of installed plugins cannot be read, so nothing was changed — run magus doctor",
|
|
367
|
+
);
|
|
364
368
|
expect(await fs.readFile(registryFile, "utf8")).toBe("{ not json");
|
|
365
369
|
});
|
|
366
370
|
});
|
|
@@ -400,8 +404,9 @@ describe("repairStaleLoad — scope reach", () => {
|
|
|
400
404
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
|
401
405
|
|
|
402
406
|
expect(result.ok).toBe(false);
|
|
403
|
-
expect(result.error).
|
|
404
|
-
|
|
407
|
+
expect(result.error).toBe(
|
|
408
|
+
"Claude Code loads the USER install of it here — update it at USER scope",
|
|
409
|
+
);
|
|
405
410
|
expect(result.rewritten).toBe(0);
|
|
406
411
|
expect(await fs.readFile(registryFile, "utf8")).toBe(before);
|
|
407
412
|
expect(await backups()).toEqual([]);
|
|
@@ -457,8 +462,9 @@ describe("repairStaleLoad — scope reach", () => {
|
|
|
457
462
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
|
458
463
|
|
|
459
464
|
expect(result.ok).toBe(false);
|
|
460
|
-
expect(result.error).
|
|
461
|
-
|
|
465
|
+
expect(result.error).toBe(
|
|
466
|
+
"Claude Code loads the install your administrator manages here — ask them to update it",
|
|
467
|
+
);
|
|
462
468
|
});
|
|
463
469
|
|
|
464
470
|
test("a user-scope row that is not the winner stays where it is", async () => {
|
|
@@ -485,13 +491,11 @@ describe("repairStaleLoad — scope reach", () => {
|
|
|
485
491
|
expect(after[1].installPath).toBe(cacheFor("0.4.0"));
|
|
486
492
|
});
|
|
487
493
|
|
|
488
|
-
test("a rootless row from another directory
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
// repository, so the repair reports that it could not fix this rather than
|
|
494
|
-
// editing a stranger's row.
|
|
494
|
+
test("a rootless row from another directory neither competes nor is rewritten", async () => {
|
|
495
|
+
// Claude Code 2.1.284 matches nothing by root in a folder that is in no
|
|
496
|
+
// repository, so the other folder's older row does not load here and this
|
|
497
|
+
// folder's own row does. Nothing to repair, and the stranger's row is left
|
|
498
|
+
// exactly as it was.
|
|
495
499
|
const looseHere = path.join(base, "loose-here");
|
|
496
500
|
const looseOther = path.join(base, "loose-other");
|
|
497
501
|
await fs.ensureDir(looseHere);
|
|
@@ -507,13 +511,13 @@ describe("repairStaleLoad — scope reach", () => {
|
|
|
507
511
|
looseHere,
|
|
508
512
|
repoRootForPath(looseHere),
|
|
509
513
|
),
|
|
510
|
-
).toBe(
|
|
514
|
+
).toBe(false);
|
|
511
515
|
|
|
512
516
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", looseHere, root);
|
|
513
517
|
|
|
514
518
|
expect(result.rewritten).toBe(0);
|
|
515
|
-
expect(result.ok).toBe(
|
|
516
|
-
expect(result.
|
|
519
|
+
expect(result.ok).toBe(true);
|
|
520
|
+
expect(result.loaded).toBe("0.5.0");
|
|
517
521
|
expect((await readRegistry()).plugins[PLUGIN][0].version).toBe("0.3.0");
|
|
518
522
|
});
|
|
519
523
|
});
|
|
@@ -557,7 +561,9 @@ describe("repairStaleLoad — a concurrent write is refused, not clobbered", ()
|
|
|
557
561
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
|
558
562
|
|
|
559
563
|
expect(result.ok).toBe(false);
|
|
560
|
-
expect(result.error).
|
|
564
|
+
expect(result.error).toBe(
|
|
565
|
+
"another Claude Code session changed its plugins at the same moment, so nothing was changed — run it again",
|
|
566
|
+
);
|
|
561
567
|
expect(result.rewritten).toBe(0);
|
|
562
568
|
} finally {
|
|
563
569
|
spy.mockRestore();
|
|
@@ -694,7 +700,9 @@ describe("repairStaleLoad — the post-write assertion", () => {
|
|
|
694
700
|
expect(result.rewritten).toBe(1);
|
|
695
701
|
expect(result.loaded).toBe("0.9.0");
|
|
696
702
|
expect(result.ok).toBe(false);
|
|
697
|
-
expect(result.error).toContain(
|
|
703
|
+
expect(result.error).toContain(
|
|
704
|
+
"Claude Code still loads v0.9.0 in this folder",
|
|
705
|
+
);
|
|
698
706
|
});
|
|
699
707
|
|
|
700
708
|
test("the assertion reads the file from DISK, not the object it mutated", async () => {
|
|
@@ -1147,7 +1155,7 @@ describe("B4: an update brings the loader forward in the same run", () => {
|
|
|
1147
1155
|
expect(result.ok).toBe(1);
|
|
1148
1156
|
expect(result.failed).toEqual([]);
|
|
1149
1157
|
expect(details).toEqual([
|
|
1150
|
-
"project → 0.5.0 · Claude Code now loads v0.5.0
|
|
1158
|
+
"project → 0.5.0 · Claude Code now loads v0.5.0 in this folder",
|
|
1151
1159
|
]);
|
|
1152
1160
|
expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
|
|
1153
1161
|
// Moved forward, nothing removed.
|
|
@@ -1265,7 +1273,7 @@ describe("7.5.2: Claude Code 2.1.281 updates another project's row of the reposi
|
|
|
1265
1273
|
[here, "0.5.0"],
|
|
1266
1274
|
]);
|
|
1267
1275
|
expect(details).toEqual([
|
|
1268
|
-
|
|
1276
|
+
"project → 0.5.0 · Claude Code updated another folder of this repository instead of this one — this folder now loads v0.5.0 too",
|
|
1269
1277
|
]);
|
|
1270
1278
|
expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
|
|
1271
1279
|
});
|
|
@@ -1335,7 +1343,7 @@ describe("7.5.2: Claude Code 2.1.281 updates another project's row of the reposi
|
|
|
1335
1343
|
?.version,
|
|
1336
1344
|
).toBe("0.4.0");
|
|
1337
1345
|
expect(details[0]).toContain(
|
|
1338
|
-
`no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated
|
|
1346
|
+
`no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: v0.6.0 is not downloaded — run magus install)`,
|
|
1339
1347
|
);
|
|
1340
1348
|
});
|
|
1341
1349
|
});
|
|
@@ -1395,7 +1395,7 @@ describe("binPinCommand — moving an installed tool to exactly its pin", () =>
|
|
|
1395
1395
|
test("bun/npm/go carry the version in the package token", () => {
|
|
1396
1396
|
expect(
|
|
1397
1397
|
binPinCommand({ name: "m", via: "bun", version: "1.0.0" }, uv),
|
|
1398
|
-
).toEqual({ cmd: "bun", args: ["
|
|
1398
|
+
).toEqual({ cmd: "bun", args: ["add", "-g", "m@1.0.0"] });
|
|
1399
1399
|
expect(
|
|
1400
1400
|
binPinCommand(
|
|
1401
1401
|
{ name: "t", via: "go", module: "github.com/x/t", version: "v2.0.0" },
|