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
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLACK-BOX parity test (R7 / R2 / R3). Written from requirements.md and the
|
|
3
|
+
* architecture §7/§8 contracts only — no implementation bodies were read.
|
|
4
|
+
*
|
|
5
|
+
* ONE fixture (catalog + registry-derived installed versions + profile pins +
|
|
6
|
+
* tool facts) is fed through the CLI planning path (`planPluginUpdates`,
|
|
7
|
+
* `planBinUpdates`) and the TUI state path (`classifyPluginRows` →
|
|
8
|
+
* `buildPluginBrowserItems` → `pluginRowText`; `specForCatalogTool` →
|
|
9
|
+
* `buildToolRow`). For every shared id the state and the reason string must be
|
|
10
|
+
* identical.
|
|
11
|
+
*
|
|
12
|
+
* Real values: the 5 `claude-plugins-official` rows carry the registry versions
|
|
13
|
+
* read (read-only) from ~/.claude/plugins/installed_plugins.json for project
|
|
14
|
+
* /Users/jack/mag/claudish on 2026-09-12; the 17 declared plugins are the real
|
|
15
|
+
* /Users/jack/mag/claudish/.claude/profiles.json `default` profile; tmux,
|
|
16
|
+
* browser-use and mcp facts are validation/baseline-tools.md. Rows marked
|
|
17
|
+
* _synthetic exist only to reach states the real data lacks.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, test } from "bun:test";
|
|
20
|
+
import { cliTools } from "../data/cli-tools.js";
|
|
21
|
+
import { selectProfileId } from "../services/active-closure.js";
|
|
22
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
23
|
+
import { resolveProfile } from "../services/resolver.js";
|
|
24
|
+
import { type ToolFacts, specForCatalogTool } from "../services/tool-probe.js";
|
|
25
|
+
import {
|
|
26
|
+
type BinUpdateItem,
|
|
27
|
+
type PluginUpdateItem,
|
|
28
|
+
classifyPluginRows,
|
|
29
|
+
countPluginStates,
|
|
30
|
+
planBinUpdates,
|
|
31
|
+
planPluginUpdates,
|
|
32
|
+
} from "../services/update-plan.js";
|
|
33
|
+
import type {
|
|
34
|
+
BinRequirement,
|
|
35
|
+
Marketplace,
|
|
36
|
+
ProfileManifest,
|
|
37
|
+
ResolvedClosure,
|
|
38
|
+
} from "../types/index.js";
|
|
39
|
+
import { buildToolRow } from "../ui/adapters/cliToolsAdapter.js";
|
|
40
|
+
import {
|
|
41
|
+
type PluginPluginItem,
|
|
42
|
+
buildPluginBrowserItems,
|
|
43
|
+
} from "../ui/adapters/pluginsAdapter.js";
|
|
44
|
+
import { pluginRowText } from "../ui/renderers/pluginRowText.js";
|
|
45
|
+
|
|
46
|
+
// ── fixture ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const OFFICIAL = "claude-plugins-official";
|
|
49
|
+
|
|
50
|
+
/** Real registry versions (project scope, /Users/jack/mag/claudish). Catalog publishes none. */
|
|
51
|
+
const REAL_OFFICIAL: Record<string, string> = {
|
|
52
|
+
[`code-review@${OFFICIAL}`]: "3ea32df27be7",
|
|
53
|
+
[`code-simplifier@${OFFICIAL}`]: "1.0.0",
|
|
54
|
+
[`explanatory-output-style@${OFFICIAL}`]: "1.0.0",
|
|
55
|
+
[`playground@${OFFICIAL}`]: "3b600518a637",
|
|
56
|
+
[`skill-creator@${OFFICIAL}`]: "517b2fcd1b60",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const NO_PUBLISHED_VERSION_NOTE =
|
|
60
|
+
"its marketplace publishes no version for this plugin, so there is nothing to compare";
|
|
61
|
+
|
|
62
|
+
function marketplace(
|
|
63
|
+
name: string,
|
|
64
|
+
extra: Partial<Marketplace> = {},
|
|
65
|
+
): Marketplace {
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
displayName: name,
|
|
69
|
+
source: { source: "github", repo: `example/${name}` },
|
|
70
|
+
description: "",
|
|
71
|
+
...extra,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeMarketplaces(): Marketplace[] {
|
|
76
|
+
return [
|
|
77
|
+
marketplace(OFFICIAL, { official: true }),
|
|
78
|
+
marketplace("magus", { owned: true }),
|
|
79
|
+
marketplace("magus-marketing", { owned: true }),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function row(
|
|
84
|
+
id: string,
|
|
85
|
+
catalogVersion: string | null,
|
|
86
|
+
installed: string | null,
|
|
87
|
+
extra: Partial<PluginInfo> = {},
|
|
88
|
+
): PluginInfo {
|
|
89
|
+
const at = id.indexOf("@");
|
|
90
|
+
const name = id.slice(0, at);
|
|
91
|
+
const mp = id.slice(at + 1);
|
|
92
|
+
return {
|
|
93
|
+
id,
|
|
94
|
+
name,
|
|
95
|
+
version: catalogVersion,
|
|
96
|
+
description: "",
|
|
97
|
+
marketplace: mp,
|
|
98
|
+
marketplaceDisplay: mp,
|
|
99
|
+
enabled: installed !== null,
|
|
100
|
+
...(installed !== null
|
|
101
|
+
? {
|
|
102
|
+
installedVersion: installed,
|
|
103
|
+
projectScope: { enabled: true, version: installed },
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
...extra,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function makeCatalog(): PluginInfo[] {
|
|
111
|
+
return [
|
|
112
|
+
// the 5 real unversioned official plugins
|
|
113
|
+
...Object.entries(REAL_OFFICIAL).map(([id, v]) => row(id, null, v)),
|
|
114
|
+
row(`typescript-lsp@${OFFICIAL}`, "1.0.0", "1.0.0"),
|
|
115
|
+
// _synthetic: the real "0.0.0" registry value, unversioned in the catalog
|
|
116
|
+
row(`zero-version@${OFFICIAL}`, null, "0.0.0"),
|
|
117
|
+
// real magus ids from the profile
|
|
118
|
+
row("browser-use@magus", "1.4.0", "1.4.0"),
|
|
119
|
+
row("designer@magus", "2.0.0", "1.5.0"),
|
|
120
|
+
row("code-analysis@magus", "3.1.0", "3.1.0"),
|
|
121
|
+
row("multimodel@magus", "2.2.0", "2.2.0"),
|
|
122
|
+
row("dev@magus", "7.4.0", "7.4.0"),
|
|
123
|
+
row("terminal@magus", "1.3.0", "1.3.0"),
|
|
124
|
+
row("madbench@magus", "1.0.0", "1.0.0", { contentStale: true }),
|
|
125
|
+
row("claudish@magus", "2.0.0", "2.0.0", {
|
|
126
|
+
updateCheckFailed: true,
|
|
127
|
+
catalogSource: "local-clone",
|
|
128
|
+
}),
|
|
129
|
+
row("mnemex@magus", "1.2.0", "1.2.0"),
|
|
130
|
+
row("bunjs@magus", "0.4.4", null),
|
|
131
|
+
row("setup@magus", "1.0.0", "1.0.0"),
|
|
132
|
+
// _synthetic orphans
|
|
133
|
+
row("seo@magus", null, "1.0.0", {
|
|
134
|
+
isOrphaned: true,
|
|
135
|
+
movedTo: "magus-marketing",
|
|
136
|
+
}),
|
|
137
|
+
row("legacy-tool@magus", null, "0.5.0", { isOrphaned: true }),
|
|
138
|
+
row("seo@magus-marketing", "1.1.0", null),
|
|
139
|
+
// _synthetic exact pins
|
|
140
|
+
row("pinned-behind@magus", "2.1.0", "2.0.0"),
|
|
141
|
+
row("pinned-unorderable@magus", "1.0.0", "3ea32df27be7"),
|
|
142
|
+
row("pinned-undeliverable@magus", "3.0.0", "1.0.0"),
|
|
143
|
+
// _synthetic TUI-only rows
|
|
144
|
+
row("installed-undeclared@magus", "1.0.0", "1.0.0"),
|
|
145
|
+
row("enabled-only@magus", "1.0.0", null, {
|
|
146
|
+
enabled: true,
|
|
147
|
+
userScope: { enabled: true },
|
|
148
|
+
}),
|
|
149
|
+
row("browse-only@magus", "1.0.0", null),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const REAL_PROFILE_PLUGINS = [
|
|
154
|
+
`code-review@${OFFICIAL}`,
|
|
155
|
+
`code-simplifier@${OFFICIAL}`,
|
|
156
|
+
`explanatory-output-style@${OFFICIAL}`,
|
|
157
|
+
`playground@${OFFICIAL}`,
|
|
158
|
+
`typescript-lsp@${OFFICIAL}`,
|
|
159
|
+
`skill-creator@${OFFICIAL}`,
|
|
160
|
+
"browser-use@magus",
|
|
161
|
+
"designer@magus",
|
|
162
|
+
"code-analysis@magus",
|
|
163
|
+
"multimodel@magus",
|
|
164
|
+
"dev@magus",
|
|
165
|
+
"terminal@magus",
|
|
166
|
+
"madbench@magus",
|
|
167
|
+
"claudish@magus",
|
|
168
|
+
"mnemex@magus",
|
|
169
|
+
"bunjs@magus",
|
|
170
|
+
"setup@magus",
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
function makeManifest(): ProfileManifest {
|
|
174
|
+
const plugins: Record<string, string> = {};
|
|
175
|
+
for (const id of REAL_PROFILE_PLUGINS) plugins[id] = "latest";
|
|
176
|
+
Object.assign(plugins, {
|
|
177
|
+
[`zero-version@${OFFICIAL}`]: "latest",
|
|
178
|
+
"seo@magus": "latest",
|
|
179
|
+
"legacy-tool@magus": "latest",
|
|
180
|
+
"gone@magus": "latest", // CLI-only: not in the catalog at all
|
|
181
|
+
"pinned-behind@magus": "2.1.0",
|
|
182
|
+
"pinned-unorderable@magus": "1.0.0",
|
|
183
|
+
"pinned-undeliverable@magus": "1.0.0",
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
version: 2,
|
|
187
|
+
profiles: {
|
|
188
|
+
default: {
|
|
189
|
+
name: "Default",
|
|
190
|
+
plugins,
|
|
191
|
+
cliTools: {
|
|
192
|
+
claudish: "latest",
|
|
193
|
+
mnemex: "1.0.0",
|
|
194
|
+
crush: "latest",
|
|
195
|
+
codex: "latest",
|
|
196
|
+
gemini: "latest",
|
|
197
|
+
qwen: "latest",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
other: { name: "Other", plugins: { "dev@magus": "latest" } },
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const PLUGIN_BINS: Record<string, BinRequirement[]> = {
|
|
206
|
+
"terminal@magus": [
|
|
207
|
+
{ name: "tmux", via: "brew", check: "tmux -V" },
|
|
208
|
+
{
|
|
209
|
+
name: "tmux-mcp",
|
|
210
|
+
via: "go",
|
|
211
|
+
module: "github.com/MadAppGang/tmux-mcp/v2",
|
|
212
|
+
version: "v2.0.0",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
"browser-use@magus": [
|
|
216
|
+
{ name: "browser-use", via: "pip" },
|
|
217
|
+
{ name: "mcp", via: "pip" },
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
async function fixtureBins(pluginId: string): Promise<BinRequirement[]> {
|
|
222
|
+
return (PLUGIN_BINS[pluginId] ?? []).map((b) => ({ ...b }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function facts(name: string, over: Partial<ToolFacts>): ToolFacts {
|
|
226
|
+
return {
|
|
227
|
+
name,
|
|
228
|
+
present: true,
|
|
229
|
+
resolvedPath: `/usr/local/bin/${name}`,
|
|
230
|
+
installed: null,
|
|
231
|
+
latest: null,
|
|
232
|
+
behind: null,
|
|
233
|
+
managed: true,
|
|
234
|
+
sources: ["path"],
|
|
235
|
+
...over,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function makeFacts(): ToolFacts[] {
|
|
240
|
+
return [
|
|
241
|
+
// baseline-tools.md: brew outdated tmux → [], tmux 3.7c
|
|
242
|
+
facts("tmux", {
|
|
243
|
+
resolvedPath: "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
|
|
244
|
+
installed: "3.7c",
|
|
245
|
+
behind: false,
|
|
246
|
+
sources: ["path", "brew"],
|
|
247
|
+
}),
|
|
248
|
+
// baseline-tools.md: absent from `uv tool list --outdated`
|
|
249
|
+
facts("browser-use", {
|
|
250
|
+
resolvedPath:
|
|
251
|
+
"/Users/jack/.local/share/uv/tools/browser-use/bin/browser-use",
|
|
252
|
+
installed: "0.13.10",
|
|
253
|
+
behind: false,
|
|
254
|
+
sources: ["path", "uv"],
|
|
255
|
+
}),
|
|
256
|
+
facts("mcp", {
|
|
257
|
+
resolvedPath: "/Users/jack/.local/share/uv/tools/mcp/bin/mcp",
|
|
258
|
+
installed: "2.2.0",
|
|
259
|
+
behind: false,
|
|
260
|
+
sources: ["path", "uv"],
|
|
261
|
+
}),
|
|
262
|
+
facts("tmux-mcp", {
|
|
263
|
+
resolvedPath: "/Users/jack/go/bin/tmux-mcp",
|
|
264
|
+
installed: "v2.0.0",
|
|
265
|
+
sources: ["path", "go-buildinfo"],
|
|
266
|
+
}),
|
|
267
|
+
facts("claudish", {
|
|
268
|
+
resolvedPath:
|
|
269
|
+
"/Users/jack/.bun/install/global/node_modules/claudish/dist/index.js",
|
|
270
|
+
installed: "9.3.0",
|
|
271
|
+
latest: "9.3.0",
|
|
272
|
+
sources: ["path", "bun-global", "npm-registry"],
|
|
273
|
+
}),
|
|
274
|
+
facts("mnemex", {
|
|
275
|
+
resolvedPath:
|
|
276
|
+
"/Users/jack/.bun/install/global/node_modules/mnemex/dist/index.js",
|
|
277
|
+
installed: "0.9.0",
|
|
278
|
+
latest: "1.2.0",
|
|
279
|
+
sources: ["path", "bun-global", "npm-registry"],
|
|
280
|
+
}),
|
|
281
|
+
facts("crush", {
|
|
282
|
+
resolvedPath:
|
|
283
|
+
"/Users/jack/.nvm/versions/node/v22.14.0/lib/node_modules/@charmland/crush/bin/crush",
|
|
284
|
+
installed: "0.7.0",
|
|
285
|
+
latest: "0.8.0",
|
|
286
|
+
managed: false,
|
|
287
|
+
sources: ["path", "npm-registry"],
|
|
288
|
+
}),
|
|
289
|
+
facts("codex", {
|
|
290
|
+
installed: "0.40.0",
|
|
291
|
+
latest: "0.41.0",
|
|
292
|
+
sources: ["path", "npm-global", "npm-registry"],
|
|
293
|
+
}),
|
|
294
|
+
facts("gemini", {
|
|
295
|
+
installed: "0.5.0",
|
|
296
|
+
latest: null,
|
|
297
|
+
behind: null,
|
|
298
|
+
failure: "timed out after 8s",
|
|
299
|
+
sources: ["path", "npm-global"],
|
|
300
|
+
}),
|
|
301
|
+
facts("qwen", {
|
|
302
|
+
present: false,
|
|
303
|
+
resolvedPath: null,
|
|
304
|
+
installed: null,
|
|
305
|
+
latest: "0.1.0",
|
|
306
|
+
managed: null,
|
|
307
|
+
sources: ["npm-registry"],
|
|
308
|
+
}),
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── the two paths ──────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
interface Paths {
|
|
315
|
+
closure: ResolvedClosure;
|
|
316
|
+
cliPlugins: Map<string, PluginUpdateItem>;
|
|
317
|
+
tuiRows: Map<string, PluginPluginItem>;
|
|
318
|
+
sharedPluginIds: string[];
|
|
319
|
+
cliBins: Map<string, BinUpdateItem>;
|
|
320
|
+
tuiTools: Map<string, ReturnType<typeof buildToolRow>>;
|
|
321
|
+
sharedToolNames: string[];
|
|
322
|
+
catalog: PluginInfo[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function runBothPaths(): Promise<Paths> {
|
|
326
|
+
const manifest = makeManifest();
|
|
327
|
+
const catalog = makeCatalog();
|
|
328
|
+
const profileId = selectProfileId(
|
|
329
|
+
Object.keys(manifest.profiles),
|
|
330
|
+
undefined,
|
|
331
|
+
"default",
|
|
332
|
+
);
|
|
333
|
+
if (profileId === null) throw new Error("fixture: no profile selected");
|
|
334
|
+
const closure = await resolveProfile(manifest, profileId, {
|
|
335
|
+
binResolver: fixtureBins,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// CLI path
|
|
339
|
+
const cliPluginList = planPluginUpdates(
|
|
340
|
+
closure.plugins,
|
|
341
|
+
new Map(catalog.map((p) => [p.id, p])),
|
|
342
|
+
);
|
|
343
|
+
const cliPlugins = new Map(cliPluginList.map((i) => [i.pluginId, i]));
|
|
344
|
+
|
|
345
|
+
// TUI path
|
|
346
|
+
const statuses = classifyPluginRows(catalog, closure.plugins);
|
|
347
|
+
const items = buildPluginBrowserItems({
|
|
348
|
+
marketplaces: makeMarketplaces(),
|
|
349
|
+
plugins: catalog,
|
|
350
|
+
collapsedMarketplaces: new Set(),
|
|
351
|
+
statuses,
|
|
352
|
+
});
|
|
353
|
+
const tuiRows = new Map<string, PluginPluginItem>();
|
|
354
|
+
for (const it of items) {
|
|
355
|
+
if (it.kind === "plugin") tuiRows.set(it.plugin.id, it);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const sharedPluginIds = [...cliPlugins.keys()]
|
|
359
|
+
.filter((id) => tuiRows.has(id))
|
|
360
|
+
.sort();
|
|
361
|
+
|
|
362
|
+
// tools
|
|
363
|
+
const factList = makeFacts();
|
|
364
|
+
const cliBins = new Map(
|
|
365
|
+
planBinUpdates(closure.bins, factList).map((b) => [b.name, b]),
|
|
366
|
+
);
|
|
367
|
+
const factsByName = new Map(factList.map((f) => [f.name, f]));
|
|
368
|
+
const declared = new Set(closure.bins.map((b) => b.name));
|
|
369
|
+
const tuiTools = new Map<string, ReturnType<typeof buildToolRow>>();
|
|
370
|
+
for (const tool of cliTools) {
|
|
371
|
+
if (!declared.has(tool.name)) continue;
|
|
372
|
+
tuiTools.set(
|
|
373
|
+
tool.name,
|
|
374
|
+
buildToolRow(
|
|
375
|
+
tool,
|
|
376
|
+
specForCatalogTool(tool, closure.bins, undefined),
|
|
377
|
+
factsByName.get(tool.name),
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const sharedToolNames = [...tuiTools.keys()]
|
|
382
|
+
.filter((n) => cliBins.has(n))
|
|
383
|
+
.sort();
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
closure,
|
|
387
|
+
cliPlugins,
|
|
388
|
+
tuiRows,
|
|
389
|
+
sharedPluginIds,
|
|
390
|
+
cliBins,
|
|
391
|
+
tuiTools,
|
|
392
|
+
sharedToolNames,
|
|
393
|
+
catalog,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function pickPlugin(i: PluginUpdateItem | null | undefined) {
|
|
398
|
+
if (!i) return i;
|
|
399
|
+
return { action: i.action, reason: i.reason, note: i.note, target: i.target };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── assertions ─────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
405
|
+
test("the intersections are non-vacuous and include every reported official plugin", async () => {
|
|
406
|
+
const p = await runBothPaths();
|
|
407
|
+
|
|
408
|
+
expect(p.sharedPluginIds.length).toBeGreaterThanOrEqual(8);
|
|
409
|
+
for (const id of Object.keys(REAL_OFFICIAL)) {
|
|
410
|
+
expect(p.sharedPluginIds).toContain(id);
|
|
411
|
+
}
|
|
412
|
+
expect(p.sharedPluginIds).toContain(`zero-version@${OFFICIAL}`);
|
|
413
|
+
expect(p.sharedToolNames.length).toBeGreaterThanOrEqual(3);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("every shared plugin has the same action, reason, note and target in both front ends", async () => {
|
|
417
|
+
const p = await runBothPaths();
|
|
418
|
+
|
|
419
|
+
for (const id of p.sharedPluginIds) {
|
|
420
|
+
const cli = p.cliPlugins.get(id);
|
|
421
|
+
const tui = p.tuiRows.get(id)?.status;
|
|
422
|
+
expect({ id, s: pickPlugin(tui) }).toEqual({ id, s: pickPlugin(cli) });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("every non-current shared plugin row renders non-empty text containing the CLI note verbatim", async () => {
|
|
427
|
+
const p = await runBothPaths();
|
|
428
|
+
|
|
429
|
+
for (const id of p.sharedPluginIds) {
|
|
430
|
+
const cli = p.cliPlugins.get(id);
|
|
431
|
+
if (!cli || cli.action === "current") continue;
|
|
432
|
+
const row = p.tuiRows.get(id);
|
|
433
|
+
if (!row) throw new Error(`no TUI row for ${id}`);
|
|
434
|
+
const { text } = pluginRowText(row);
|
|
435
|
+
expect({ id, empty: text.trim() === "" }).toEqual({ id, empty: false });
|
|
436
|
+
if (cli.note) {
|
|
437
|
+
expect({ id, containsNote: text.includes(cli.note) }).toEqual({
|
|
438
|
+
id,
|
|
439
|
+
containsNote: true,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("the 5 reported official plugins are UNKNOWN with the same no-published-version reason on both paths", async () => {
|
|
446
|
+
const p = await runBothPaths();
|
|
447
|
+
|
|
448
|
+
for (const id of Object.keys(REAL_OFFICIAL)) {
|
|
449
|
+
const cli = p.cliPlugins.get(id);
|
|
450
|
+
const tui = p.tuiRows.get(id)?.status;
|
|
451
|
+
expect({ id, action: cli?.action, reason: cli?.reason }).toEqual({
|
|
452
|
+
id,
|
|
453
|
+
action: "unknown",
|
|
454
|
+
reason: "no-published-version",
|
|
455
|
+
});
|
|
456
|
+
expect(cli?.note ?? "").toContain(NO_PUBLISHED_VERSION_NOTE);
|
|
457
|
+
expect(tui?.note).toBe(cli?.note);
|
|
458
|
+
const row = p.tuiRows.get(id);
|
|
459
|
+
if (!row) throw new Error(`no TUI row for ${id}`);
|
|
460
|
+
expect(pluginRowText(row).text).toContain(NO_PUBLISHED_VERSION_NOTE);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("each fixture row has its expected state on both paths", async () => {
|
|
465
|
+
const p = await runBothPaths();
|
|
466
|
+
const expected: Record<
|
|
467
|
+
string,
|
|
468
|
+
{ action: string; reason?: string; target?: string | null }
|
|
469
|
+
> = {
|
|
470
|
+
[`zero-version@${OFFICIAL}`]: { action: "unknown" },
|
|
471
|
+
[`typescript-lsp@${OFFICIAL}`]: {
|
|
472
|
+
action: "current",
|
|
473
|
+
reason: "up-to-date",
|
|
474
|
+
},
|
|
475
|
+
"browser-use@magus": { action: "current", reason: "up-to-date" },
|
|
476
|
+
"dev@magus": { action: "current", reason: "up-to-date" },
|
|
477
|
+
"designer@magus": { action: "update", reason: "behind", target: "2.0.0" },
|
|
478
|
+
"bunjs@magus": { action: "install", reason: "absent" },
|
|
479
|
+
"madbench@magus": { action: "repair", reason: "content-stale" },
|
|
480
|
+
"claudish@magus": {
|
|
481
|
+
action: "unknown",
|
|
482
|
+
reason: "catalog-unverified",
|
|
483
|
+
target: null,
|
|
484
|
+
},
|
|
485
|
+
"seo@magus": { action: "unknown", reason: "moved", target: null },
|
|
486
|
+
"legacy-tool@magus": {
|
|
487
|
+
action: "unknown",
|
|
488
|
+
reason: "not-offered",
|
|
489
|
+
target: null,
|
|
490
|
+
},
|
|
491
|
+
"pinned-behind@magus": {
|
|
492
|
+
action: "update",
|
|
493
|
+
reason: "behind",
|
|
494
|
+
target: "2.1.0",
|
|
495
|
+
},
|
|
496
|
+
"pinned-unorderable@magus": {
|
|
497
|
+
action: "unknown",
|
|
498
|
+
reason: "unorderable",
|
|
499
|
+
target: null,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
for (const [id, want] of Object.entries(expected)) {
|
|
504
|
+
for (const [side, item] of [
|
|
505
|
+
["cli", p.cliPlugins.get(id)],
|
|
506
|
+
["tui", p.tuiRows.get(id)?.status],
|
|
507
|
+
] as const) {
|
|
508
|
+
const got: Record<string, unknown> = { id, side, action: item?.action };
|
|
509
|
+
const exp: Record<string, unknown> = { id, side, action: want.action };
|
|
510
|
+
if (want.reason !== undefined) {
|
|
511
|
+
got.reason = item?.reason;
|
|
512
|
+
exp.reason = want.reason;
|
|
513
|
+
}
|
|
514
|
+
if (want.target !== undefined) {
|
|
515
|
+
got.target = item?.target;
|
|
516
|
+
exp.target = want.target;
|
|
517
|
+
}
|
|
518
|
+
expect(got).toEqual(exp);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const undeliverable = p.cliPlugins.get("pinned-undeliverable@magus");
|
|
523
|
+
expect(undeliverable?.reason).toBe("pin-undeliverable");
|
|
524
|
+
expect(["current", "update"]).toContain(undeliverable?.action ?? "");
|
|
525
|
+
expect(undeliverable?.note).toBeTruthy();
|
|
526
|
+
|
|
527
|
+
// declared but absent from the catalog: a CLI item, never a TUI row
|
|
528
|
+
expect(p.cliPlugins.get("gone@magus")?.action).toBe("unknown");
|
|
529
|
+
expect(p.tuiRows.has("gone@magus")).toBe(false);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("TUI-only rows: installed or enabled rows get a status, browse-only rows get none", async () => {
|
|
533
|
+
const p = await runBothPaths();
|
|
534
|
+
|
|
535
|
+
expect(p.tuiRows.get("installed-undeclared@magus")?.status?.action).toBe(
|
|
536
|
+
"current",
|
|
537
|
+
);
|
|
538
|
+
const enabledOnly = p.tuiRows.get("enabled-only@magus")?.status;
|
|
539
|
+
expect({
|
|
540
|
+
action: enabledOnly?.action,
|
|
541
|
+
reason: enabledOnly?.reason,
|
|
542
|
+
}).toEqual({
|
|
543
|
+
action: "install",
|
|
544
|
+
reason: "enabled-not-installed",
|
|
545
|
+
});
|
|
546
|
+
const enabledRow = p.tuiRows.get("enabled-only@magus");
|
|
547
|
+
if (!enabledRow) throw new Error("no enabled-only row");
|
|
548
|
+
expect(pluginRowText(enabledRow).text).not.toContain("profile declares it");
|
|
549
|
+
expect(p.tuiRows.get("browse-only@magus")?.status).toBeNull();
|
|
550
|
+
expect(p.tuiRows.get("seo@magus-marketing")?.status).toBeNull();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("per-state counts over the shared set are equal on both paths and match the fixture", async () => {
|
|
554
|
+
const p = await runBothPaths();
|
|
555
|
+
const cliItems = p.sharedPluginIds.map(
|
|
556
|
+
(id) => p.cliPlugins.get(id) as PluginUpdateItem,
|
|
557
|
+
);
|
|
558
|
+
const tuiItems = p.sharedPluginIds.map(
|
|
559
|
+
(id) => p.tuiRows.get(id)?.status as PluginUpdateItem,
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const cliCounts = countPluginStates(cliItems);
|
|
563
|
+
expect(countPluginStates(tuiItems)).toEqual(cliCounts);
|
|
564
|
+
expect(cliCounts.install).toBe(1); // bunjs
|
|
565
|
+
expect(cliCounts.repair).toBe(1); // madbench
|
|
566
|
+
// 5 official + zero-version + claudish (unverified) + 2 orphans + unorderable
|
|
567
|
+
expect(cliCounts.unknown).toBe(10);
|
|
568
|
+
expect(cliCounts.update).toBeGreaterThanOrEqual(2); // designer, pinned-behind
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("every shared tool has the same state, reason, note and target in both front ends", async () => {
|
|
572
|
+
const p = await runBothPaths();
|
|
573
|
+
|
|
574
|
+
for (const name of p.sharedToolNames) {
|
|
575
|
+
const cli = p.cliBins.get(name);
|
|
576
|
+
const tui = p.tuiTools.get(name);
|
|
577
|
+
expect({ name, checking: tui?.checking }).toEqual({
|
|
578
|
+
name,
|
|
579
|
+
checking: false,
|
|
580
|
+
});
|
|
581
|
+
expect({
|
|
582
|
+
name,
|
|
583
|
+
state: tui?.status?.state,
|
|
584
|
+
reason: tui?.status?.reason,
|
|
585
|
+
note: tui?.status?.note,
|
|
586
|
+
target: tui?.status?.target,
|
|
587
|
+
}).toEqual({
|
|
588
|
+
name,
|
|
589
|
+
state: cli?.action,
|
|
590
|
+
reason: cli?.reason,
|
|
591
|
+
note: cli?.note,
|
|
592
|
+
target: cli?.target,
|
|
593
|
+
});
|
|
594
|
+
// the TUI judges a declared tool against the closure's own spec
|
|
595
|
+
const declared = p.closure.bins.find((b) => b.name === name);
|
|
596
|
+
expect({ name, via: tui?.spec.via, version: tui?.spec.version }).toEqual({
|
|
597
|
+
name,
|
|
598
|
+
via: declared?.via,
|
|
599
|
+
version: declared?.version,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("each fixture tool has its expected state; the user's looping trio is current", async () => {
|
|
605
|
+
const p = await runBothPaths();
|
|
606
|
+
const expected: Record<
|
|
607
|
+
string,
|
|
608
|
+
{ action: string; reason?: string; target?: string }
|
|
609
|
+
> = {
|
|
610
|
+
tmux: { action: "current" },
|
|
611
|
+
"browser-use": { action: "current" },
|
|
612
|
+
mcp: { action: "current" },
|
|
613
|
+
"tmux-mcp": { action: "current", reason: "at-pin" },
|
|
614
|
+
claudish: { action: "current" },
|
|
615
|
+
mnemex: { action: "upgrade", reason: "behind-pin", target: "1.0.0" },
|
|
616
|
+
codex: { action: "upgrade", reason: "behind", target: "0.41.0" },
|
|
617
|
+
gemini: { action: "unknown" },
|
|
618
|
+
qwen: { action: "install", reason: "absent" },
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
for (const [name, want] of Object.entries(expected)) {
|
|
622
|
+
const item = p.cliBins.get(name);
|
|
623
|
+
const got: Record<string, unknown> = { name, action: item?.action };
|
|
624
|
+
const exp: Record<string, unknown> = { name, action: want.action };
|
|
625
|
+
if (want.reason) {
|
|
626
|
+
got.reason = item?.reason;
|
|
627
|
+
exp.reason = want.reason;
|
|
628
|
+
}
|
|
629
|
+
if (want.target) {
|
|
630
|
+
got.target = item?.target;
|
|
631
|
+
exp.target = want.target;
|
|
632
|
+
}
|
|
633
|
+
expect(got).toEqual(exp);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const crush = p.cliBins.get("crush");
|
|
637
|
+
expect(crush?.reason).toBe("not-managed");
|
|
638
|
+
expect(crush?.action).not.toBe("upgrade");
|
|
639
|
+
expect(p.cliBins.get("gemini")?.note).toBeTruthy();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("planning the same fixture twice yields identical plans", async () => {
|
|
643
|
+
const a = await runBothPaths();
|
|
644
|
+
const b = await runBothPaths();
|
|
645
|
+
|
|
646
|
+
expect([...b.cliPlugins.values()]).toEqual([...a.cliPlugins.values()]);
|
|
647
|
+
expect([...b.cliBins.values()]).toEqual([...a.cliBins.values()]);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe("fixture discriminates the pre-change logic", () => {
|
|
652
|
+
/** requirements.md, root causes: `present ? (bin.version ? "current" : "upgrade") : "install"`. */
|
|
653
|
+
function legacyPlanBin(present: boolean, pinned: boolean): string {
|
|
654
|
+
return present ? (pinned ? "current" : "upgrade") : "install";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Pre-change TUI row state from the PluginInfo flags alone (requirements: "reads hasUpdate / updateCheckFailed"). */
|
|
658
|
+
function legacyFlagState(info: PluginInfo): string {
|
|
659
|
+
if (info.updateCheckFailed) return "unknown";
|
|
660
|
+
if (info.hasUpdate) return "update";
|
|
661
|
+
return "healthy";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
test("legacy bin rule upgrades tmux, browser-use and mcp on every run; the new plan says current", async () => {
|
|
665
|
+
const p = await runBothPaths();
|
|
666
|
+
|
|
667
|
+
for (const name of ["tmux", "browser-use", "mcp"]) {
|
|
668
|
+
const spec = p.closure.bins.find((b) => b.name === name);
|
|
669
|
+
expect(legacyPlanBin(true, Boolean(spec?.version))).toBe("upgrade");
|
|
670
|
+
expect(p.cliBins.get(name)?.action).toBe("current");
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("flag-derived TUI state calls the unversioned rows healthy; the shared status says unknown", async () => {
|
|
675
|
+
const p = await runBothPaths();
|
|
676
|
+
const ids = [...Object.keys(REAL_OFFICIAL), `zero-version@${OFFICIAL}`];
|
|
677
|
+
|
|
678
|
+
for (const id of ids) {
|
|
679
|
+
const info = p.catalog.find((c) => c.id === id) as PluginInfo;
|
|
680
|
+
expect(legacyFlagState(info)).toBe("healthy");
|
|
681
|
+
expect(p.tuiRows.get(id)?.status?.action).toBe("unknown");
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
});
|