magus-cli 7.3.1 → 7.4.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/package.json +4 -4
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C15 (requirements R9.7, R9.8).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only. Every fact reaches
|
|
5
|
+
* `probeTools` through a scripted fake `ToolProbeIO`: a small in-memory file
|
|
6
|
+
* system (files, symlinks, realpaths), a PATH table, an `npm root -g` answer
|
|
7
|
+
* and an npm registry that answers dist-tags. Nothing touches the real disk,
|
|
8
|
+
* network or PATH.
|
|
9
|
+
*
|
|
10
|
+
* Fake-IO conventions come from C15's "Observable facts for a fake IO":
|
|
11
|
+
* - `npm root -g` is asked via exec({ cmd: "npm", args: ["root", "-g"] });
|
|
12
|
+
* - the registry is fetchJson(<registry>/-/package/<url-encoded pkg>/dist-tags)
|
|
13
|
+
* answering { latest }; registry https://registry.npmjs.org by default;
|
|
14
|
+
* - a package's version is readJson(<package dir>/package.json);
|
|
15
|
+
* - an exec nobody scripted returns { code: 1, …, missing: true }.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { posix } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
type ToolFacts,
|
|
21
|
+
type ToolProbeIO,
|
|
22
|
+
classifyTool,
|
|
23
|
+
probeTools,
|
|
24
|
+
} from "../../services/tool-probe.js";
|
|
25
|
+
import type { BinRequirement } from "../../types/index.js";
|
|
26
|
+
|
|
27
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
28
|
+
|
|
29
|
+
const CRUSH: BinRequirement = {
|
|
30
|
+
name: "crush",
|
|
31
|
+
via: "npm",
|
|
32
|
+
package: "@charmland/crush",
|
|
33
|
+
};
|
|
34
|
+
const CLAUDE: BinRequirement = {
|
|
35
|
+
name: "claude",
|
|
36
|
+
via: "npm",
|
|
37
|
+
package: "@anthropic-ai/claude-code",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── fake IO ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface World {
|
|
43
|
+
homedir: string;
|
|
44
|
+
env: Record<string, string | undefined>;
|
|
45
|
+
/** name → first executable on PATH, AS FOUND (unresolved). */
|
|
46
|
+
onPath: Record<string, string>;
|
|
47
|
+
/** readlink table (the literal link target, may be relative). */
|
|
48
|
+
links?: Record<string, string>;
|
|
49
|
+
/** realpath table for symlinks. Any other existing path is its own realpath. */
|
|
50
|
+
real?: Record<string, string>;
|
|
51
|
+
/** Existing regular files and their text. */
|
|
52
|
+
files?: Record<string, string>;
|
|
53
|
+
/** Existing directories with no listed file inside. */
|
|
54
|
+
dirs?: string[];
|
|
55
|
+
/** "cmd arg1 arg2" → stdout, exit 0. */
|
|
56
|
+
exec?: Record<string, string>;
|
|
57
|
+
/** Tool basename → stdout for a lone version flag (`--version`, `-v`, …). */
|
|
58
|
+
versionOutput?: Record<string, string>;
|
|
59
|
+
/** package → dist-tags body. */
|
|
60
|
+
distTags?: Record<string, Record<string, string>>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const VERSION_FLAGS = new Set(["--version", "-v", "-V", "version"]);
|
|
64
|
+
|
|
65
|
+
const MISSING = {
|
|
66
|
+
code: 1,
|
|
67
|
+
stdout: "",
|
|
68
|
+
stderr: "",
|
|
69
|
+
timedOut: false,
|
|
70
|
+
missing: true,
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
interface Log {
|
|
74
|
+
execs: string[];
|
|
75
|
+
fetches: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fakeIO(world: World): ToolProbeIO & { log: Log } {
|
|
79
|
+
const log: Log = { execs: [], fetches: [] };
|
|
80
|
+
const norm = (p: string): string | null =>
|
|
81
|
+
p.startsWith("/") ? posix.resolve(p) : null;
|
|
82
|
+
|
|
83
|
+
const existing = new Set<string>();
|
|
84
|
+
const addWithAncestors = (p: string) => {
|
|
85
|
+
for (let d = posix.resolve(p); ; d = posix.dirname(d)) {
|
|
86
|
+
existing.add(d);
|
|
87
|
+
if (d === "/") break;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const f of Object.keys(world.files ?? {})) addWithAncestors(f);
|
|
91
|
+
for (const d of world.dirs ?? []) addWithAncestors(d);
|
|
92
|
+
for (const t of Object.values(world.real ?? {})) addWithAncestors(t);
|
|
93
|
+
for (const p of Object.values(world.onPath)) addWithAncestors(p);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
log,
|
|
97
|
+
env: world.env,
|
|
98
|
+
homedir: world.homedir,
|
|
99
|
+
async exec(command) {
|
|
100
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
101
|
+
log.execs.push(key);
|
|
102
|
+
const scripted = world.exec?.[key];
|
|
103
|
+
if (scripted !== undefined) {
|
|
104
|
+
return {
|
|
105
|
+
code: 0,
|
|
106
|
+
stdout: scripted,
|
|
107
|
+
stderr: "",
|
|
108
|
+
timedOut: false,
|
|
109
|
+
missing: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const version = world.versionOutput?.[posix.basename(command.cmd)];
|
|
113
|
+
if (
|
|
114
|
+
version !== undefined &&
|
|
115
|
+
command.args.length === 1 &&
|
|
116
|
+
VERSION_FLAGS.has(command.args[0] as string)
|
|
117
|
+
) {
|
|
118
|
+
return {
|
|
119
|
+
code: 0,
|
|
120
|
+
stdout: version,
|
|
121
|
+
stderr: "",
|
|
122
|
+
timedOut: false,
|
|
123
|
+
missing: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ...MISSING };
|
|
127
|
+
},
|
|
128
|
+
async fetchJson(url) {
|
|
129
|
+
log.fetches.push(url);
|
|
130
|
+
const base = (world.env.npm_config_registry ?? NPM_REGISTRY).replace(
|
|
131
|
+
/\/+$/,
|
|
132
|
+
"",
|
|
133
|
+
);
|
|
134
|
+
if (!url.startsWith(`${base}/`)) return { ok: false, reason: "no host" };
|
|
135
|
+
const m = url
|
|
136
|
+
.slice(base.length)
|
|
137
|
+
.match(/^\/-\/package\/(.+)\/dist-tags\/?$/);
|
|
138
|
+
if (!m) return { ok: false, reason: "404" };
|
|
139
|
+
let pkg: string;
|
|
140
|
+
try {
|
|
141
|
+
pkg = decodeURIComponent(m[1] as string);
|
|
142
|
+
} catch {
|
|
143
|
+
return { ok: false, reason: "bad name" };
|
|
144
|
+
}
|
|
145
|
+
const body = world.distTags?.[pkg];
|
|
146
|
+
return body ? { ok: true, body } : { ok: false, reason: "404" };
|
|
147
|
+
},
|
|
148
|
+
async readJson(file) {
|
|
149
|
+
const n = norm(file);
|
|
150
|
+
const text = n === null ? undefined : world.files?.[n];
|
|
151
|
+
if (text === undefined) return null;
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(text);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async readText(file) {
|
|
159
|
+
const n = norm(file);
|
|
160
|
+
return n === null ? null : (world.files?.[n] ?? null);
|
|
161
|
+
},
|
|
162
|
+
async readlink(file) {
|
|
163
|
+
const n = norm(file);
|
|
164
|
+
return n === null ? null : (world.links?.[n] ?? null);
|
|
165
|
+
},
|
|
166
|
+
async realpath(file) {
|
|
167
|
+
const n = norm(file);
|
|
168
|
+
if (n === null) return null;
|
|
169
|
+
const r = world.real?.[n];
|
|
170
|
+
if (r !== undefined) return r;
|
|
171
|
+
return existing.has(n) ? n : null;
|
|
172
|
+
},
|
|
173
|
+
async resolveExecutable(name) {
|
|
174
|
+
return world.onPath[name] ?? null;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function probe(
|
|
180
|
+
spec: BinRequirement,
|
|
181
|
+
world: World,
|
|
182
|
+
): Promise<{ facts: ToolFacts; log: Log }> {
|
|
183
|
+
const io = fakeIO(world);
|
|
184
|
+
const all = await probeTools([spec], io);
|
|
185
|
+
const facts = all.find((f) => f.name === spec.name) ?? all[0];
|
|
186
|
+
if (!facts) throw new Error("probeTools returned no facts");
|
|
187
|
+
return { facts, log: io.log };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const pkgJson = (name: string, version: string) =>
|
|
191
|
+
JSON.stringify({ name, version, bin: { crush: "run-crush.js" } });
|
|
192
|
+
|
|
193
|
+
// ── worlds ──────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const NVM = "/fake/nvm";
|
|
196
|
+
const NVM_CRUSH_PKG = `${NVM}/lib/node_modules/@charmland/crush`;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* (a)/(b): crush installed by an nvm node's npm. PATH finds /fake/nvm/bin/crush,
|
|
200
|
+
* a symlink into /fake/nvm/lib/node_modules/@charmland/crush/run-crush.js.
|
|
201
|
+
* `npmRoot` is what `npm root -g` answers (the npm first on PATH).
|
|
202
|
+
*/
|
|
203
|
+
function nvmCrush(npmRoot: string, latest = "0.60.0"): World {
|
|
204
|
+
const npmPrefix = posix.dirname(posix.dirname(npmRoot));
|
|
205
|
+
return {
|
|
206
|
+
homedir: "/fake/home",
|
|
207
|
+
env: {
|
|
208
|
+
HOME: "/fake/home",
|
|
209
|
+
PATH: `${NVM}/bin:${npmPrefix}/bin:/usr/bin:/bin`,
|
|
210
|
+
},
|
|
211
|
+
onPath: {
|
|
212
|
+
crush: `${NVM}/bin/crush`,
|
|
213
|
+
npm: `${npmPrefix}/bin/npm`,
|
|
214
|
+
},
|
|
215
|
+
links: {
|
|
216
|
+
[`${NVM}/bin/crush`]: "../lib/node_modules/@charmland/crush/run-crush.js",
|
|
217
|
+
},
|
|
218
|
+
real: {
|
|
219
|
+
[`${NVM}/bin/crush`]: `${NVM_CRUSH_PKG}/run-crush.js`,
|
|
220
|
+
},
|
|
221
|
+
files: {
|
|
222
|
+
[`${NVM_CRUSH_PKG}/package.json`]: pkgJson("@charmland/crush", "0.55.0"),
|
|
223
|
+
[`${NVM_CRUSH_PKG}/run-crush.js`]: "#!/usr/bin/env node\n",
|
|
224
|
+
[`${npmPrefix}/bin/npm`]: "#!/usr/bin/env node\n",
|
|
225
|
+
},
|
|
226
|
+
dirs: [npmRoot],
|
|
227
|
+
exec: {
|
|
228
|
+
"npm root -g": `${npmRoot}\n`,
|
|
229
|
+
"npm prefix -g": `${npmPrefix}\n`,
|
|
230
|
+
},
|
|
231
|
+
distTags: { "@charmland/crush": { latest } },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** (a): npm on PATH is a DIFFERENT node install than the one crush lives in. */
|
|
236
|
+
const OTHER_NPM_ROOT = "/fake/local/lib/node_modules";
|
|
237
|
+
|
|
238
|
+
/** The value that follows `--prefix` in an argv, or undefined. */
|
|
239
|
+
function prefixOf(args: readonly string[]): string | undefined {
|
|
240
|
+
const i = args.indexOf("--prefix");
|
|
241
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── R9.7 (a): binary inside a DIFFERENT prefix than `npm root -g` ───────────
|
|
245
|
+
|
|
246
|
+
describe("C15 (a) — npm package in a prefix other than `npm root -g`", () => {
|
|
247
|
+
test("R9.7 the tool is npm-managed", async () => {
|
|
248
|
+
const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
|
|
249
|
+
expect(facts.managed).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("R9.7 the installed version comes from that package's package.json", async () => {
|
|
253
|
+
const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
|
|
254
|
+
expect(facts.installed).toBe("0.55.0");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("R9.7 the latest version comes from the npm registry", async () => {
|
|
258
|
+
const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
|
|
259
|
+
expect(facts.latest).toBe("0.60.0");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("R9.7 the upgrade command is npm install -g --prefix <that prefix> <package>@latest", async () => {
|
|
263
|
+
const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
|
|
264
|
+
const uc = facts.upgradeCommand;
|
|
265
|
+
expect(uc).toBeDefined();
|
|
266
|
+
expect(posix.basename(uc?.cmd ?? "")).toBe("npm");
|
|
267
|
+
const args = uc?.args ?? [];
|
|
268
|
+
for (const a of [
|
|
269
|
+
"install",
|
|
270
|
+
"-g",
|
|
271
|
+
"--prefix",
|
|
272
|
+
NVM,
|
|
273
|
+
"@charmland/crush@latest",
|
|
274
|
+
])
|
|
275
|
+
expect(args).toContain(a);
|
|
276
|
+
// npm reads the prefix from the argument right after --prefix.
|
|
277
|
+
expect(prefixOf(args)).toBe(NVM);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("R9.7 classifyTool says upgrade, never the not-managed veto", async () => {
|
|
281
|
+
const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
|
|
282
|
+
const status = classifyTool(CRUSH, facts);
|
|
283
|
+
expect(status.state).toBe("upgrade");
|
|
284
|
+
expect(status.reason).not.toBe("not-managed");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("R9.7 control: at the registry's latest, classifyTool says current (still managed)", async () => {
|
|
288
|
+
const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT, "0.55.0"));
|
|
289
|
+
expect(facts.managed).toBe(true);
|
|
290
|
+
expect(classifyTool(CRUSH, facts).state).toBe("current");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("R9.7 probing only reads: it never runs an install", async () => {
|
|
294
|
+
const { log } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
|
|
295
|
+
expect(log.execs.filter((c) => /\b(install|add|update)\b/.test(c))).toEqual(
|
|
296
|
+
[],
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ── R9.7 (b): binary inside the prefix `npm root -g` reports ────────────────
|
|
302
|
+
|
|
303
|
+
describe("C15 (b) — npm package in the prefix `npm root -g` reports", () => {
|
|
304
|
+
const SAME_ROOT = `${NVM}/lib/node_modules`;
|
|
305
|
+
|
|
306
|
+
test("R9.7 the tool is npm-managed", async () => {
|
|
307
|
+
const { facts } = await probe(CRUSH, nvmCrush(SAME_ROOT));
|
|
308
|
+
expect(facts.managed).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("R9.7 the upgrade command carries no --prefix", async () => {
|
|
312
|
+
const { facts } = await probe(CRUSH, nvmCrush(SAME_ROOT));
|
|
313
|
+
const args = facts.upgradeCommand?.args ?? [];
|
|
314
|
+
expect(
|
|
315
|
+
args.some((a) => a === "--prefix" || a.startsWith("--prefix=")),
|
|
316
|
+
).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("R9.7 version from package.json, latest from the registry, classified upgrade", async () => {
|
|
320
|
+
const { facts } = await probe(CRUSH, nvmCrush(SAME_ROOT));
|
|
321
|
+
expect({ installed: facts.installed, latest: facts.latest }).toEqual({
|
|
322
|
+
installed: "0.55.0",
|
|
323
|
+
latest: "0.60.0",
|
|
324
|
+
});
|
|
325
|
+
const status = classifyTool(CRUSH, facts);
|
|
326
|
+
expect(status.state).toBe("upgrade");
|
|
327
|
+
expect(status.reason).not.toBe("not-managed");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── R9.7 (c): negatives — the binary is not inside lib/node_modules/<package> ─
|
|
332
|
+
|
|
333
|
+
describe("C15 (c) — a crush binary that npm does not own", () => {
|
|
334
|
+
/** PATH crush is a plain file elsewhere; npm's own copy sits at npm root -g. */
|
|
335
|
+
function elsewhere(): World {
|
|
336
|
+
const npmPkg = `${OTHER_NPM_ROOT}/@charmland/crush`;
|
|
337
|
+
return {
|
|
338
|
+
homedir: "/fake/home",
|
|
339
|
+
env: {
|
|
340
|
+
HOME: "/fake/home",
|
|
341
|
+
PATH: "/fake/elsewhere:/fake/local/bin:/usr/bin:/bin",
|
|
342
|
+
},
|
|
343
|
+
onPath: { crush: "/fake/elsewhere/crush", npm: "/fake/local/bin/npm" },
|
|
344
|
+
files: {
|
|
345
|
+
"/fake/elsewhere/crush": "ELF binary\n",
|
|
346
|
+
[`${npmPkg}/package.json`]: pkgJson("@charmland/crush", "0.55.0"),
|
|
347
|
+
[`${npmPkg}/run-crush.js`]: "#!/usr/bin/env node\n",
|
|
348
|
+
"/fake/local/bin/npm": "#!/usr/bin/env node\n",
|
|
349
|
+
},
|
|
350
|
+
exec: {
|
|
351
|
+
"npm root -g": `${OTHER_NPM_ROOT}\n`,
|
|
352
|
+
"npm prefix -g": "/fake/local\n",
|
|
353
|
+
},
|
|
354
|
+
versionOutput: { crush: "crush version 0.55.0\n" },
|
|
355
|
+
distTags: { "@charmland/crush": { latest: "0.60.0" } },
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
test("R9.7 a binary outside any lib/node_modules/<package> is not npm-managed", async () => {
|
|
360
|
+
const { facts } = await probe(CRUSH, elsewhere());
|
|
361
|
+
expect(facts.managed).not.toBe(true);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("R9.7 classifyTool keeps the not-managed veto for it (unknown / not-managed)", async () => {
|
|
365
|
+
const { facts } = await probe(CRUSH, elsewhere());
|
|
366
|
+
const status = classifyTool(CRUSH, facts);
|
|
367
|
+
expect({ state: status.state, reason: status.reason }).toEqual({
|
|
368
|
+
state: "unknown",
|
|
369
|
+
reason: "not-managed",
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("R9.7 a binary inside lib/node_modules of a DIFFERENT package is not managed as this one", async () => {
|
|
374
|
+
const otherPkg = `${NVM}/lib/node_modules/some-other-tool`;
|
|
375
|
+
const world = nvmCrush(OTHER_NPM_ROOT);
|
|
376
|
+
world.links = {
|
|
377
|
+
[`${NVM}/bin/crush`]: "../lib/node_modules/some-other-tool/cli.js",
|
|
378
|
+
};
|
|
379
|
+
world.real = { [`${NVM}/bin/crush`]: `${otherPkg}/cli.js` };
|
|
380
|
+
world.files = {
|
|
381
|
+
[`${otherPkg}/package.json`]: JSON.stringify({
|
|
382
|
+
name: "some-other-tool",
|
|
383
|
+
version: "3.0.0",
|
|
384
|
+
}),
|
|
385
|
+
[`${otherPkg}/cli.js`]: "#!/usr/bin/env node\n",
|
|
386
|
+
"/fake/local/bin/npm": "#!/usr/bin/env node\n",
|
|
387
|
+
};
|
|
388
|
+
const { facts } = await probe(CRUSH, world);
|
|
389
|
+
expect(facts.managed).not.toBe(true);
|
|
390
|
+
expect(prefixOf(facts.upgradeCommand?.args ?? [])).toBeUndefined();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ── R9.8 (d): Claude Code's native installer layout ─────────────────────────
|
|
395
|
+
|
|
396
|
+
const HOME = "/fake/home";
|
|
397
|
+
const NATIVE_BIN = `${HOME}/.local/bin/claude`;
|
|
398
|
+
const NATIVE_TARGET = `${HOME}/.local/share/claude/versions/2.1.276`;
|
|
399
|
+
|
|
400
|
+
function nativeClaude(opts: { latest?: string; npmCopy?: string } = {}): World {
|
|
401
|
+
const files: Record<string, string> = {
|
|
402
|
+
[NATIVE_TARGET]: "ELF claude native binary\n",
|
|
403
|
+
"/fake/local/bin/npm": "#!/usr/bin/env node\n",
|
|
404
|
+
};
|
|
405
|
+
if (opts.npmCopy) {
|
|
406
|
+
const pkg = `${OTHER_NPM_ROOT}/@anthropic-ai/claude-code`;
|
|
407
|
+
files[`${pkg}/package.json`] = JSON.stringify({
|
|
408
|
+
name: "@anthropic-ai/claude-code",
|
|
409
|
+
version: opts.npmCopy,
|
|
410
|
+
});
|
|
411
|
+
files[`${pkg}/cli.js`] = "#!/usr/bin/env node\n";
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
homedir: HOME,
|
|
415
|
+
env: { HOME, PATH: `${HOME}/.local/bin:/fake/local/bin:/usr/bin:/bin` },
|
|
416
|
+
onPath: { claude: NATIVE_BIN, npm: "/fake/local/bin/npm" },
|
|
417
|
+
links: { [NATIVE_BIN]: NATIVE_TARGET },
|
|
418
|
+
real: { [NATIVE_BIN]: NATIVE_TARGET },
|
|
419
|
+
files,
|
|
420
|
+
dirs: [OTHER_NPM_ROOT],
|
|
421
|
+
exec: {
|
|
422
|
+
"npm root -g": `${OTHER_NPM_ROOT}\n`,
|
|
423
|
+
"npm prefix -g": "/fake/local\n",
|
|
424
|
+
},
|
|
425
|
+
distTags: {
|
|
426
|
+
"@anthropic-ai/claude-code": { latest: opts.latest ?? "2.1.280" },
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
describe("C15 (d) — claude resolving into <home>/.local/share/claude/versions/<version>", () => {
|
|
432
|
+
test("R9.8 the installed version is the <version> directory name", async () => {
|
|
433
|
+
const { facts } = await probe(CLAUDE, nativeClaude());
|
|
434
|
+
expect(facts.installed).toBe("2.1.276");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("R9.8 the latest comes from the npm registry for @anthropic-ai/claude-code", async () => {
|
|
438
|
+
const { facts } = await probe(CLAUDE, nativeClaude());
|
|
439
|
+
expect(facts.latest).toBe("2.1.280");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("R9.8 it is managed (by Claude Code's own updater)", async () => {
|
|
443
|
+
const { facts } = await probe(CLAUDE, nativeClaude());
|
|
444
|
+
expect(facts.managed).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("R9.8 the upgrade command is `claude update`", async () => {
|
|
448
|
+
const { facts } = await probe(CLAUDE, nativeClaude());
|
|
449
|
+
expect(facts.upgradeCommand).toEqual({ cmd: "claude", args: ["update"] });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("R9.8 classifyTool says upgrade, never the not-managed veto", async () => {
|
|
453
|
+
const { facts } = await probe(CLAUDE, nativeClaude());
|
|
454
|
+
const status = classifyTool(CLAUDE, facts);
|
|
455
|
+
expect(status.state).toBe("upgrade");
|
|
456
|
+
expect(status.reason).not.toBe("not-managed");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("R9.8 an npm copy of claude-code at `npm root -g` does not trigger the second-copy veto", async () => {
|
|
460
|
+
const { facts } = await probe(CLAUDE, nativeClaude({ npmCopy: "2.0.1" }));
|
|
461
|
+
expect({
|
|
462
|
+
installed: facts.installed,
|
|
463
|
+
managed: facts.managed,
|
|
464
|
+
upgradeCommand: facts.upgradeCommand,
|
|
465
|
+
}).toEqual({
|
|
466
|
+
installed: "2.1.276",
|
|
467
|
+
managed: true,
|
|
468
|
+
upgradeCommand: { cmd: "claude", args: ["update"] },
|
|
469
|
+
});
|
|
470
|
+
const status = classifyTool(CLAUDE, facts);
|
|
471
|
+
expect(status.state).toBe("upgrade");
|
|
472
|
+
expect(status.reason).not.toBe("not-managed");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("R9.8 control: at the registry's latest, classifyTool says current", async () => {
|
|
476
|
+
const { facts } = await probe(CLAUDE, nativeClaude({ latest: "2.1.276" }));
|
|
477
|
+
expect(classifyTool(CLAUDE, facts).state).toBe("current");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("R9.8 probing only reads: it never runs `claude update` or an install", async () => {
|
|
481
|
+
const { log } = await probe(CLAUDE, nativeClaude());
|
|
482
|
+
expect(log.execs.filter((c) => /\b(install|add|update)\b/.test(c))).toEqual(
|
|
483
|
+
[],
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ── R9.8 (e): negative — a claude that is not the native layout ─────────────
|
|
489
|
+
|
|
490
|
+
describe("C15 (e) — a claude binary outside the native layout and outside node_modules", () => {
|
|
491
|
+
function strayClaude(): World {
|
|
492
|
+
return {
|
|
493
|
+
homedir: HOME,
|
|
494
|
+
env: { HOME, PATH: "/fake/opt/claude/bin:/fake/local/bin:/usr/bin:/bin" },
|
|
495
|
+
onPath: {
|
|
496
|
+
claude: "/fake/opt/claude/bin/claude",
|
|
497
|
+
npm: "/fake/local/bin/npm",
|
|
498
|
+
},
|
|
499
|
+
files: {
|
|
500
|
+
"/fake/opt/claude/bin/claude": "ELF claude\n",
|
|
501
|
+
"/fake/local/bin/npm": "#!/usr/bin/env node\n",
|
|
502
|
+
},
|
|
503
|
+
dirs: [OTHER_NPM_ROOT],
|
|
504
|
+
exec: {
|
|
505
|
+
"npm root -g": `${OTHER_NPM_ROOT}\n`,
|
|
506
|
+
"npm prefix -g": "/fake/local\n",
|
|
507
|
+
},
|
|
508
|
+
versionOutput: { claude: "2.1.276 (Claude Code)\n" },
|
|
509
|
+
distTags: { "@anthropic-ai/claude-code": { latest: "2.1.280" } },
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
test("R9.8 its upgrade command is not `claude update`", async () => {
|
|
514
|
+
const { facts } = await probe(CLAUDE, strayClaude());
|
|
515
|
+
const uc = facts.upgradeCommand;
|
|
516
|
+
const isClaudeUpdate =
|
|
517
|
+
uc !== undefined &&
|
|
518
|
+
posix.basename(uc.cmd) === "claude" &&
|
|
519
|
+
uc.args.length === 1 &&
|
|
520
|
+
uc.args[0] === "update";
|
|
521
|
+
expect(isClaudeUpdate).toBe(false);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("R9.8 it is not managed, and classifyTool keeps the not-managed veto", async () => {
|
|
525
|
+
const { facts } = await probe(CLAUDE, strayClaude());
|
|
526
|
+
expect(facts.managed).not.toBe(true);
|
|
527
|
+
const status = classifyTool(CLAUDE, facts);
|
|
528
|
+
expect({ state: status.state, reason: status.reason }).toEqual({
|
|
529
|
+
state: "unknown",
|
|
530
|
+
reason: "not-managed",
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|