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,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `runShellScript` has exactly one legitimate caller, and this pins it.
|
|
3
|
-
*
|
|
4
|
-
* The whole of item 3 is the split between two ways of running a child process:
|
|
5
|
-
* argv (`runCommand`), which no shell ever sees, and a script (`runShellScript`),
|
|
6
|
-
* which `/bin/sh` parses. Every installer command belongs to the first, because
|
|
7
|
-
* it is assembled from package names that arrive in `.claude/profiles.json` and
|
|
8
|
-
* plugin `requires.bin` — hand-authored, committed by teammates, fetched from
|
|
9
|
-
* marketplaces, validated by nothing. The second exists only for the two
|
|
10
|
-
* `TOOLCHAIN_BOOTSTRAP` values, which are constants in this repo copied from
|
|
11
|
-
* bun.sh and brew.sh and which genuinely contain a pipe and a command
|
|
12
|
-
* substitution.
|
|
13
|
-
*
|
|
14
|
-
* Nothing in the type system distinguishes them — both are strings — so after
|
|
15
|
-
* the commit lands, the design decision is enforced by this test or by nothing.
|
|
16
|
-
* A branded `ShellScript` type would enforce it at compile time and was
|
|
17
|
-
* considered; for one map, two entries and one call site it is more machinery
|
|
18
|
-
* than the rule is worth, and unlike a brand this is greppable.
|
|
19
|
-
*
|
|
20
|
-
* Source-scanning precedent in this suite: `uppercase-keybindings.test.ts`.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { describe, expect, test } from "bun:test";
|
|
24
|
-
import path from "node:path";
|
|
25
|
-
import fs from "fs-extra";
|
|
26
|
-
|
|
27
|
-
const SRC_DIR = path.join(import.meta.dir, "..");
|
|
28
|
-
|
|
29
|
-
/** Files permitted to call `runShellScript`, relative to `src/`. */
|
|
30
|
-
const ALLOWED = ["cli/install.ts"];
|
|
31
|
-
|
|
32
|
-
/** The definition itself, plus this test, are not callers. */
|
|
33
|
-
const NOT_CALLERS = ["utils/run.ts", "__tests__/shell-script-callers.test.ts"];
|
|
34
|
-
|
|
35
|
-
async function sourceFiles(dir: string): Promise<string[]> {
|
|
36
|
-
const out: string[] = [];
|
|
37
|
-
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
38
|
-
const full = path.join(dir, entry.name);
|
|
39
|
-
if (entry.isDirectory()) out.push(...(await sourceFiles(full)));
|
|
40
|
-
else if (/\.tsx?$/.test(entry.name)) out.push(full);
|
|
41
|
-
}
|
|
42
|
-
return out;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const isComment = (line: string) => {
|
|
46
|
-
const t = line.trim();
|
|
47
|
-
return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
async function callersOf(pattern: RegExp): Promise<string[]> {
|
|
51
|
-
const found = new Set<string>();
|
|
52
|
-
for (const file of await sourceFiles(SRC_DIR)) {
|
|
53
|
-
const rel = path.relative(SRC_DIR, file);
|
|
54
|
-
if (NOT_CALLERS.includes(rel)) continue;
|
|
55
|
-
const text = await fs.readFile(file, "utf8");
|
|
56
|
-
for (const line of text.split("\n")) {
|
|
57
|
-
if (isComment(line)) continue;
|
|
58
|
-
if (pattern.test(line)) found.add(rel);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return [...found].sort();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
describe("only the toolchain bootstrap goes through a shell", () => {
|
|
65
|
-
test("runShellScript is called from exactly the allowed files", async () => {
|
|
66
|
-
expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("nothing spawns with a shell outside utils/run.ts", async () => {
|
|
70
|
-
// Catches someone reaching for `spawn`/`exec` directly rather than
|
|
71
|
-
// through the seam — a second shell path that never names
|
|
72
|
-
// `runShellScript`.
|
|
73
|
-
//
|
|
74
|
-
// Both spellings: `shell: true` and a named shell (`shell: "/bin/bash"`).
|
|
75
|
-
// The CLI Tools screen used the second for its version probe (`which -a`
|
|
76
|
-
// pipelines, a string-form `exec` of each check command); that probe now
|
|
77
|
-
// lives in services/tool-probe.ts and runs argv only. `shell: false` and
|
|
78
|
-
// the alias writer's `shell: ShellKind` values are not spawn options and
|
|
79
|
-
// do not match.
|
|
80
|
-
expect(await callersOf(/shell:\s*(true|["'`])/)).toEqual([]);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("the scan can actually fail", async () => {
|
|
84
|
-
// Negative control. The two assertions above pass trivially if the walk
|
|
85
|
-
// finds nothing or the pattern never matches, and a check that cannot
|
|
86
|
-
// fail is not evidence. `runCommand(` is known to be called from several
|
|
87
|
-
// files, so a working scan must find them.
|
|
88
|
-
const runCommandCallers = await callersOf(/\brunCommand\(/);
|
|
89
|
-
expect(runCommandCallers.length).toBeGreaterThan(0);
|
|
90
|
-
expect(runCommandCallers).toContain("cli/install.ts");
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
3
|
-
import type { AppAction, AppState } from "../ui/state/types.js";
|
|
4
|
-
|
|
5
|
-
function run(actions: AppAction[], from: AppState = initialState): AppState {
|
|
6
|
-
return actions.reduce(appReducer, from);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The status line lives in app state, not in the screen.
|
|
11
|
-
*
|
|
12
|
-
* Two bugs drove this, and both were invisible to every test we had:
|
|
13
|
-
*
|
|
14
|
-
* 1. It was cleared on a 5-second wall clock, so the message describing what
|
|
15
|
-
* you just did was usually gone before you looked at it — and unobservable
|
|
16
|
-
* to anything sampling the screen a moment later.
|
|
17
|
-
* 2. It was component-local, and `Router` swaps the component type on a tab
|
|
18
|
-
* change, so `9 → 1 → 9` destroyed it.
|
|
19
|
-
*/
|
|
20
|
-
describe("status line state", () => {
|
|
21
|
-
test("survives leaving the tab and coming back", () => {
|
|
22
|
-
const shown = run([
|
|
23
|
-
{
|
|
24
|
-
type: "STYLES_STATUS_SET",
|
|
25
|
-
status: { text: "Selection reset to the live style", tone: "success" },
|
|
26
|
-
},
|
|
27
|
-
]);
|
|
28
|
-
expect(shown.styles.status?.text).toBe("Selection reset to the live style");
|
|
29
|
-
|
|
30
|
-
// Navigating away and back is what unmounts the screen.
|
|
31
|
-
const roundTrip = run(
|
|
32
|
-
[
|
|
33
|
-
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
34
|
-
{ type: "NAVIGATE", route: { screen: "styles" } },
|
|
35
|
-
],
|
|
36
|
-
shown,
|
|
37
|
-
);
|
|
38
|
-
expect(roundTrip.styles.status?.text).toBe(
|
|
39
|
-
"Selection reset to the live style",
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("a new message replaces the previous one", () => {
|
|
44
|
-
const state = run([
|
|
45
|
-
{ type: "STYLES_STATUS_SET", status: { text: "first", tone: "success" } },
|
|
46
|
-
{ type: "STYLES_STATUS_SET", status: { text: "second", tone: "error" } },
|
|
47
|
-
]);
|
|
48
|
-
expect(state.styles.status).toEqual({ text: "second", tone: "error" });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("clearing returns the line to its default", () => {
|
|
52
|
-
const state = run([
|
|
53
|
-
{ type: "STYLES_STATUS_SET", status: { text: "done", tone: "success" } },
|
|
54
|
-
{ type: "STYLES_STATUS_CLEAR" },
|
|
55
|
-
]);
|
|
56
|
-
expect(state.styles.status).toBeNull();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("starts empty", () => {
|
|
60
|
-
expect(initialState.styles.status).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("keeps the tone, so an error cannot render as a success", () => {
|
|
64
|
-
const state = run([
|
|
65
|
-
{
|
|
66
|
-
type: "STYLES_STATUS_SET",
|
|
67
|
-
status: { text: "Already talking to GitHub", tone: "error" },
|
|
68
|
-
},
|
|
69
|
-
]);
|
|
70
|
-
expect(state.styles.status?.tone).toBe("error");
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { promises as fs } from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
UNKNOWN_VERSION,
|
|
7
|
-
diffVersions,
|
|
8
|
-
isKnownVersion,
|
|
9
|
-
loadSeenVersions,
|
|
10
|
-
saveSeenVersions,
|
|
11
|
-
} from "../services/version-snapshot.js";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Plugins get updated by things magus does not control — Claude Code's own
|
|
15
|
-
* `plugin update` writes installed_plugins.json without touching magus's
|
|
16
|
-
* bookkeeping. Diffing what we last rendered against reality catches every
|
|
17
|
-
* source; logging only magus's own actions catches none of them.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
describe("isKnownVersion", () => {
|
|
21
|
-
test('"0.0.0" is not a usable version', () => {
|
|
22
|
-
// Claude Code records this for a plugin whose version it cannot determine.
|
|
23
|
-
expect(isKnownVersion(UNKNOWN_VERSION)).toBe(false);
|
|
24
|
-
expect(isKnownVersion(undefined)).toBe(false);
|
|
25
|
-
expect(isKnownVersion("")).toBe(false);
|
|
26
|
-
expect(isKnownVersion("1.0.0")).toBe(true);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("diffVersions — first run", () => {
|
|
31
|
-
test("no baseline reports nothing at all", () => {
|
|
32
|
-
// Without this, the first launch would flag every installed plugin as new,
|
|
33
|
-
// which is both wrong and the fastest way to train someone to ignore badges.
|
|
34
|
-
expect(
|
|
35
|
-
diffVersions(null, { "dev@magus": "2.12.1", "a@mp": UNKNOWN_VERSION }),
|
|
36
|
-
).toEqual([]);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("an empty baseline is NOT the same as no baseline", () => {
|
|
40
|
-
// A real snapshot that happens to be empty means we have looked before and
|
|
41
|
-
// saw nothing, so anything present now is genuinely new.
|
|
42
|
-
const changes = diffVersions({}, { "dev@magus": "2.12.1" });
|
|
43
|
-
expect(changes).toEqual([
|
|
44
|
-
{ pluginId: "dev@magus", kind: "installed", to: "2.12.1" },
|
|
45
|
-
]);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("diffVersions — installs", () => {
|
|
50
|
-
test("a plugin absent from the baseline is reported as installed, not updated", () => {
|
|
51
|
-
const changes = diffVersions(
|
|
52
|
-
{ "other@mp": "1.0.0" },
|
|
53
|
-
{ "new@mp": "3.1.0" },
|
|
54
|
-
);
|
|
55
|
-
expect(changes).toEqual([
|
|
56
|
-
{ pluginId: "new@mp", kind: "installed", to: "3.1.0" },
|
|
57
|
-
]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("an install with an unknown version is still reported", () => {
|
|
61
|
-
// The version is unusable but the install is real — the UI shows "new"
|
|
62
|
-
// with no version rather than suppressing the plugin entirely.
|
|
63
|
-
const changes = diffVersions({}, { "a@mp": UNKNOWN_VERSION });
|
|
64
|
-
expect(changes).toEqual([
|
|
65
|
-
{ pluginId: "a@mp", kind: "installed", to: UNKNOWN_VERSION },
|
|
66
|
-
]);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("diffVersions — updates", () => {
|
|
71
|
-
test("reports a genuine version transition", () => {
|
|
72
|
-
expect(
|
|
73
|
-
diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
|
|
74
|
-
).toEqual([
|
|
75
|
-
{ pluginId: "dev@magus", kind: "updated", from: "2.12.0", to: "2.12.1" },
|
|
76
|
-
]);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("unchanged versions report nothing", () => {
|
|
80
|
-
expect(
|
|
81
|
-
diffVersions({ "dev@magus": "2.12.1" }, { "dev@magus": "2.12.1" }),
|
|
82
|
-
).toEqual([]);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("learning a previously unknown version is not an update", () => {
|
|
86
|
-
// This is exactly what v4.22.0's registry overlay caused: versions that
|
|
87
|
-
// could not be resolved suddenly could be. Reporting it would announce
|
|
88
|
-
// magus's own bugfix as if the user's plugins had changed.
|
|
89
|
-
expect(
|
|
90
|
-
diffVersions({ "a@mp": UNKNOWN_VERSION }, { "a@mp": "5.3.0" }),
|
|
91
|
-
).toEqual([]);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("losing a known version is not an update either", () => {
|
|
95
|
-
expect(
|
|
96
|
-
diffVersions({ "a@mp": "5.3.0" }, { "a@mp": UNKNOWN_VERSION }),
|
|
97
|
-
).toEqual([]);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("a downgrade IS reported", () => {
|
|
101
|
-
// Rolling back is a real change, arguably the most important to surface.
|
|
102
|
-
expect(diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" })).toEqual([
|
|
103
|
-
{ pluginId: "a@mp", kind: "updated", from: "2.0.0", to: "1.0.0" },
|
|
104
|
-
]);
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe("diffVersions — removals and mixed sets", () => {
|
|
109
|
-
test("a removed plugin is not reported", () => {
|
|
110
|
-
expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("classifies each plugin independently in one pass", () => {
|
|
114
|
-
const changes = diffVersions(
|
|
115
|
-
{ same: "1.0.0", moved: "2.0.0", learned: UNKNOWN_VERSION },
|
|
116
|
-
{ same: "1.0.0", moved: "2.1.0", learned: "9.0.0", fresh: "0.1.0" },
|
|
117
|
-
);
|
|
118
|
-
expect(changes).toEqual([
|
|
119
|
-
{ pluginId: "moved", kind: "updated", from: "2.0.0", to: "2.1.0" },
|
|
120
|
-
{ pluginId: "fresh", kind: "installed", to: "0.1.0" },
|
|
121
|
-
]);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe("per-project baselines", () => {
|
|
126
|
-
/**
|
|
127
|
-
* The baseline used to be one flat map for the whole machine, but the
|
|
128
|
-
* versions written into it are resolved per project. Two projects
|
|
129
|
-
* legitimately on different versions overwrote each other's baseline, so
|
|
130
|
-
* every differing plugin was reported as "updated" on the next render — a
|
|
131
|
-
* change that never happened.
|
|
132
|
-
*
|
|
133
|
-
* CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
|
|
134
|
-
* macOS and ignores $HOME, so a HOME-only override writes to the operator's
|
|
135
|
-
* real ~/.claude. That is not hypothetical — it happened while writing these.
|
|
136
|
-
*/
|
|
137
|
-
const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
|
|
138
|
-
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
|
|
139
|
-
const prev = process.env.CLAUDE_CONFIG_DIR;
|
|
140
|
-
process.env.CLAUDE_CONFIG_DIR = dir;
|
|
141
|
-
try {
|
|
142
|
-
await fn();
|
|
143
|
-
} finally {
|
|
144
|
-
// biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
|
|
145
|
-
if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
146
|
-
else process.env.CLAUDE_CONFIG_DIR = prev;
|
|
147
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
test("a project with no baseline of its own reports nothing", async () => {
|
|
152
|
-
await withSandbox(async () => {
|
|
153
|
-
await saveSeenVersions("/proj/a", { "setup@magus": "2.2.0" });
|
|
154
|
-
const baseline = await loadSeenVersions("/proj/b");
|
|
155
|
-
expect(baseline).toBeNull();
|
|
156
|
-
expect(diffVersions(baseline, { "setup@magus": "2.5.0" })).toEqual([]);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("saving one project preserves the others", async () => {
|
|
161
|
-
await withSandbox(async () => {
|
|
162
|
-
await saveSeenVersions("/proj/a", { "designer@magus": "2.0.0" });
|
|
163
|
-
await saveSeenVersions("/proj/b", { "designer@magus": "2.0.1" });
|
|
164
|
-
expect(await loadSeenVersions("/proj/a")).toEqual({
|
|
165
|
-
"designer@magus": "2.0.0",
|
|
166
|
-
});
|
|
167
|
-
expect(await loadSeenVersions("/proj/b")).toEqual({
|
|
168
|
-
"designer@magus": "2.0.1",
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("a real update inside one project is still reported", async () => {
|
|
174
|
-
await withSandbox(async () => {
|
|
175
|
-
await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
|
|
176
|
-
const previous = await loadSeenVersions("/proj/a");
|
|
177
|
-
expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
|
|
178
|
-
{ pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
|
|
179
|
-
]);
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("a pre-migration flat baseline is adopted once, then replaced", async () => {
|
|
184
|
-
await withSandbox(async () => {
|
|
185
|
-
const file = path.join(
|
|
186
|
-
process.env.CLAUDE_CONFIG_DIR as string,
|
|
187
|
-
"magus-version-snapshot.json",
|
|
188
|
-
);
|
|
189
|
-
await fs.writeFile(
|
|
190
|
-
file,
|
|
191
|
-
JSON.stringify({
|
|
192
|
-
seen: { "dev@magus": "3.0.0" },
|
|
193
|
-
seededAt: "2026-07-29T00:00:00.000Z",
|
|
194
|
-
updatedAt: "2026-07-29T00:00:00.000Z",
|
|
195
|
-
}),
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
// Legacy shape carries no project, so it stands in for whoever asks first
|
|
199
|
-
// rather than being thrown away.
|
|
200
|
-
expect(await loadSeenVersions("/proj/a")).toEqual({
|
|
201
|
-
"dev@magus": "3.0.0",
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
|
|
205
|
-
// Once per-project entries exist, the flat map is no longer consulted.
|
|
206
|
-
expect(await loadSeenVersions("/proj/b")).toBeNull();
|
|
207
|
-
expect(await loadSeenVersions("/proj/a")).toEqual({
|
|
208
|
-
"dev@magus": "3.0.1",
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
});
|