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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent tier table is keyed by agent id, and nothing else checks those ids.
|
|
3
|
+
*
|
|
4
|
+
* `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
|
|
5
|
+
* agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
|
|
6
|
+
* something else, `dev:synthesizer` → `dev:aggregator`) breaks the key with no signal: the
|
|
7
|
+
* renamed agent silently falls to the preset's fallback, and the stale key routes nothing.
|
|
8
|
+
* This test reads the marketplace source tree beside this repo and fails on either drift.
|
|
9
|
+
*
|
|
10
|
+
* Two directions:
|
|
11
|
+
* (a) every preset key names an agent that exists (or is a Claude Code built-in);
|
|
12
|
+
* (b) every marketplace agent is classified — in the tier table, in the frontmatter map,
|
|
13
|
+
* a vision agent, or explicitly left to the fallback in FALLBACK_BY_DESIGN below.
|
|
14
|
+
* Absence is a legitimate routing choice (fallback = `normal`), but it must be a
|
|
15
|
+
* choice: a new agent fails here until someone decides.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { DEFAULT_AGENT_GRADES, VISION_AGENTS } from "../data/models-presets.js";
|
|
21
|
+
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
22
|
+
|
|
23
|
+
const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
|
|
24
|
+
const MARKETPLACE = join(REPO, ".claude-plugin", "marketplace.json");
|
|
25
|
+
const inMagusSrc = existsSync(MARKETPLACE) && existsSync(join(REPO, "plugins"));
|
|
26
|
+
|
|
27
|
+
/** Claude Code's own agents: not in any marketplace, addressed by bare name. */
|
|
28
|
+
const BUILT_IN = new Set([
|
|
29
|
+
"Explore",
|
|
30
|
+
"Plan",
|
|
31
|
+
"general-purpose",
|
|
32
|
+
"statusline-setup",
|
|
33
|
+
"claude-code-guide",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Marketplace agents deliberately left to the preset's `fallback` grade. Each entry is a
|
|
38
|
+
* decision, not an omission: remove the line when the agent gets a tier. A NEW agent is not
|
|
39
|
+
* on this list, so adding one to the marketplace fails (b) until it is placed somewhere.
|
|
40
|
+
*/
|
|
41
|
+
const FALLBACK_BY_DESIGN = new Set([
|
|
42
|
+
"dev:developer",
|
|
43
|
+
"dev:devops",
|
|
44
|
+
"dev:frontend-developer",
|
|
45
|
+
"dev:researcher",
|
|
46
|
+
"dev:qa-engineer",
|
|
47
|
+
"madbench:bench",
|
|
48
|
+
"video-editing:timeline-builder",
|
|
49
|
+
"video-editing:transcriber",
|
|
50
|
+
"video-editing:video-processor",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
interface MarketplaceAgent {
|
|
54
|
+
id: string;
|
|
55
|
+
file: string;
|
|
56
|
+
frontmatterModel: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every `plugins/<p>/agents/*.md` of every plugin the marketplace lists, by frontmatter name. */
|
|
60
|
+
function marketplaceAgents(): MarketplaceAgent[] {
|
|
61
|
+
const manifest = JSON.parse(readFileSync(MARKETPLACE, "utf8")) as {
|
|
62
|
+
plugins: { name: string; source: string }[];
|
|
63
|
+
};
|
|
64
|
+
const out: MarketplaceAgent[] = [];
|
|
65
|
+
for (const plugin of manifest.plugins) {
|
|
66
|
+
const agentsDir = join(REPO, plugin.source, "agents");
|
|
67
|
+
if (!existsSync(agentsDir)) continue;
|
|
68
|
+
for (const entry of readdirSync(agentsDir)) {
|
|
69
|
+
if (!entry.endsWith(".md")) continue;
|
|
70
|
+
const file = join(agentsDir, entry);
|
|
71
|
+
const text = readFileSync(file, "utf8");
|
|
72
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
73
|
+
if (!fm) continue;
|
|
74
|
+
const name = fm[1].match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
75
|
+
if (!name) continue;
|
|
76
|
+
const model = fm[1].match(/^model:\s*(.+?)\s*$/m)?.[1];
|
|
77
|
+
out.push({
|
|
78
|
+
id: `${plugin.name}:${name}`,
|
|
79
|
+
file: join(plugin.source, "agents", entry),
|
|
80
|
+
frontmatterModel: model,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe.skipIf(!inMagusSrc)(
|
|
88
|
+
"models-presets agent ids track the marketplace",
|
|
89
|
+
() => {
|
|
90
|
+
const agents = marketplaceAgents();
|
|
91
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
92
|
+
const presetKeys = [...Object.keys(DEFAULT_AGENT_GRADES)];
|
|
93
|
+
|
|
94
|
+
test("reads a non-trivial marketplace", () => {
|
|
95
|
+
expect(agents.length).toBeGreaterThan(10);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("(a) every preset key names an existing agent or a built-in", () => {
|
|
99
|
+
const dead = presetKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
|
|
100
|
+
expect(dead).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("(b) every marketplace agent is classified somewhere", () => {
|
|
104
|
+
const classified = new Set<string>([
|
|
105
|
+
...presetKeys,
|
|
106
|
+
...VISION_AGENTS,
|
|
107
|
+
...FALLBACK_BY_DESIGN,
|
|
108
|
+
]);
|
|
109
|
+
const unplaced = agents
|
|
110
|
+
.filter((a) => !classified.has(a.id))
|
|
111
|
+
.map((a) => a.id);
|
|
112
|
+
expect(unplaced).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("FALLBACK_BY_DESIGN and VISION_AGENTS name agents that still exist", () => {
|
|
116
|
+
const stale = [...FALLBACK_BY_DESIGN, ...VISION_AGENTS].filter(
|
|
117
|
+
(k) => !ids.has(k),
|
|
118
|
+
);
|
|
119
|
+
expect(stale).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// A profile's magusPlugins is an install list, not documentation: resolver.ts expands
|
|
123
|
+
// each entry to `<name>@<marketplace>: latest`. A plugin rename moves under it silently,
|
|
124
|
+
// which is how 13.0.0's code-analysis -> code-search rename left six profiles installing
|
|
125
|
+
// a plugin the marketplace no longer lists. The agent checks above never read this array.
|
|
126
|
+
test("(d) every predefined profile names plugins the marketplace still lists", () => {
|
|
127
|
+
const marketplace = JSON.parse(readFileSync(MARKETPLACE, "utf8")) as {
|
|
128
|
+
plugins: { name: string }[];
|
|
129
|
+
};
|
|
130
|
+
const known = new Set(marketplace.plugins.map((p) => p.name));
|
|
131
|
+
const dead: string[] = [];
|
|
132
|
+
for (const profile of PREDEFINED_PROFILES) {
|
|
133
|
+
for (const entry of profile.magusPlugins ?? []) {
|
|
134
|
+
const name = entry.split("@")[0];
|
|
135
|
+
if (!known.has(name)) dead.push(`${profile.id}: ${entry}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
expect(dead).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Both exit 0. The no-op is indistinguishable from success at every layer
|
|
17
17
|
* above, so every claudeup update path — the TUI's `U`, "update all", the
|
|
18
|
-
* scope menu, `claudeup update
|
|
18
|
+
* scope menu, `claudeup update` — reported
|
|
19
19
|
* success and changed nothing, for ever.
|
|
20
20
|
*
|
|
21
21
|
* Mocking the CLI cannot catch that: a stub answers whatever the test asks it
|
|
@@ -83,7 +83,7 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
83
83
|
"magus",
|
|
84
84
|
buildLocalMarketplace("magus", [
|
|
85
85
|
{
|
|
86
|
-
name: "code-
|
|
86
|
+
name: "code-search",
|
|
87
87
|
version: "5.3.0",
|
|
88
88
|
description: "Codebase investigation tools",
|
|
89
89
|
category: "development",
|
|
@@ -108,12 +108,10 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
108
108
|
|
|
109
109
|
expect(resolved.plugins).toHaveLength(2);
|
|
110
110
|
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
111
|
-
"code-
|
|
111
|
+
"code-search",
|
|
112
112
|
"dev",
|
|
113
113
|
]);
|
|
114
|
-
const codeAnalysis = resolved.plugins.find(
|
|
115
|
-
(p) => p.name === "code-analysis",
|
|
116
|
-
);
|
|
114
|
+
const codeAnalysis = resolved.plugins.find((p) => p.name === "code-search");
|
|
117
115
|
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
118
116
|
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
119
117
|
expect(codeAnalysis?.category).toBe("development");
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
3
|
+
import {
|
|
4
|
+
type PluginUpdateItem,
|
|
5
|
+
classifyPluginRows,
|
|
6
|
+
planPluginUpdates,
|
|
7
|
+
} from "../services/update-plan.js";
|
|
8
|
+
import type { Marketplace } from "../types/index.js";
|
|
9
|
+
import {
|
|
10
|
+
type PluginPluginItem,
|
|
11
|
+
buildPluginBrowserItems,
|
|
12
|
+
} from "../ui/adapters/pluginsAdapter.js";
|
|
13
|
+
import { pluginRowText } from "../ui/renderers/pluginRowText.js";
|
|
14
|
+
import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `pluginRowText` is exactly what a Plugins row prints after the name, in
|
|
18
|
+
* every branch of `pluginRow` — selected, unselected, orphan. So asserting on
|
|
19
|
+
* it asserts on the screen.
|
|
20
|
+
*
|
|
21
|
+
* Rows here are built the way the screen builds them: `classifyPluginRows` over
|
|
22
|
+
* the plugin list and the profile's pins, then `buildPluginBrowserItems`. The
|
|
23
|
+
* note each row must contain is taken from `planPluginUpdates` — the CLI's own
|
|
24
|
+
* answer — never retyped, so the two front ends are compared, not two copies.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const OFFICIAL: Marketplace = {
|
|
28
|
+
name: "claude-plugins-official",
|
|
29
|
+
displayName: "Anthropic Official",
|
|
30
|
+
source: { source: "github", repo: "anthropics/claude-plugins-official" },
|
|
31
|
+
description: "",
|
|
32
|
+
} as Marketplace;
|
|
33
|
+
|
|
34
|
+
const MAGUS: Marketplace = {
|
|
35
|
+
name: "magus",
|
|
36
|
+
displayName: "Magus",
|
|
37
|
+
source: { source: "github", repo: "MadAppGang/magus" },
|
|
38
|
+
description: "",
|
|
39
|
+
owned: true,
|
|
40
|
+
} as Marketplace;
|
|
41
|
+
|
|
42
|
+
function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
43
|
+
const [name, marketplace] = over.id.split("@");
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
version: null,
|
|
47
|
+
description: "",
|
|
48
|
+
marketplace,
|
|
49
|
+
marketplaceDisplay: "",
|
|
50
|
+
enabled: true,
|
|
51
|
+
author: { name: "Anthropic" },
|
|
52
|
+
...over,
|
|
53
|
+
} as PluginInfo;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Real: `code-review@claude-plugins-official` in /Users/jack/mag/claudish. */
|
|
57
|
+
const CODE_REVIEW = info({
|
|
58
|
+
id: "code-review@claude-plugins-official",
|
|
59
|
+
version: null, // the official catalog publishes no version for it
|
|
60
|
+
installedVersion: "3ea32df27be7",
|
|
61
|
+
projectScope: { enabled: true, version: "3ea32df27be7" },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A registry "0.0.0" — the value the old row hid every status behind. */
|
|
65
|
+
const ZERO = info({
|
|
66
|
+
id: "zero-version@claude-plugins-official",
|
|
67
|
+
version: null,
|
|
68
|
+
installedVersion: "0.0.0",
|
|
69
|
+
projectScope: { enabled: true, version: "0.0.0" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Other listed ids, so each marketplace demonstrably was read. */
|
|
73
|
+
const LISTED = [
|
|
74
|
+
info({ id: "dev@magus", version: "4.7.0", author: { name: "MadAppGang" } }),
|
|
75
|
+
info({ id: "github@claude-plugins-official", version: null }),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function rows(
|
|
79
|
+
plugins: PluginInfo[],
|
|
80
|
+
pins: Record<string, string> = {},
|
|
81
|
+
): Map<string, PluginPluginItem> {
|
|
82
|
+
const all = [...plugins, ...LISTED];
|
|
83
|
+
const items = buildPluginBrowserItems({
|
|
84
|
+
marketplaces: [MAGUS, OFFICIAL],
|
|
85
|
+
plugins: all,
|
|
86
|
+
collapsedMarketplaces: new Set(),
|
|
87
|
+
statuses: classifyPluginRows(all, pins),
|
|
88
|
+
});
|
|
89
|
+
return new Map(
|
|
90
|
+
items
|
|
91
|
+
.filter((i): i is PluginPluginItem => i.kind === "plugin")
|
|
92
|
+
.map((i) => [i.plugin.id, i]),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The note `claudeup update` prints for this plugin under this pin. */
|
|
97
|
+
function cliNote(
|
|
98
|
+
plugins: PluginInfo[],
|
|
99
|
+
id: string,
|
|
100
|
+
pin = "latest",
|
|
101
|
+
): string | undefined {
|
|
102
|
+
const all = [...plugins, ...LISTED];
|
|
103
|
+
const [item] = planPluginUpdates(
|
|
104
|
+
{ [id]: pin },
|
|
105
|
+
new Map(all.map((p) => [p.id, p])),
|
|
106
|
+
);
|
|
107
|
+
return item?.note;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("pluginRowText — a status never depends on a displayable version", () => {
|
|
111
|
+
test("a sha-versioned unknown carries the CLI's reason verbatim", () => {
|
|
112
|
+
// The old row said "? not comparable" here; the CLI said the sentence
|
|
113
|
+
// below. Same state, different reason — the R2 violation.
|
|
114
|
+
const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id);
|
|
115
|
+
const note = cliNote([CODE_REVIEW], CODE_REVIEW.id);
|
|
116
|
+
expect(note).toBe(
|
|
117
|
+
"its marketplace publishes no version for this plugin, so there is nothing to compare",
|
|
118
|
+
);
|
|
119
|
+
const { text } = pluginRowText(row as PluginPluginItem);
|
|
120
|
+
expect(text).toBe(` v3ea32df27be7 ? ${note}`);
|
|
121
|
+
expect(text).not.toContain("not comparable");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('REGRESSION: a "0.0.0" unknown is no longer blank', () => {
|
|
125
|
+
// Every old status clause was gated on a displayable version, so this row
|
|
126
|
+
// printed nothing after its name and read as healthy.
|
|
127
|
+
const row = rows([ZERO]).get(ZERO.id) as PluginPluginItem;
|
|
128
|
+
expect(row.status?.action).toBe("unknown");
|
|
129
|
+
const { text, segments } = pluginRowText(row);
|
|
130
|
+
expect(text).toBe(` ? ${cliNote([ZERO], ZERO.id)}`);
|
|
131
|
+
// The version itself is still never printed.
|
|
132
|
+
expect(text).not.toContain("0.0.0");
|
|
133
|
+
expect(segments.at(-1)?.tone).toBe("warning");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
138
|
+
test("an orphan that moved names where it went", () => {
|
|
139
|
+
const seo = info({
|
|
140
|
+
id: "seo@magus",
|
|
141
|
+
version: "1.8.0",
|
|
142
|
+
installedVersion: "1.8.0",
|
|
143
|
+
isOrphaned: true,
|
|
144
|
+
movedTo: "magus-marketing",
|
|
145
|
+
projectScope: { enabled: true, version: "1.8.0" },
|
|
146
|
+
});
|
|
147
|
+
const row = rows([seo]).get(seo.id) as PluginPluginItem;
|
|
148
|
+
expect(cliNote([seo], seo.id)).toBe("moved to magus-marketing");
|
|
149
|
+
expect(pluginRowText(row).text).toBe(" v1.8.0 ? moved to magus-marketing");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an orphan with no movedTo, from a marketplace we read, is 'not offered'", () => {
|
|
153
|
+
const gone = info({
|
|
154
|
+
id: "gone@magus",
|
|
155
|
+
version: "2.0.0",
|
|
156
|
+
installedVersion: "2.0.0",
|
|
157
|
+
isOrphaned: true,
|
|
158
|
+
projectScope: { enabled: true, version: "2.0.0" },
|
|
159
|
+
});
|
|
160
|
+
const row = rows([gone]).get(gone.id) as PluginPluginItem;
|
|
161
|
+
expect(pluginRowText(row).text).toBe(
|
|
162
|
+
` v2.0.0 ? ${cliNote([gone], gone.id)}`,
|
|
163
|
+
);
|
|
164
|
+
expect(pluginRowText(row).text).toContain(
|
|
165
|
+
"not offered by any configured marketplace",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("the old words are gone: no 'deprecated', no 'moved →'", () => {
|
|
170
|
+
const seo = info({
|
|
171
|
+
id: "seo@magus",
|
|
172
|
+
installedVersion: "1.8.0",
|
|
173
|
+
isOrphaned: true,
|
|
174
|
+
movedTo: "magus-marketing",
|
|
175
|
+
projectScope: { enabled: true, version: "1.8.0" },
|
|
176
|
+
});
|
|
177
|
+
const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
|
|
178
|
+
expect(text).not.toContain("deprecated");
|
|
179
|
+
expect(text).not.toContain("moved →");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("pluginRowText — history badges combine with the state", () => {
|
|
184
|
+
const updated = (over: Partial<PluginInfo>) =>
|
|
185
|
+
info({
|
|
186
|
+
id: "terminal@magus",
|
|
187
|
+
version: "3.3.1",
|
|
188
|
+
installedVersion: "3.0.1",
|
|
189
|
+
projectScope: { enabled: true, version: "3.0.1" },
|
|
190
|
+
author: { name: "MadAppGang" },
|
|
191
|
+
...over,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("'updated' and a pending update are both shown", () => {
|
|
195
|
+
// An external update moved 2.9.0 → 3.0.1 while 3.3.1 is already out.
|
|
196
|
+
const p = updated({ recentlyUpdatedFrom: "2.9.0" });
|
|
197
|
+
const row = rows([p]).get(p.id) as PluginPluginItem;
|
|
198
|
+
const { text, segments } = pluginRowText(row);
|
|
199
|
+
expect(text).toBe(" v2.9.0 → v3.0.1 updated → v3.3.1 available");
|
|
200
|
+
expect(segments.map((s) => s.tone)).toEqual(["success", "warning"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("'new' and a pending update are both shown", () => {
|
|
204
|
+
const p = updated({ recentlyInstalled: true });
|
|
205
|
+
const row = rows([p]).get(p.id) as PluginPluginItem;
|
|
206
|
+
expect(pluginRowText(row).text).toBe(" v3.0.1 new → v3.3.1 available");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("'new' with no displayable version still says new", () => {
|
|
210
|
+
const p = updated({
|
|
211
|
+
recentlyInstalled: true,
|
|
212
|
+
version: "0.0.0",
|
|
213
|
+
installedVersion: "0.0.0",
|
|
214
|
+
projectScope: { enabled: true, version: "0.0.0" },
|
|
215
|
+
});
|
|
216
|
+
const row = rows([p]).get(p.id) as PluginPluginItem;
|
|
217
|
+
expect(pluginRowText(row).text).toBe(" new");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a current row shows only its version", () => {
|
|
221
|
+
const p = updated({
|
|
222
|
+
version: "3.0.1",
|
|
223
|
+
});
|
|
224
|
+
const row = rows([p]).get(p.id) as PluginPluginItem;
|
|
225
|
+
expect(row.status?.action).toBe("current");
|
|
226
|
+
expect(pluginRowText(row).text).toBe(" v3.0.1");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("pluginRowText — rows with no state claim none", () => {
|
|
231
|
+
test("an undeclared, uninstalled catalog entry has no status and no text", () => {
|
|
232
|
+
const browse = info({
|
|
233
|
+
id: "kanban@magus",
|
|
234
|
+
version: "1.6.2",
|
|
235
|
+
author: { name: "MadAppGang" },
|
|
236
|
+
});
|
|
237
|
+
const row = rows([browse]).get(browse.id) as PluginPluginItem;
|
|
238
|
+
expect(row.status).toBeNull();
|
|
239
|
+
expect(pluginRowText(row).text).toBe("");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("the same entry DECLARED by the profile says the profile wants it", () => {
|
|
243
|
+
const declared = info({
|
|
244
|
+
id: "kanban@magus",
|
|
245
|
+
version: "1.6.2",
|
|
246
|
+
author: { name: "MadAppGang" },
|
|
247
|
+
});
|
|
248
|
+
const row = rows([declared], { "kanban@magus": "latest" }).get(
|
|
249
|
+
declared.id,
|
|
250
|
+
) as PluginPluginItem;
|
|
251
|
+
expect(pluginRowText(row).text).toBe(
|
|
252
|
+
" not installed — profile declares it",
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", () => {
|
|
258
|
+
const status = (over: Partial<PluginUpdateItem>): PluginUpdateItem => ({
|
|
259
|
+
pluginId: "x@magus",
|
|
260
|
+
pinned: "latest",
|
|
261
|
+
installed: null,
|
|
262
|
+
available: "1.0.0",
|
|
263
|
+
target: "1.0.0",
|
|
264
|
+
action: "install",
|
|
265
|
+
reason: "absent",
|
|
266
|
+
scopes: [],
|
|
267
|
+
beforeVersions: {},
|
|
268
|
+
...over,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("absent — only ever from a profile pin — may say the profile declares it", () => {
|
|
272
|
+
expect(pluginStatusText(status({}))).toEqual({
|
|
273
|
+
suffix: " not installed — profile declares it",
|
|
274
|
+
tone: "warning",
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("enabled-not-installed shows its note and never claims a declaration", () => {
|
|
279
|
+
const got = pluginStatusText(
|
|
280
|
+
status({
|
|
281
|
+
reason: "enabled-not-installed",
|
|
282
|
+
note: "enabled in settings, but no files on disk",
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
expect(got).toEqual({
|
|
286
|
+
suffix: " not installed (enabled in settings, but no files on disk)",
|
|
287
|
+
tone: "danger",
|
|
288
|
+
});
|
|
289
|
+
expect(got.suffix).not.toContain("profile");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("an undeclared enabled-but-not-installed ROW gets the enabled text", () => {
|
|
293
|
+
const broken = info({
|
|
294
|
+
id: "kanban@magus",
|
|
295
|
+
version: "1.6.2",
|
|
296
|
+
author: { name: "MadAppGang" },
|
|
297
|
+
localScope: { enabled: true },
|
|
298
|
+
});
|
|
299
|
+
const row = rows([broken]).get(broken.id) as PluginPluginItem;
|
|
300
|
+
expect(row.status?.reason).toBe("enabled-not-installed");
|
|
301
|
+
expect(pluginRowText(row).text).toBe(
|
|
302
|
+
" not installed (enabled in settings, but no files on disk)",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("null status says nothing", () => {
|
|
307
|
+
expect(pluginStatusText(null)).toEqual({ suffix: "", tone: "muted" });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("update with a pin-undeliverable note carries it", () => {
|
|
311
|
+
expect(
|
|
312
|
+
pluginStatusText(
|
|
313
|
+
status({
|
|
314
|
+
action: "update",
|
|
315
|
+
reason: "pin-undeliverable",
|
|
316
|
+
target: "9.9.9",
|
|
317
|
+
note: "manifest pins 4.6.1; the marketplace offers only 9.9.9",
|
|
318
|
+
}),
|
|
319
|
+
).suffix,
|
|
320
|
+
).toBe(
|
|
321
|
+
" → v9.9.9 available (manifest pins 4.6.1; the marketplace offers only 9.9.9)",
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("repair carries its note", () => {
|
|
326
|
+
expect(
|
|
327
|
+
pluginStatusText(
|
|
328
|
+
status({
|
|
329
|
+
action: "repair",
|
|
330
|
+
reason: "content-stale",
|
|
331
|
+
note: "files changed without a version bump",
|
|
332
|
+
}),
|
|
333
|
+
).suffix,
|
|
334
|
+
).toBe(" stale — reinstall (files changed without a version bump)");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -91,9 +91,9 @@ describe("resolveExtends", () => {
|
|
|
91
91
|
extends: "growth-marketer",
|
|
92
92
|
};
|
|
93
93
|
const merged = resolveExtends(entry);
|
|
94
|
-
// image
|
|
94
|
+
// image/video-editing ship on the magus-marketing channel and
|
|
95
95
|
// pin it explicitly — they must NOT be rewritten to @magus.
|
|
96
|
-
expect(merged.plugins!["image
|
|
96
|
+
expect(merged.plugins!["image@magus-marketing"]).toBe("latest");
|
|
97
97
|
expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
|
|
98
98
|
expect(merged.plugins!["image-generate@magus"]).toBeUndefined();
|
|
99
99
|
// Bare names still default to @magus.
|
|
@@ -302,7 +302,7 @@ describe("marketplaces derived from plugin ids", () => {
|
|
|
302
302
|
name: "Mixed",
|
|
303
303
|
plugins: {
|
|
304
304
|
"dev@magus": "latest",
|
|
305
|
-
"image
|
|
305
|
+
"image@magus-marketing": "latest",
|
|
306
306
|
"feature-dev@claude-plugins-official": "latest",
|
|
307
307
|
},
|
|
308
308
|
},
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
type PluginInfo,
|
|
3
4
|
isInstalledInScope,
|
|
4
5
|
resolveScopeAction,
|
|
5
6
|
} from "../services/plugin-manager.js";
|
|
7
|
+
import { planPluginUpdates, scopeIsBehind } from "../services/update-plan.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The scope keys (u / p / l) and the Enter scope picker both decide between
|
|
@@ -29,10 +31,18 @@ describe("isInstalledInScope", () => {
|
|
|
29
31
|
expect(isInstalledInScope({ enabled: true })).toBe(false);
|
|
30
32
|
});
|
|
31
33
|
|
|
32
|
-
test("a version with the flag off
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
test("a registry version with the flag off IS installed", () => {
|
|
35
|
+
// Installation and enablement are separate facts. This asserted `false`
|
|
36
|
+
// until it was measured against the other direction: a plugin with a real
|
|
37
|
+
// registry row that the user had simply switched off reported "not
|
|
38
|
+
// installed", so its row offered to install what was already on disk, and
|
|
39
|
+
// the header counted it out.
|
|
40
|
+
//
|
|
41
|
+
// The hazard the sibling test above guards — pressing a scope key on
|
|
42
|
+
// enabled-with-no-version and having it run `uninstall` — is untouched:
|
|
43
|
+
// that case has no version, so it is still not installed, and still
|
|
44
|
+
// resolves to `install`.
|
|
45
|
+
expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(true);
|
|
36
46
|
});
|
|
37
47
|
|
|
38
48
|
test("an absent scope is not installed", () => {
|
|
@@ -45,40 +55,80 @@ describe("isInstalledInScope", () => {
|
|
|
45
55
|
});
|
|
46
56
|
|
|
47
57
|
describe("resolveScopeAction", () => {
|
|
58
|
+
// The second argument is "is this scope provably behind?", which the screen
|
|
59
|
+
// takes from the classifier (`scopeIsBehind`). It used to be the catalog
|
|
60
|
+
// version, compared here with raw string inequality — a fourth rule.
|
|
61
|
+
|
|
48
62
|
test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
|
|
49
63
|
// This is the reported bug. Before the fix this returned "uninstall".
|
|
50
|
-
expect(resolveScopeAction({ enabled: true },
|
|
64
|
+
expect(resolveScopeAction({ enabled: true }, false)).toBe("install");
|
|
65
|
+
// Whatever the classifier says: the safety branch comes first.
|
|
66
|
+
expect(resolveScopeAction({ enabled: true }, true)).toBe("install");
|
|
51
67
|
});
|
|
52
68
|
|
|
53
69
|
test("a scope that was never enabled resolves to install", () => {
|
|
54
|
-
expect(resolveScopeAction(undefined,
|
|
55
|
-
expect(resolveScopeAction({ enabled: false },
|
|
70
|
+
expect(resolveScopeAction(undefined, false)).toBe("install");
|
|
71
|
+
expect(resolveScopeAction({ enabled: false }, false)).toBe("install");
|
|
56
72
|
});
|
|
57
73
|
|
|
58
74
|
test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
|
|
59
|
-
expect(
|
|
60
|
-
|
|
61
|
-
)
|
|
75
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, false)).toBe(
|
|
76
|
+
"uninstall",
|
|
77
|
+
);
|
|
62
78
|
});
|
|
63
79
|
|
|
64
80
|
test("a healthy install behind the catalog resolves to update", () => {
|
|
65
|
-
expect(
|
|
66
|
-
|
|
67
|
-
)
|
|
81
|
+
expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, true)).toBe(
|
|
82
|
+
"update",
|
|
83
|
+
);
|
|
68
84
|
});
|
|
85
|
+
});
|
|
69
86
|
|
|
87
|
+
/** The screen's path: classify, then ask `scopeIsBehind` for the pressed scope. */
|
|
88
|
+
function keyFor(installed: string, catalogVersion: string | null) {
|
|
89
|
+
const scope = { enabled: true, version: installed };
|
|
90
|
+
const [status] = planPluginUpdates(
|
|
91
|
+
{ "x@magus": "latest" },
|
|
92
|
+
new Map([
|
|
93
|
+
[
|
|
94
|
+
"x@magus",
|
|
95
|
+
{
|
|
96
|
+
id: "x@magus",
|
|
97
|
+
name: "x",
|
|
98
|
+
version: catalogVersion,
|
|
99
|
+
description: "",
|
|
100
|
+
marketplace: "magus",
|
|
101
|
+
marketplaceDisplay: "Magus",
|
|
102
|
+
enabled: true,
|
|
103
|
+
projectScope: scope,
|
|
104
|
+
} as PluginInfo,
|
|
105
|
+
],
|
|
106
|
+
]),
|
|
107
|
+
);
|
|
108
|
+
return resolveScopeAction(scope, scopeIsBehind(status, "project"));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("resolveScopeAction through the classifier — the screen's real path", () => {
|
|
70
112
|
test('an unknown catalog version ("0.0.0") never fabricates an update', () => {
|
|
71
113
|
// There is nothing to update *to*, so toggling must still mean uninstall.
|
|
72
|
-
expect(
|
|
73
|
-
resolveScopeAction({ enabled: true, version: "3.0.2" }, "0.0.0"),
|
|
74
|
-
).toBe("uninstall");
|
|
114
|
+
expect(keyFor("3.0.2", "0.0.0")).toBe("uninstall");
|
|
75
115
|
});
|
|
76
116
|
|
|
77
117
|
test("installed with unknown version resolves to update when a real version is published", () => {
|
|
78
118
|
// "0.0.0" installed against a real catalog version is a genuine upgrade
|
|
79
119
|
// path, not the broken state — it must not be treated as uninstall.
|
|
80
|
-
expect(
|
|
81
|
-
|
|
82
|
-
|
|
120
|
+
expect(keyFor("0.0.0", "3.0.2")).toBe("update");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("REGRESSION: a scope AHEAD of the catalog no longer resolves to update", () => {
|
|
124
|
+
// Raw string inequality called 3.1.0 vs 3.0.2 "behind" and offered an
|
|
125
|
+
// update that could only reinstall the older version or no-op.
|
|
126
|
+
expect(keyFor("3.1.0", "3.0.2")).toBe("uninstall");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("REGRESSION: an unorderable scope no longer resolves to update", () => {
|
|
130
|
+
// A sha against a semver cannot be ordered. The row says "?", so the key
|
|
131
|
+
// must not act as though it knew the scope was behind.
|
|
132
|
+
expect(keyFor("3ea32df27be7", "3.0.2")).toBe("uninstall");
|
|
83
133
|
});
|
|
84
134
|
});
|