claudeup 6.5.1 → 6.7.1
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__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +2 -3
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/models-presets-marketplace.test.ts +141 -0
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/data/models-presets.ts +10 -11
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +2 -174
- package/src/services/community-styles.ts +1 -1
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +57 -0
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- package/src/ui/hooks/useMismatchModal.ts +0 -89
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "6.
|
|
68
|
-
"claudeup-darwin-x64": "6.
|
|
69
|
-
"claudeup-linux-x64": "6.
|
|
67
|
+
"claudeup-darwin-arm64": "6.7.1",
|
|
68
|
+
"claudeup-darwin-x64": "6.7.1",
|
|
69
|
+
"claudeup-linux-x64": "6.7.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ActiveClosureDeps,
|
|
4
|
+
loadActiveClosure,
|
|
5
|
+
selectProfileId,
|
|
6
|
+
} from "../services/active-closure.js";
|
|
7
|
+
import { resolveProfile } from "../services/resolver.js";
|
|
8
|
+
import type {
|
|
9
|
+
BinRequirement,
|
|
10
|
+
ProfileManifest,
|
|
11
|
+
ResolvedClosure,
|
|
12
|
+
} from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The CLI and the TUI choose a profile through ONE function. They used to
|
|
16
|
+
* differ — the CLI inline, the TUI not at all — so an exact pin could never
|
|
17
|
+
* produce the same state in both.
|
|
18
|
+
*
|
|
19
|
+
* Every IO edge is injected; nothing here reads the real manifest, the real
|
|
20
|
+
* active-profile symlink, or any plugin.json.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const PROJECT = "/tmp/no-such-project";
|
|
24
|
+
|
|
25
|
+
function manifest(
|
|
26
|
+
profiles: ProfileManifest["profiles"],
|
|
27
|
+
over: Partial<ProfileManifest> = {},
|
|
28
|
+
): ProfileManifest {
|
|
29
|
+
return { version: 2, profiles, ...over };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ONE = manifest({
|
|
33
|
+
default: {
|
|
34
|
+
name: "default",
|
|
35
|
+
plugins: { "dev@magus": "latest", "terminal@magus": "5.0.1" },
|
|
36
|
+
cliTools: { claudish: "latest" },
|
|
37
|
+
},
|
|
38
|
+
} as ProfileManifest["profiles"]);
|
|
39
|
+
|
|
40
|
+
const TWO = manifest({
|
|
41
|
+
frontend: { name: "frontend", plugins: { "dev@magus": "latest" } },
|
|
42
|
+
backend: { name: "backend", plugins: { "terminal@magus": "latest" } },
|
|
43
|
+
} as ProfileManifest["profiles"]);
|
|
44
|
+
|
|
45
|
+
/** A plugin.json resolver that fails the test if the screen reads one. */
|
|
46
|
+
const MUST_NOT_READ = async (pluginId: string): Promise<BinRequirement[]> => {
|
|
47
|
+
throw new Error(`read plugin.json for ${pluginId}`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("selectProfileId", () => {
|
|
51
|
+
const ids = ["frontend", "backend"];
|
|
52
|
+
|
|
53
|
+
test.each([
|
|
54
|
+
// [explicit, active, expected]
|
|
55
|
+
["frontend", "backend", "frontend"], // explicit wins
|
|
56
|
+
[undefined, "backend", "backend"], // then the active one
|
|
57
|
+
[undefined, null, null], // two profiles, no choice: ambiguous
|
|
58
|
+
["missing", "backend", "backend"], // unknown explicit is ignored
|
|
59
|
+
[undefined, "stale", null], // stale active, two profiles: ambiguous
|
|
60
|
+
] as const)("explicit=%p active=%p → %p", (explicit, active, expected) => {
|
|
61
|
+
expect(selectProfileId(ids, explicit, active)).toBe(expected);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a single profile needs no activation", () => {
|
|
65
|
+
expect(selectProfileId(["default"], undefined, null)).toBe("default");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a stale active symlink with a single profile picks the single one", () => {
|
|
69
|
+
// The profile the symlink names was removed from the manifest. The CLI
|
|
70
|
+
// used to pick the stale name and then fail to resolve it.
|
|
71
|
+
expect(selectProfileId(["default"], undefined, "removed")).toBe("default");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("no profiles at all selects nothing", () => {
|
|
75
|
+
expect(selectProfileId([], undefined, null)).toBeNull();
|
|
76
|
+
expect(selectProfileId([], "x", "y")).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Deps over a fixed manifest and active name, recording every resolve call. */
|
|
81
|
+
function fakeDeps(m: ProfileManifest, active: string | null) {
|
|
82
|
+
const calls: Array<{
|
|
83
|
+
id: string;
|
|
84
|
+
binResolver?: (id: string) => Promise<BinRequirement[]>;
|
|
85
|
+
}> = [];
|
|
86
|
+
const deps: Partial<ActiveClosureDeps> = {
|
|
87
|
+
readManifest: async () => m,
|
|
88
|
+
activeProfile: async () => active,
|
|
89
|
+
resolveProfile: async (manifest, id, opts) => {
|
|
90
|
+
calls.push({ id, binResolver: opts?.binResolver });
|
|
91
|
+
// The real resolver, but never the real plugin.json reader: when the
|
|
92
|
+
// caller passed none, a reader that throws stands in for the default.
|
|
93
|
+
return resolveProfile(manifest, id, {
|
|
94
|
+
binResolver: opts?.binResolver ?? MUST_NOT_READ,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return { deps, calls };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("loadActiveClosure", () => {
|
|
102
|
+
test("resolves the active profile's closure", async () => {
|
|
103
|
+
const { deps } = fakeDeps(TWO, "backend");
|
|
104
|
+
const got = await loadActiveClosure(PROJECT, { resolveBins: false, deps });
|
|
105
|
+
expect(got?.profileId).toBe("backend");
|
|
106
|
+
expect(got?.closure.plugins).toEqual({ "terminal@magus": "latest" });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("passes the manifest's pins through exactly", async () => {
|
|
110
|
+
const { deps } = fakeDeps(ONE, null);
|
|
111
|
+
const got = await loadActiveClosure(PROJECT, { resolveBins: false, deps });
|
|
112
|
+
expect(got?.closure.plugins).toEqual({
|
|
113
|
+
"dev@magus": "latest",
|
|
114
|
+
"terminal@magus": "5.0.1",
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("an invalid manifest is null, not a throw", async () => {
|
|
119
|
+
const { deps, calls } = fakeDeps(
|
|
120
|
+
{ version: 1, profiles: {} } as unknown as ProfileManifest,
|
|
121
|
+
null,
|
|
122
|
+
);
|
|
123
|
+
expect(await loadActiveClosure(PROJECT, { deps })).toBeNull();
|
|
124
|
+
expect(calls).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("no manifest (readManifest's empty v2) is null", async () => {
|
|
128
|
+
const { deps } = fakeDeps(manifest({}), null);
|
|
129
|
+
expect(await loadActiveClosure(PROJECT, { deps })).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("two profiles and none active is null — there is no choice to make", async () => {
|
|
133
|
+
const { deps, calls } = fakeDeps(TWO, null);
|
|
134
|
+
expect(await loadActiveClosure(PROJECT, { deps })).toBeNull();
|
|
135
|
+
expect(calls).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a stale active symlink with a single profile resolves the single one", async () => {
|
|
139
|
+
const { deps } = fakeDeps(ONE, "removed-profile");
|
|
140
|
+
const got = await loadActiveClosure(PROJECT, { resolveBins: false, deps });
|
|
141
|
+
expect(got?.profileId).toBe("default");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("resolveBins:false hands resolveProfile a resolver that returns []", async () => {
|
|
145
|
+
// The Plugins screen needs only pins. The default resolver reads every
|
|
146
|
+
// declared plugin's plugin.json on each fetch.
|
|
147
|
+
const { deps, calls } = fakeDeps(ONE, null);
|
|
148
|
+
const got = await loadActiveClosure(PROJECT, { resolveBins: false, deps });
|
|
149
|
+
expect(calls).toHaveLength(1);
|
|
150
|
+
const resolver = calls[0]?.binResolver;
|
|
151
|
+
expect(resolver).toBeDefined();
|
|
152
|
+
expect(await resolver?.("dev@magus")).toEqual([]);
|
|
153
|
+
// No plugin-declared bins; the profile's own cliTools still resolve.
|
|
154
|
+
expect(got?.closure.bins.map((b) => b.name)).toEqual(["claudish"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("resolveBins defaults to true: the real plugin.json resolver is used", async () => {
|
|
158
|
+
const { deps, calls } = fakeDeps(ONE, null);
|
|
159
|
+
// MUST_NOT_READ stands in for the default reader, so the default path
|
|
160
|
+
// shows up as a rejected resolve — which loadActiveClosure reports as null.
|
|
161
|
+
expect(await loadActiveClosure(PROJECT, { deps })).toBeNull();
|
|
162
|
+
expect(calls).toHaveLength(1);
|
|
163
|
+
expect(calls[0]?.binResolver).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("any failing edge is null, never a throw", async () => {
|
|
167
|
+
const boom = async (): Promise<never> => {
|
|
168
|
+
throw new Error("boom");
|
|
169
|
+
};
|
|
170
|
+
for (const deps of [
|
|
171
|
+
{ readManifest: boom },
|
|
172
|
+
{ readManifest: async () => ONE, activeProfile: boom },
|
|
173
|
+
{
|
|
174
|
+
readManifest: async () => ONE,
|
|
175
|
+
activeProfile: async () => null,
|
|
176
|
+
resolveProfile: boom as unknown as (
|
|
177
|
+
...args: unknown[]
|
|
178
|
+
) => Promise<ResolvedClosure>,
|
|
179
|
+
},
|
|
180
|
+
]) {
|
|
181
|
+
expect(await loadActiveClosure(PROJECT, { deps })).toBeNull();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLACK-BOX: the one profile choice both front ends resolve through (R2
|
|
3
|
+
* precondition: "for the same project and profile"). §8.4 contracts only;
|
|
4
|
+
* IO injected through `loadActiveClosure`'s deps.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
type ActiveClosureDeps,
|
|
9
|
+
loadActiveClosure,
|
|
10
|
+
selectProfileId,
|
|
11
|
+
} from "../services/active-closure.js";
|
|
12
|
+
import type {
|
|
13
|
+
BinRequirement,
|
|
14
|
+
ProfileManifest,
|
|
15
|
+
ResolvedClosure,
|
|
16
|
+
} from "../types/index.js";
|
|
17
|
+
|
|
18
|
+
describe("selectProfileId: explicit ?? active ?? the only id ?? null", () => {
|
|
19
|
+
test.each([
|
|
20
|
+
{ ids: ["a", "b"], explicit: "b", active: "a", want: "b" },
|
|
21
|
+
{ ids: ["a", "b"], explicit: "zz", active: "a", want: "a" },
|
|
22
|
+
{ ids: ["a", "b"], explicit: undefined, active: "b", want: "b" },
|
|
23
|
+
{ ids: ["a", "b"], explicit: undefined, active: "zz", want: null },
|
|
24
|
+
{ ids: ["only"], explicit: undefined, active: null, want: "only" },
|
|
25
|
+
{ ids: ["only"], explicit: "zz", active: "yy", want: "only" },
|
|
26
|
+
{ ids: ["a", "b"], explicit: undefined, active: null, want: null },
|
|
27
|
+
{ ids: [], explicit: "a", active: "a", want: null },
|
|
28
|
+
])(
|
|
29
|
+
"ids=$ids explicit=$explicit active=$active → $want",
|
|
30
|
+
({ ids, explicit, active, want }) => {
|
|
31
|
+
expect(selectProfileId(ids, explicit, active)).toBe(want);
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function manifest(): ProfileManifest {
|
|
37
|
+
return {
|
|
38
|
+
version: 2,
|
|
39
|
+
profiles: {
|
|
40
|
+
default: { name: "Default", plugins: { "dev@magus": "latest" } },
|
|
41
|
+
other: { name: "Other", plugins: { "setup@magus": "latest" } },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function closureFor(id: string): ResolvedClosure {
|
|
47
|
+
return {
|
|
48
|
+
marketplaces: {},
|
|
49
|
+
plugins: { [`${id}-plugin@magus`]: "latest" },
|
|
50
|
+
mcpServers: {},
|
|
51
|
+
bins: [],
|
|
52
|
+
skills: [],
|
|
53
|
+
settings: {},
|
|
54
|
+
env: { required: [], optional: [] },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("loadActiveClosure", () => {
|
|
59
|
+
test("resolves the active profile through the injected deps", async () => {
|
|
60
|
+
const resolved: string[] = [];
|
|
61
|
+
const deps: Partial<ActiveClosureDeps> = {
|
|
62
|
+
readManifest: async () => manifest(),
|
|
63
|
+
activeProfile: async () => "other",
|
|
64
|
+
resolveProfile: async (_m, id) => {
|
|
65
|
+
resolved.push(id);
|
|
66
|
+
return closureFor(id);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const got = await loadActiveClosure("/proj", { deps });
|
|
71
|
+
|
|
72
|
+
expect(got?.profileId).toBe("other");
|
|
73
|
+
// selectProfileId returns `string | null`; widen the matcher's type so the
|
|
74
|
+
// comparison type-checks. Runtime assertion unchanged.
|
|
75
|
+
expect<string | null | undefined>(got?.profileId).toBe(
|
|
76
|
+
selectProfileId(["default", "other"], undefined, "other"),
|
|
77
|
+
);
|
|
78
|
+
expect(got?.closure.plugins).toEqual({ "other-plugin@magus": "latest" });
|
|
79
|
+
expect(resolved).toEqual(["other"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("resolveBins: false hands resolveProfile a bin resolver that yields nothing", async () => {
|
|
83
|
+
let resolver: ((pluginId: string) => Promise<BinRequirement[]>) | undefined;
|
|
84
|
+
const deps: Partial<ActiveClosureDeps> = {
|
|
85
|
+
readManifest: async () => manifest(),
|
|
86
|
+
activeProfile: async () => "default",
|
|
87
|
+
resolveProfile: async (_m, id, opts) => {
|
|
88
|
+
resolver = opts?.binResolver;
|
|
89
|
+
return closureFor(id);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
await loadActiveClosure("/proj", { resolveBins: false, deps });
|
|
94
|
+
|
|
95
|
+
expect(resolver).toBeDefined();
|
|
96
|
+
expect(await resolver?.("terminal@magus")).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an unreadable manifest yields null and never throws", async () => {
|
|
100
|
+
const deps: Partial<ActiveClosureDeps> = {
|
|
101
|
+
readManifest: async () => {
|
|
102
|
+
throw new Error("ENOENT profiles.json");
|
|
103
|
+
},
|
|
104
|
+
activeProfile: async () => "default",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
expect(await loadActiveClosure("/proj", { deps })).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("no selectable profile (two profiles, none active) yields null", async () => {
|
|
111
|
+
const deps: Partial<ActiveClosureDeps> = {
|
|
112
|
+
readManifest: async () => manifest(),
|
|
113
|
+
activeProfile: async () => null,
|
|
114
|
+
resolveProfile: async (_m, id) => closureFor(id),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
expect(await loadActiveClosure("/proj", { deps })).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLACK-BOX: R6 — convergence. A no-op installer run (brew "already installed",
|
|
3
|
+
* uv reinstalling the same version) is never counted as changed, unknown tools
|
|
4
|
+
* are never applied, and a second plan after a successful apply has no work
|
|
5
|
+
* for what the first run changed. Written from requirements.md and §8.2/§8.5/§8.8.
|
|
6
|
+
*
|
|
7
|
+
* IO is injected through the contracts' seams: applyBins(run, probe),
|
|
8
|
+
* applyPlugins(deps), applyPlan({ run, probe, deps }). No mock.module.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { summaryLine } from "../cli/update-view.js";
|
|
12
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
13
|
+
import type { ToolFacts } from "../services/tool-probe.js";
|
|
14
|
+
import {
|
|
15
|
+
type PluginApplyDeps,
|
|
16
|
+
SILENT_REPORTER,
|
|
17
|
+
applyBins,
|
|
18
|
+
applyPlan,
|
|
19
|
+
applyPlugins,
|
|
20
|
+
planWorkItems,
|
|
21
|
+
} from "../services/update-engine.js";
|
|
22
|
+
import {
|
|
23
|
+
type UpdatePlan,
|
|
24
|
+
classifyPlugin,
|
|
25
|
+
indexCatalog,
|
|
26
|
+
planBinUpdates,
|
|
27
|
+
planHasWork,
|
|
28
|
+
} from "../services/update-plan.js";
|
|
29
|
+
import type { ResolvedBin } from "../types/index.js";
|
|
30
|
+
import type { Command } from "../utils/command-utils.js";
|
|
31
|
+
|
|
32
|
+
function facts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
present: true,
|
|
36
|
+
resolvedPath: `/usr/local/bin/${name}`,
|
|
37
|
+
installed: "1.0.0",
|
|
38
|
+
latest: null,
|
|
39
|
+
behind: null,
|
|
40
|
+
managed: true,
|
|
41
|
+
sources: ["path"],
|
|
42
|
+
...over,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SPECS: Record<string, ResolvedBin> = {
|
|
47
|
+
codex: {
|
|
48
|
+
name: "codex",
|
|
49
|
+
via: "npm",
|
|
50
|
+
package: "@openai/codex",
|
|
51
|
+
sources: ["profile"],
|
|
52
|
+
},
|
|
53
|
+
mnemex: {
|
|
54
|
+
name: "mnemex",
|
|
55
|
+
via: "bun",
|
|
56
|
+
version: "1.0.0",
|
|
57
|
+
sources: ["profile"],
|
|
58
|
+
},
|
|
59
|
+
tmux: {
|
|
60
|
+
name: "tmux",
|
|
61
|
+
via: "brew",
|
|
62
|
+
check: "tmux -V",
|
|
63
|
+
sources: ["terminal@magus"],
|
|
64
|
+
},
|
|
65
|
+
gemini: {
|
|
66
|
+
name: "gemini",
|
|
67
|
+
via: "npm",
|
|
68
|
+
package: "@google/gemini-cli",
|
|
69
|
+
sources: ["profile"],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function toolPlan(names: string[], before: ToolFacts[]): UpdatePlan {
|
|
74
|
+
const bins = names.map((n) => SPECS[n] as ResolvedBin);
|
|
75
|
+
return {
|
|
76
|
+
profileId: "default",
|
|
77
|
+
plugins: [],
|
|
78
|
+
bins: planBinUpdates(bins, before),
|
|
79
|
+
skills: [],
|
|
80
|
+
binSpecs: new Map(bins.map((b) => [b.name, b])),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function recorder(ok = true) {
|
|
85
|
+
const ran: Command[] = [];
|
|
86
|
+
return {
|
|
87
|
+
ran,
|
|
88
|
+
run: async (c: Command) => {
|
|
89
|
+
ran.push(c);
|
|
90
|
+
return ok;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function probeFrom(after: Record<string, ToolFacts>) {
|
|
96
|
+
return async (spec: ResolvedBin) =>
|
|
97
|
+
after[spec.name] ?? facts(spec.name, { installed: null });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const CODEX_BEHIND = facts("codex", { installed: "0.40.0", latest: "0.41.0" });
|
|
101
|
+
|
|
102
|
+
describe("applyBins read-back (R6)", () => {
|
|
103
|
+
test("installer exits 0 but nothing moved (brew 'already installed'): unchanged, not changed", async () => {
|
|
104
|
+
const plan = toolPlan(["codex"], [CODEX_BEHIND]);
|
|
105
|
+
const r = recorder();
|
|
106
|
+
|
|
107
|
+
const res = await applyBins(
|
|
108
|
+
plan,
|
|
109
|
+
SILENT_REPORTER,
|
|
110
|
+
r.run,
|
|
111
|
+
probeFrom({ codex: CODEX_BEHIND }),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(plan.bins[0]?.action).toBe("upgrade");
|
|
115
|
+
expect(r.ran.length).toBe(1);
|
|
116
|
+
expect({
|
|
117
|
+
ok: res.ok,
|
|
118
|
+
unchanged: res.unchanged,
|
|
119
|
+
failed: res.failed,
|
|
120
|
+
}).toEqual({
|
|
121
|
+
ok: 0,
|
|
122
|
+
unchanged: ["codex"],
|
|
123
|
+
failed: [],
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("installer exits 0 and read-back shows the new version: changed", async () => {
|
|
128
|
+
const plan = toolPlan(["codex"], [CODEX_BEHIND]);
|
|
129
|
+
const r = recorder();
|
|
130
|
+
const after = facts("codex", { installed: "0.41.0", latest: "0.41.0" });
|
|
131
|
+
|
|
132
|
+
const res = await applyBins(
|
|
133
|
+
plan,
|
|
134
|
+
SILENT_REPORTER,
|
|
135
|
+
r.run,
|
|
136
|
+
probeFrom({ codex: after }),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect({
|
|
140
|
+
ok: res.ok,
|
|
141
|
+
unchanged: res.unchanged,
|
|
142
|
+
failed: res.failed,
|
|
143
|
+
}).toEqual({
|
|
144
|
+
ok: 1,
|
|
145
|
+
unchanged: [],
|
|
146
|
+
failed: [],
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("current and unknown tools are never run and never counted", async () => {
|
|
151
|
+
const plan = toolPlan(
|
|
152
|
+
["tmux", "gemini"],
|
|
153
|
+
[
|
|
154
|
+
facts("tmux", { installed: "3.7c", behind: false }),
|
|
155
|
+
facts("gemini", { installed: "0.5.0", failure: "timed out after 8s" }),
|
|
156
|
+
],
|
|
157
|
+
);
|
|
158
|
+
const r = recorder();
|
|
159
|
+
|
|
160
|
+
const res = await applyBins(plan, SILENT_REPORTER, r.run, probeFrom({}));
|
|
161
|
+
|
|
162
|
+
expect(plan.bins.map((b) => b.action)).toEqual(["current", "unknown"]);
|
|
163
|
+
expect(planWorkItems(plan)).toEqual([]);
|
|
164
|
+
expect(r.ran).toEqual([]);
|
|
165
|
+
expect({
|
|
166
|
+
ok: res.ok,
|
|
167
|
+
unchanged: res.unchanged,
|
|
168
|
+
failed: res.failed,
|
|
169
|
+
}).toEqual({
|
|
170
|
+
ok: 0,
|
|
171
|
+
unchanged: [],
|
|
172
|
+
failed: [],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("installer exits non-zero: failed, not changed", async () => {
|
|
177
|
+
const plan = toolPlan(["codex"], [CODEX_BEHIND]);
|
|
178
|
+
const r = recorder(false);
|
|
179
|
+
|
|
180
|
+
const res = await applyBins(
|
|
181
|
+
plan,
|
|
182
|
+
SILENT_REPORTER,
|
|
183
|
+
r.run,
|
|
184
|
+
probeFrom({ codex: CODEX_BEHIND }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(res.ok).toBe(0);
|
|
188
|
+
expect(res.failed).toContain("codex");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("after an upgrade to the pin, the next plan has no tool work", async () => {
|
|
192
|
+
const before = facts("mnemex", { installed: "0.9.0", latest: "1.2.0" });
|
|
193
|
+
const after = facts("mnemex", { installed: "1.0.0", latest: "1.2.0" });
|
|
194
|
+
const plan1 = toolPlan(["mnemex"], [before]);
|
|
195
|
+
const r = recorder();
|
|
196
|
+
|
|
197
|
+
const res = await applyBins(
|
|
198
|
+
plan1,
|
|
199
|
+
SILENT_REPORTER,
|
|
200
|
+
r.run,
|
|
201
|
+
probeFrom({ mnemex: after }),
|
|
202
|
+
);
|
|
203
|
+
const plan2 = toolPlan(["mnemex"], [after]);
|
|
204
|
+
|
|
205
|
+
expect(plan1.bins[0]?.action).toBe("upgrade");
|
|
206
|
+
expect(res.ok).toBe(1);
|
|
207
|
+
expect(plan2.bins[0]?.action).toBe("current");
|
|
208
|
+
expect(planWorkItems(plan2)).toEqual([]);
|
|
209
|
+
expect(planHasWork(plan2)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── plugins ────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function designer(installed: string): PluginInfo {
|
|
216
|
+
return {
|
|
217
|
+
id: "designer@magus",
|
|
218
|
+
name: "designer",
|
|
219
|
+
version: "2.0.0",
|
|
220
|
+
description: "",
|
|
221
|
+
marketplace: "magus",
|
|
222
|
+
marketplaceDisplay: "magus",
|
|
223
|
+
enabled: true,
|
|
224
|
+
installedVersion: installed,
|
|
225
|
+
projectScope: { enabled: true, version: installed },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pluginDeps(readBack: string | null) {
|
|
230
|
+
const calls: string[] = [];
|
|
231
|
+
const deps: PluginApplyDeps = {
|
|
232
|
+
install: async (id, scope) => {
|
|
233
|
+
calls.push(`install ${id} ${scope}`);
|
|
234
|
+
},
|
|
235
|
+
update: async (id, scope) => {
|
|
236
|
+
calls.push(`update ${id} ${scope}`);
|
|
237
|
+
},
|
|
238
|
+
repair: async (id, scope) => {
|
|
239
|
+
calls.push(`repair ${id} ${scope}`);
|
|
240
|
+
},
|
|
241
|
+
readInstalled: async () => readBack,
|
|
242
|
+
saveInstalled: async () => {},
|
|
243
|
+
};
|
|
244
|
+
return { calls, deps };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
describe("applyPlugins read-back (R6)", () => {
|
|
248
|
+
test("update exits 0 but the registry still shows the before-version: unchanged, not changed", async () => {
|
|
249
|
+
const item = classifyPlugin(
|
|
250
|
+
"designer@magus",
|
|
251
|
+
"latest",
|
|
252
|
+
indexCatalog([designer("1.5.0")]),
|
|
253
|
+
);
|
|
254
|
+
const { calls, deps } = pluginDeps("1.5.0");
|
|
255
|
+
|
|
256
|
+
const res = await applyPlugins([item], "/proj", deps, SILENT_REPORTER);
|
|
257
|
+
|
|
258
|
+
expect(item.action).toBe("update");
|
|
259
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
260
|
+
expect({ ok: res.ok, unchanged: res.unchanged }).toEqual({
|
|
261
|
+
ok: 0,
|
|
262
|
+
unchanged: ["designer@magus"],
|
|
263
|
+
});
|
|
264
|
+
expect(res.failed).toEqual([]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("update moves the registry to the target: changed, and the next classification is current", async () => {
|
|
268
|
+
const item = classifyPlugin(
|
|
269
|
+
"designer@magus",
|
|
270
|
+
"latest",
|
|
271
|
+
indexCatalog([designer("1.5.0")]),
|
|
272
|
+
);
|
|
273
|
+
const { deps } = pluginDeps("2.0.0");
|
|
274
|
+
|
|
275
|
+
const res = await applyPlugins([item], "/proj", deps, SILENT_REPORTER);
|
|
276
|
+
const next = classifyPlugin(
|
|
277
|
+
"designer@magus",
|
|
278
|
+
"latest",
|
|
279
|
+
indexCatalog([designer("2.0.0")]),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect({ ok: res.ok, unchanged: res.unchanged }).toEqual({
|
|
283
|
+
ok: 1,
|
|
284
|
+
unchanged: [],
|
|
285
|
+
});
|
|
286
|
+
expect(next.action).toBe("current");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("applyPlan totals (R6)", () => {
|
|
291
|
+
test("changed counts only real movement; the no-op is listed as unchanged", async () => {
|
|
292
|
+
const plan = toolPlan(
|
|
293
|
+
["codex", "mnemex", "tmux"],
|
|
294
|
+
[
|
|
295
|
+
CODEX_BEHIND,
|
|
296
|
+
facts("mnemex", { installed: "0.9.0", latest: "1.2.0" }),
|
|
297
|
+
facts("tmux", { installed: "3.7c", behind: false }),
|
|
298
|
+
],
|
|
299
|
+
);
|
|
300
|
+
const r = recorder();
|
|
301
|
+
const { deps } = pluginDeps(null);
|
|
302
|
+
|
|
303
|
+
const out = await applyPlan(plan, "/proj", {
|
|
304
|
+
deps,
|
|
305
|
+
report: SILENT_REPORTER,
|
|
306
|
+
run: r.run,
|
|
307
|
+
probe: probeFrom({
|
|
308
|
+
codex: CODEX_BEHIND,
|
|
309
|
+
mnemex: facts("mnemex", { installed: "1.0.0", latest: "1.2.0" }),
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(r.ran.length).toBe(2); // tmux is current: never run
|
|
314
|
+
expect({
|
|
315
|
+
changed: out.changed,
|
|
316
|
+
unchanged: out.unchanged,
|
|
317
|
+
failed: out.failed,
|
|
318
|
+
}).toEqual({
|
|
319
|
+
changed: 1,
|
|
320
|
+
unchanged: ["codex"],
|
|
321
|
+
failed: [],
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("summaryLine never reports a no-op as changed", () => {
|
|
327
|
+
test("three no-op runs do not read as '3 changed', and the unchanged count is visible", () => {
|
|
328
|
+
const withUnchanged = summaryLine(0, 5, 1, 0, 1200, 3).join("\n");
|
|
329
|
+
const without = summaryLine(0, 5, 1, 0, 1200, 0).join("\n");
|
|
330
|
+
|
|
331
|
+
expect(withUnchanged).not.toMatch(/\b3 changed\b/);
|
|
332
|
+
expect(withUnchanged).not.toBe(without);
|
|
333
|
+
});
|
|
334
|
+
});
|