magus-cli 7.8.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 +16 -11
- 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 +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- 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__/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__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- 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__/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__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- 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-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- 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 +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- 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/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
makePlugin,
|
|
7
|
+
makeWorld,
|
|
8
|
+
removeWorld,
|
|
9
|
+
runDoctor,
|
|
10
|
+
writeExecutable,
|
|
11
|
+
writeJson,
|
|
12
|
+
} from "./helpers";
|
|
13
|
+
|
|
14
|
+
// Spec: AS-BUILT.md §5, doctor, doctor --json, and doctor --fix --yes
|
|
15
|
+
// leave all five Claude Code files byte-identical even when a marketplace
|
|
16
|
+
// lists a plugin whose source directory is missing.
|
|
17
|
+
test("all doctor modes leave Claude Code marketplace state byte-identical", async () => {
|
|
18
|
+
const world = await makeWorld();
|
|
19
|
+
try {
|
|
20
|
+
const claude = join(world.home, ".claude");
|
|
21
|
+
const clone = join(claude, "plugins", "marketplaces", "test");
|
|
22
|
+
const plugin = await makePlugin(
|
|
23
|
+
world,
|
|
24
|
+
"present",
|
|
25
|
+
{
|
|
26
|
+
bin: [
|
|
27
|
+
{
|
|
28
|
+
check: ["present-tool", "--version"],
|
|
29
|
+
install: [{ formula: "present-tool", via: "brew" }],
|
|
30
|
+
name: "present-tool",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
schema: 1,
|
|
34
|
+
},
|
|
35
|
+
join(clone, "plugins"),
|
|
36
|
+
);
|
|
37
|
+
const marketplace = join(clone, ".claude-plugin", "marketplace.json");
|
|
38
|
+
const pluginManifest = join(plugin, ".claude-plugin", "plugin.json");
|
|
39
|
+
const known = join(claude, "plugins", "known_marketplaces.json");
|
|
40
|
+
const installed = join(claude, "plugins", "installed_plugins.json");
|
|
41
|
+
const settings = join(claude, "settings.json");
|
|
42
|
+
await writeJson(marketplace, {
|
|
43
|
+
name: "test",
|
|
44
|
+
plugins: [
|
|
45
|
+
{ name: "present", source: "./plugins/present", version: "1.0.0" },
|
|
46
|
+
{ name: "missing", source: "./plugins/missing", version: "1.0.0" },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
await writeJson(known, {
|
|
50
|
+
test: {
|
|
51
|
+
installLocation: clone,
|
|
52
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
53
|
+
source: { repo: "Example/test", source: "github" },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
await writeJson(installed, {
|
|
57
|
+
plugins: {
|
|
58
|
+
"present@test": [
|
|
59
|
+
{
|
|
60
|
+
installPath: plugin,
|
|
61
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
62
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
63
|
+
scope: "user",
|
|
64
|
+
version: "1.0.0",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
version: 2,
|
|
69
|
+
});
|
|
70
|
+
await writeJson(settings, { enabledPlugins: { "present@test": true } });
|
|
71
|
+
await writeExecutable(join(world.bin, "present-tool"), "exit 0");
|
|
72
|
+
const protectedFiles = [
|
|
73
|
+
marketplace,
|
|
74
|
+
pluginManifest,
|
|
75
|
+
known,
|
|
76
|
+
installed,
|
|
77
|
+
settings,
|
|
78
|
+
];
|
|
79
|
+
const before = await Promise.all(
|
|
80
|
+
protectedFiles.map((path) => readFile(path)),
|
|
81
|
+
);
|
|
82
|
+
for (const args of [[], ["--json"], ["--fix", "--yes"]]) {
|
|
83
|
+
const result = await runDoctor(world, args);
|
|
84
|
+
expect(result.timedOut).toBe(false);
|
|
85
|
+
expect(result.exitCode).not.toBeNull();
|
|
86
|
+
const after = await Promise.all(
|
|
87
|
+
protectedFiles.map((path) => readFile(path)),
|
|
88
|
+
);
|
|
89
|
+
for (const [index, bytes] of after.entries()) {
|
|
90
|
+
expect(bytes.equals(before[index])).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
await removeWorld(world);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseRequires } from "../../../shared/plugin/hooks/lib/requires-probe.js";
|
|
4
|
+
|
|
5
|
+
const digest = "a".repeat(64);
|
|
6
|
+
|
|
7
|
+
function requirement(
|
|
8
|
+
name: string,
|
|
9
|
+
install: Record<string, unknown>[],
|
|
10
|
+
check = [name, "--version"],
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
return { check, install, name };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function manifest(bin: Record<string, unknown>[]): Record<string, unknown> {
|
|
16
|
+
return { requires: { bin, schema: 1 } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function invalidText(value: unknown): string {
|
|
20
|
+
const result = parseRequires(value);
|
|
21
|
+
expect(result.ok).toBe(false);
|
|
22
|
+
if (result.ok) {
|
|
23
|
+
throw new Error("testdata unexpectedly parsed");
|
|
24
|
+
}
|
|
25
|
+
expect(result.kind).toBe("invalid");
|
|
26
|
+
return result.errors.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("requires schema 1", () => {
|
|
30
|
+
// Spec: AS-BUILT.md §1, “brew takes EXACTLY ONE ...” through the
|
|
31
|
+
// renderInstall table define the seven manifest installer kinds.
|
|
32
|
+
test("accepts a complete declaration of every manifest via", () => {
|
|
33
|
+
const value = manifest([
|
|
34
|
+
requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
|
|
35
|
+
requirement("uv", [{ tool: "uv", via: "bootstrap" }]),
|
|
36
|
+
requirement("bun-tool", [
|
|
37
|
+
{ package: "@scope/tool", version: "1.2.3", via: "bun" },
|
|
38
|
+
]),
|
|
39
|
+
requirement("formula-tool", [
|
|
40
|
+
{ formula: "owner/tap/tool@2", via: "brew" },
|
|
41
|
+
]),
|
|
42
|
+
requirement("cask-tool", [{ cask: "visual-tool", via: "brew" }]),
|
|
43
|
+
requirement("apt-tool", [
|
|
44
|
+
{ packages: ["libfoo2", "foo+bar"], via: "apt" },
|
|
45
|
+
]),
|
|
46
|
+
requirement("release-tool", [
|
|
47
|
+
{
|
|
48
|
+
arch: { amd64: "x64", arm64: "aarch64" },
|
|
49
|
+
asset: "tool-{os}-{arch}.tgz",
|
|
50
|
+
binary: "tool.bin",
|
|
51
|
+
repo: "Owner/repo.name",
|
|
52
|
+
sha256: { darwin_amd64: digest },
|
|
53
|
+
tag: "v1.2.3",
|
|
54
|
+
via: "release",
|
|
55
|
+
},
|
|
56
|
+
]),
|
|
57
|
+
requirement("uv-script-tool", [
|
|
58
|
+
{ args: ["--check"], script: "scripts/tool.py", via: "uv-script" },
|
|
59
|
+
]),
|
|
60
|
+
requirement("uv-module-tool", [
|
|
61
|
+
{
|
|
62
|
+
args: ["install", "thing"],
|
|
63
|
+
module: "package.module",
|
|
64
|
+
root: true,
|
|
65
|
+
script: "scripts/tool.py",
|
|
66
|
+
via: "uv-module",
|
|
67
|
+
},
|
|
68
|
+
]),
|
|
69
|
+
]);
|
|
70
|
+
const result = parseRequires(value);
|
|
71
|
+
expect(result.ok).toBe(true);
|
|
72
|
+
if (result.ok) {
|
|
73
|
+
expect(result.requirements).toHaveLength(9);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Spec: AS-BUILT.md §1, “Unknown keys anywhere inside requires (top
|
|
78
|
+
// level, a requirement, an option) ... error naming the key.”
|
|
79
|
+
test("rejects and locates unknown keys at all three schema levels", () => {
|
|
80
|
+
const top = invalidText({
|
|
81
|
+
requires: { bin: [], mysteryTop: true, schema: 1 },
|
|
82
|
+
});
|
|
83
|
+
const req = invalidText(
|
|
84
|
+
manifest([
|
|
85
|
+
{
|
|
86
|
+
...requirement("tool", [{ formula: "tool", via: "brew" }]),
|
|
87
|
+
mysteryRequirement: true,
|
|
88
|
+
},
|
|
89
|
+
]),
|
|
90
|
+
);
|
|
91
|
+
const option = invalidText(
|
|
92
|
+
manifest([
|
|
93
|
+
requirement("tool", [
|
|
94
|
+
{ formula: "tool", mysteryOption: true, via: "brew" },
|
|
95
|
+
]),
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
expect(top).toContain("mysteryTop");
|
|
99
|
+
expect(req).toContain("requires.bin[0]");
|
|
100
|
+
expect(req).toContain("mysteryRequirement");
|
|
101
|
+
expect(option).toContain("requires.bin[0]");
|
|
102
|
+
expect(option).toContain("mysteryOption");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Spec: AS-BUILT.md §1, “schema greater than 1 ... smaller ...;
|
|
106
|
+
// missing/non-number → invalid” and “no requires key ... empty.”
|
|
107
|
+
test("classifies schema versions and treats no requires as empty", () => {
|
|
108
|
+
expect(parseRequires({})).toEqual({ ok: true, requirements: [] });
|
|
109
|
+
for (const schema of [undefined, "1", null]) {
|
|
110
|
+
expect(parseRequires({ requires: { bin: [], schema } })).toMatchObject({
|
|
111
|
+
kind: "invalid",
|
|
112
|
+
ok: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
expect(parseRequires({ requires: { bin: [], schema: 2 } })).toMatchObject({
|
|
116
|
+
kind: "newer-schema",
|
|
117
|
+
ok: false,
|
|
118
|
+
});
|
|
119
|
+
expect(parseRequires({ requires: { bin: [], schema: 0 } })).toMatchObject({
|
|
120
|
+
kind: "older-schema",
|
|
121
|
+
ok: false,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules,
|
|
126
|
+
// “name ... ^[a-z0-9][a-z0-9._-]*$” and check is non-empty argv.
|
|
127
|
+
test("enforces requirement name and check argv grammars", () => {
|
|
128
|
+
const good = parseRequires(
|
|
129
|
+
manifest([
|
|
130
|
+
requirement("a.good_name-2", [{ formula: "good", via: "brew" }]),
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
expect(good.ok).toBe(true);
|
|
134
|
+
for (const name of ["Upper", "-leading", "has space", ""]) {
|
|
135
|
+
expect(invalidText(manifest([requirement(name, [])]))).toContain(
|
|
136
|
+
"requires.bin[0]",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
for (const check of [[], ["tool", 7]]) {
|
|
140
|
+
expect(
|
|
141
|
+
invalidText(manifest([requirement("tool", [], check as string[])])),
|
|
142
|
+
).toContain("requires.bin[0]");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Spec: AS-BUILT.md §1, “brew takes EXACTLY ONE of formula | cask.”
|
|
147
|
+
// Design §7.1 gives the formula grammar.
|
|
148
|
+
test("enforces brew formula grammar and formula XOR cask", () => {
|
|
149
|
+
for (const option of [
|
|
150
|
+
{ via: "brew" },
|
|
151
|
+
{ cask: "tool", formula: "tool", via: "brew" },
|
|
152
|
+
{ formula: "-tool", via: "brew" },
|
|
153
|
+
]) {
|
|
154
|
+
expect(invalidText(manifest([requirement("tool", [option])]))).toContain(
|
|
155
|
+
"requires.bin[0]",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
for (const option of [
|
|
159
|
+
{ formula: "owner/tap/tool+v2@1", via: "brew" },
|
|
160
|
+
{ cask: "visual-tool", via: "brew" },
|
|
161
|
+
]) {
|
|
162
|
+
expect(parseRequires(manifest([requirement("tool", [option])])).ok).toBe(
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules,
|
|
169
|
+
// apt package names match ^[a-z0-9][a-z0-9+.-]+$ and cannot be options.
|
|
170
|
+
test("enforces apt package grammar and a non-empty package list", () => {
|
|
171
|
+
for (const packages of [[], ["-option"], ["UPPER"], ["x"]]) {
|
|
172
|
+
expect(
|
|
173
|
+
invalidText(
|
|
174
|
+
manifest([requirement("tool", [{ packages, via: "apt" }])]),
|
|
175
|
+
),
|
|
176
|
+
).toContain("requires.bin[0]");
|
|
177
|
+
}
|
|
178
|
+
expect(
|
|
179
|
+
parseRequires(
|
|
180
|
+
manifest([
|
|
181
|
+
requirement("tool", [
|
|
182
|
+
{ packages: ["libx2", "x+extra", "x.dev"], via: "apt" },
|
|
183
|
+
]),
|
|
184
|
+
]),
|
|
185
|
+
).ok,
|
|
186
|
+
).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules defines
|
|
190
|
+
// release repo, tag, and binary regexes and a fixed GitHub host.
|
|
191
|
+
test("enforces release repo, tag, and binary grammars", () => {
|
|
192
|
+
const base = {
|
|
193
|
+
asset: "tool-{os}-{arch}",
|
|
194
|
+
binary: "tool",
|
|
195
|
+
repo: "Owner/repo",
|
|
196
|
+
sha256: { linux_amd64: digest },
|
|
197
|
+
tag: "v1.0",
|
|
198
|
+
via: "release",
|
|
199
|
+
};
|
|
200
|
+
for (const patch of [
|
|
201
|
+
{ repo: "one-part" },
|
|
202
|
+
{ repo: "Owner/repo/extra" },
|
|
203
|
+
{ tag: "bad tag" },
|
|
204
|
+
{ tag: "-leading" },
|
|
205
|
+
{ binary: "bad/path" },
|
|
206
|
+
{ binary: "-leading" },
|
|
207
|
+
]) {
|
|
208
|
+
expect(
|
|
209
|
+
invalidText(manifest([requirement("tool", [{ ...base, ...patch }])])),
|
|
210
|
+
).toContain("requires.bin[0]");
|
|
211
|
+
}
|
|
212
|
+
expect(parseRequires(manifest([requirement("tool", [base])])).ok).toBe(
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Spec: AS-BUILT.md §1 says only {os}/{arch} are asset substitutions,
|
|
218
|
+
// tar.gz/tgz are archives, and the listed other archive kinds are refused.
|
|
219
|
+
test("accepts bare and tar-gzip assets but refuses unsafe templates and archives", () => {
|
|
220
|
+
const option = {
|
|
221
|
+
binary: "tool",
|
|
222
|
+
repo: "Owner/repo",
|
|
223
|
+
sha256: { linux_amd64: digest },
|
|
224
|
+
tag: "v1",
|
|
225
|
+
via: "release",
|
|
226
|
+
};
|
|
227
|
+
for (const asset of [
|
|
228
|
+
"tool-{os}-{arch}",
|
|
229
|
+
"tool-{os}-{arch}.tar.gz",
|
|
230
|
+
"tool-{os}-{arch}.tgz",
|
|
231
|
+
]) {
|
|
232
|
+
expect(
|
|
233
|
+
parseRequires(manifest([requirement("tool", [{ ...option, asset }])]))
|
|
234
|
+
.ok,
|
|
235
|
+
).toBe(true);
|
|
236
|
+
}
|
|
237
|
+
for (const asset of [
|
|
238
|
+
"tool-{platform}",
|
|
239
|
+
"tool.zip",
|
|
240
|
+
"tool.tar",
|
|
241
|
+
"tool.tar.xz",
|
|
242
|
+
"tool.txz",
|
|
243
|
+
"tool.tar.bz2",
|
|
244
|
+
"tool.tbz",
|
|
245
|
+
"tool.tbz2",
|
|
246
|
+
"tool.gz",
|
|
247
|
+
"tool.xz",
|
|
248
|
+
"tool.bz2",
|
|
249
|
+
"tool.7z",
|
|
250
|
+
]) {
|
|
251
|
+
expect(
|
|
252
|
+
invalidText(manifest([requirement("tool", [{ ...option, asset }])])),
|
|
253
|
+
).toContain("requires.bin[0]");
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Spec: AS-BUILT.md §1 limits arch keys and sha256 platform keys, and
|
|
258
|
+
// requires each digest to be 64 lowercase hexadecimal characters.
|
|
259
|
+
test("validates release arch maps and pinned digests", () => {
|
|
260
|
+
const base = {
|
|
261
|
+
asset: "tool-{os}-{arch}",
|
|
262
|
+
binary: "tool",
|
|
263
|
+
repo: "Owner/repo",
|
|
264
|
+
tag: "v1",
|
|
265
|
+
via: "release",
|
|
266
|
+
};
|
|
267
|
+
expect(
|
|
268
|
+
parseRequires(
|
|
269
|
+
manifest([
|
|
270
|
+
requirement("tool", [
|
|
271
|
+
{
|
|
272
|
+
...base,
|
|
273
|
+
arch: { amd64: "x64", arm64: "aarch64" },
|
|
274
|
+
sha256: { darwin_arm64: digest },
|
|
275
|
+
},
|
|
276
|
+
]),
|
|
277
|
+
]),
|
|
278
|
+
).ok,
|
|
279
|
+
).toBe(true);
|
|
280
|
+
for (const option of [
|
|
281
|
+
{ ...base, arch: { riscv64: "riscv" }, sha256: {} },
|
|
282
|
+
{ ...base, sha256: { windows_amd64: digest } },
|
|
283
|
+
{ ...base, sha256: { linux_amd64: "A".repeat(64) } },
|
|
284
|
+
{ ...base, sha256: { linux_amd64: "a".repeat(63) } },
|
|
285
|
+
]) {
|
|
286
|
+
expect(invalidText(manifest([requirement("tool", [option])]))).toContain(
|
|
287
|
+
"requires.bin[0]",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Spec: arch-7.1-7.2-schema-probe-hook.md §7.1 Schema Rules says uv
|
|
293
|
+
// scripts are relative and inside the root, and defines the module regex.
|
|
294
|
+
test("enforces uv script paths and module names", () => {
|
|
295
|
+
const uv = requirement("uv", [{ tool: "uv", via: "bootstrap" }]);
|
|
296
|
+
for (const option of [
|
|
297
|
+
{ args: [], script: "/abs/tool.py", via: "uv-script" },
|
|
298
|
+
{ args: [], script: "../tool.py", via: "uv-script" },
|
|
299
|
+
{
|
|
300
|
+
args: [],
|
|
301
|
+
module: "bad-module",
|
|
302
|
+
script: "scripts/tool.py",
|
|
303
|
+
via: "uv-module",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
args: [],
|
|
307
|
+
module: "9module",
|
|
308
|
+
script: "scripts/tool.py",
|
|
309
|
+
via: "uv-module",
|
|
310
|
+
},
|
|
311
|
+
]) {
|
|
312
|
+
expect(
|
|
313
|
+
invalidText(manifest([uv, requirement("tool", [option])])),
|
|
314
|
+
).toContain("requires.bin[1]");
|
|
315
|
+
}
|
|
316
|
+
expect(
|
|
317
|
+
parseRequires(
|
|
318
|
+
manifest([
|
|
319
|
+
uv,
|
|
320
|
+
requirement("tool", [
|
|
321
|
+
{
|
|
322
|
+
args: ["--ok"],
|
|
323
|
+
module: "good_module.child2",
|
|
324
|
+
script: "scripts/tool.py",
|
|
325
|
+
via: "uv-module",
|
|
326
|
+
},
|
|
327
|
+
]),
|
|
328
|
+
]),
|
|
329
|
+
).ok,
|
|
330
|
+
).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Spec: AS-BUILT.md §1, bun and both uv options need their toolchain
|
|
334
|
+
// declared beside them in the same manifest.
|
|
335
|
+
test("requires bun and uv toolchains to be declared beside consumers", () => {
|
|
336
|
+
for (const option of [
|
|
337
|
+
{ package: "pkg", via: "bun" },
|
|
338
|
+
{ args: [], script: "tool.py", via: "uv-script" },
|
|
339
|
+
{
|
|
340
|
+
args: [],
|
|
341
|
+
module: "tool",
|
|
342
|
+
script: "tool.py",
|
|
343
|
+
via: "uv-module",
|
|
344
|
+
},
|
|
345
|
+
]) {
|
|
346
|
+
const error = invalidText(manifest([requirement("consumer", [option])]));
|
|
347
|
+
expect(error).toMatch(/bun|uv/);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Spec: AS-BUILT.md §1, “bootstrap.tool must be a key ... AND equal
|
|
352
|
+
// the requirement's name”; a URL is an unknown key.
|
|
353
|
+
test("restricts bootstrap to its self-named built-in tool", () => {
|
|
354
|
+
expect(
|
|
355
|
+
parseRequires(
|
|
356
|
+
manifest([requirement("bun", [{ tool: "bun", via: "bootstrap" }])]),
|
|
357
|
+
).ok,
|
|
358
|
+
).toBe(true);
|
|
359
|
+
for (const option of [
|
|
360
|
+
{ tool: "unknown", via: "bootstrap" },
|
|
361
|
+
{ tool: "uv", via: "bootstrap" },
|
|
362
|
+
{
|
|
363
|
+
tool: "bun",
|
|
364
|
+
url: "https://example.invalid/install",
|
|
365
|
+
via: "bootstrap",
|
|
366
|
+
},
|
|
367
|
+
]) {
|
|
368
|
+
expect(invalidText(manifest([requirement("bun", [option])]))).toContain(
|
|
369
|
+
"requires.bin[0]",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Spec: AS-BUILT.md §1, profile-only npm, pip, and go are refused in
|
|
375
|
+
// plugin.json; arch-2-decisions.md D2 says the installer set is closed.
|
|
376
|
+
test("refuses every profile-only installer kind", () => {
|
|
377
|
+
for (const via of ["npm", "pip", "go"]) {
|
|
378
|
+
const error = invalidText(
|
|
379
|
+
manifest([requirement("tool", [{ package: "tool", via }])]),
|
|
380
|
+
);
|
|
381
|
+
expect(error).toContain(via);
|
|
382
|
+
expect(error).toContain("not allowed");
|
|
383
|
+
}
|
|
384
|
+
expect(
|
|
385
|
+
invalidText(
|
|
386
|
+
manifest([requirement("tool", [{ package: "tool", via: "cargo" }])]),
|
|
387
|
+
),
|
|
388
|
+
).toContain("cargo");
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
* C5 stepFailureReport: settings file that does not parse, fallback fix → "C5"
|
|
18
18
|
* C6 no internal word or absolute binary path in headline/reason → "C6"
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Failures are built in the shape `runArgv` resolves with (`RunResult`):
|
|
21
|
+
* the exit code, whether the timeout killed it, the combined output tail, and
|
|
22
|
+
* for a missing executable a `spawn <cmd> ENOENT` spawn error. Nothing is
|
|
23
|
+
* spawned.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { describe, expect, test } from "bun:test";
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from "../ui/adapters/startupReports.js";
|
|
41
41
|
import type { FailureReport } from "../ui/state/types.js";
|
|
42
42
|
import type { Command } from "../utils/command-utils.js";
|
|
43
|
+
import type { RunResult } from "../utils/run.js";
|
|
43
44
|
|
|
44
45
|
/** The installer timeout the spec names: 60 seconds. */
|
|
45
46
|
const TIMEOUT_MS = 60_000;
|
|
@@ -73,42 +74,43 @@ interface ExecErrorShape {
|
|
|
73
74
|
code?: number | string | null;
|
|
74
75
|
killed?: boolean;
|
|
75
76
|
signal?: string | null;
|
|
76
|
-
/** Extra lines appended to the "Command failed:" message. */
|
|
77
|
-
messageTail?: string;
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
/** What
|
|
81
|
-
function execError(o: ExecErrorShape):
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
86
|
-
return Object.assign(err, {
|
|
87
|
-
cmd: cmdLine,
|
|
88
|
-
code: o.code === undefined ? 1 : o.code,
|
|
89
|
-
killed: o.killed ?? false,
|
|
79
|
+
/** What `runArgv` resolves with when the command ran and failed. */
|
|
80
|
+
function execError(o: ExecErrorShape): RunResult {
|
|
81
|
+
return {
|
|
82
|
+
exitCode:
|
|
83
|
+
typeof o.code === "number" ? o.code : o.code === undefined ? 1 : null,
|
|
90
84
|
signal: o.signal ?? null,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
timedOut: o.killed ?? false,
|
|
86
|
+
outputTail: [o.stdout ?? "", o.stderr ?? ""].filter(Boolean).join("\n"),
|
|
87
|
+
durationMs: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What `runArgv` resolves with when the executable does not exist. */
|
|
92
|
+
function enoent(command: Command): RunResult {
|
|
93
|
+
return {
|
|
94
|
+
exitCode: null,
|
|
95
|
+
signal: null,
|
|
96
|
+
timedOut: false,
|
|
97
|
+
spawnError: `spawn ${command.cmd} ENOENT`,
|
|
98
|
+
outputTail: "",
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
};
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
/** What
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
return Object.assign(err, {
|
|
103
|
+
/** What a spawn throws when the executable does not exist (a step's error). */
|
|
104
|
+
function enoentError(command: Command): Error {
|
|
105
|
+
return Object.assign(new Error(`spawn ${command.cmd} ENOENT`), {
|
|
100
106
|
code: "ENOENT",
|
|
101
|
-
errno: -2,
|
|
102
107
|
syscall: `spawn ${command.cmd}`,
|
|
103
108
|
path: command.cmd,
|
|
104
|
-
spawnargs: [...command.args],
|
|
105
|
-
stdout: "",
|
|
106
|
-
stderr: "",
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
/** What `
|
|
111
|
-
function timedOut(command: Command, partialStderr = ""):
|
|
112
|
+
/** What `runArgv` resolves with when its timeout killed the child. */
|
|
113
|
+
function timedOut(command: Command, partialStderr = ""): RunResult {
|
|
112
114
|
return execError({
|
|
113
115
|
command,
|
|
114
116
|
code: null,
|
|
@@ -241,13 +243,11 @@ describe("C1 commandFailureReason", () => {
|
|
|
241
243
|
command: BREW_UPGRADE,
|
|
242
244
|
stdout: "==> Fetching downloads for: tmux",
|
|
243
245
|
stderr: 'Error: No available formula with the name "tmux-nope".',
|
|
244
|
-
messageTail: "some other text from the message",
|
|
245
246
|
}),
|
|
246
247
|
TIMEOUT_MS,
|
|
247
248
|
);
|
|
248
249
|
expect(reason).toContain("No available formula");
|
|
249
250
|
expect(reason).not.toContain("Fetching downloads");
|
|
250
|
-
expect(reason).not.toContain("some other text from the message");
|
|
251
251
|
});
|
|
252
252
|
|
|
253
253
|
test("C1: is one line, even when stderr has many", () => {
|
|
@@ -319,25 +319,13 @@ describe("C1 commandFailureReason", () => {
|
|
|
319
319
|
expect(reason.trim().length).toBeGreaterThan(0);
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
-
test("C1:
|
|
322
|
+
test("C1: an installer that printed nothing still yields a non-empty reason", () => {
|
|
323
323
|
const reason = commandFailureReason(
|
|
324
324
|
NPM_INSTALL,
|
|
325
|
-
execError({
|
|
326
|
-
command: NPM_INSTALL,
|
|
327
|
-
messageTail:
|
|
328
|
-
"npm error 404 Not Found - GET https://npm.example.test/x - Not found",
|
|
329
|
-
}),
|
|
325
|
+
execError({ command: NPM_INSTALL }),
|
|
330
326
|
TIMEOUT_MS,
|
|
331
327
|
);
|
|
332
|
-
expect(reason).
|
|
333
|
-
expect(reason.trim().length).toBeGreaterThan(0);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("C1: something that is not an Error still yields a non-empty reason", () => {
|
|
337
|
-
const reason = commandFailureReason(NPM_INSTALL, "boom", TIMEOUT_MS);
|
|
338
|
-
expect(typeof reason).toBe("string");
|
|
339
|
-
expect(reason.trim().length).toBeGreaterThan(0);
|
|
340
|
-
expect(reason).not.toContain("Command failed");
|
|
328
|
+
expect(reason).toBe("npm exited with code 1");
|
|
341
329
|
});
|
|
342
330
|
});
|
|
343
331
|
|
|
@@ -664,7 +652,7 @@ describe("C5 stepFailureReport", () => {
|
|
|
664
652
|
const report = stepFailureReport(
|
|
665
653
|
"Plugins were not refreshed",
|
|
666
654
|
"magus",
|
|
667
|
-
|
|
655
|
+
enoentError({ cmd: "claude", args: ["plugin", "marketplace", "update"] }),
|
|
668
656
|
"Run it yourself: claude plugin marketplace update magus",
|
|
669
657
|
);
|
|
670
658
|
const fixes = fixesOf(report);
|
|
@@ -791,7 +779,7 @@ describe("C6 no internal word in any headline or reason", () => {
|
|
|
791
779
|
stepFailureReport(
|
|
792
780
|
"Plugins were not refreshed",
|
|
793
781
|
"magus",
|
|
794
|
-
|
|
782
|
+
enoentError(ABS_CLAUDE),
|
|
795
783
|
),
|
|
796
784
|
],
|
|
797
785
|
];
|
|
@@ -58,7 +58,6 @@ import {
|
|
|
58
58
|
} from "../ui/adapters/pluginsAdapter.js";
|
|
59
59
|
import {
|
|
60
60
|
activationFailureReport,
|
|
61
|
-
marketplaceRefreshReport,
|
|
62
61
|
profileProblemRow,
|
|
63
62
|
profileProblemsReport,
|
|
64
63
|
staleRepairReport,
|
|
@@ -449,30 +448,6 @@ describe("U4 failed stale-load repairs", () => {
|
|
|
449
448
|
});
|
|
450
449
|
});
|
|
451
450
|
|
|
452
|
-
// ---------------------------------------------------------------------------
|
|
453
|
-
// U5
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
|
|
456
|
-
describe("U5 a failed marketplace refresh", () => {
|
|
457
|
-
test("U5 the row's subject is the marketplace name", () => {
|
|
458
|
-
const r = marketplaceRefreshReport([
|
|
459
|
-
{
|
|
460
|
-
marketplace: "magus-marketing",
|
|
461
|
-
error: new Error("fatal: unable to access remote"),
|
|
462
|
-
},
|
|
463
|
-
]);
|
|
464
|
-
expect(r).not.toBeNull();
|
|
465
|
-
const report = r as FailureReport;
|
|
466
|
-
expect(report.rows).toHaveLength(1);
|
|
467
|
-
expect((report.rows[0] as FailureRow).subject).toBe("magus-marketing");
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
test("U5 nothing failed → no report", () => {
|
|
471
|
-
const r = marketplaceRefreshReport([]);
|
|
472
|
-
expect(r === null || r.rows.length === 0).toBe(true);
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
|
|
476
451
|
// ---------------------------------------------------------------------------
|
|
477
452
|
// U6
|
|
478
453
|
// ---------------------------------------------------------------------------
|