claudeup 6.5.1 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/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__/enabled-not-installed.test.ts +1 -2
- 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__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- 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/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +1 -173
- 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,7 @@
|
|
|
1
|
+
# captured 2026-09-12 00:42 AEST, read-only: cat ~/.local/share/uv/tools/graphifyy/uv-receipt.toml (uv 0.11.15)
|
|
2
|
+
[tool]
|
|
3
|
+
requirements = [{ name = "graphifyy", extras = ["mcp"] }]
|
|
4
|
+
entrypoints = [
|
|
5
|
+
{ name = "graphify", install-path = "/Users/jack/.local/bin/graphify", from = "graphifyy" },
|
|
6
|
+
{ name = "graphify-mcp", install-path = "/Users/jack/.local/bin/graphify-mcp", from = "graphifyy" },
|
|
7
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# captured 2026-09-12 00:42 AEST, read-only: cat ~/.local/share/uv/tools/serena-agent/uv-receipt.toml (uv 0.11.15)
|
|
2
|
+
[tool]
|
|
3
|
+
requirements = [{ name = "serena-agent" }]
|
|
4
|
+
python = "3.13"
|
|
5
|
+
entrypoints = [
|
|
6
|
+
{ name = "serena", install-path = "/Users/jack/.local/bin/serena", from = "serena-agent" },
|
|
7
|
+
{ name = "serena-agent", install-path = "/Users/jack/.local/bin/serena-agent", from = "serena-agent" },
|
|
8
|
+
{ name = "serena-hooks", install-path = "/Users/jack/.local/bin/serena-hooks", from = "serena-agent" },
|
|
9
|
+
]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# captured 2026-09-12 00:54 AEST: UV_TOOL_DIR=$SESSION/validation/captures/uv-scratch/tools UV_TOOL_BIN_DIR=$SESSION/validation/captures/uv-scratch/bin UV_CACHE_DIR=$SESSION/validation/captures/uv-scratch/cache UV_PYTHON_INSTALL_DIR=$SESSION/validation/captures/uv-scratch/python NO_COLOR=1 uv tool list --outdated (exit=0)
|
|
2
|
+
# cowsay was installed as 'cowsay==6.0' (receipt specifier ==6.0); uv still lists it as outdated.
|
|
3
|
+
# $SESSION = claudish worktree update-fix2 ai-docs/sessions/dev-feature-claudeup-unify-20260911-2240 (the user's real uv tools were never touched)
|
|
4
|
+
cowsay v6.0 [latest: 6.1]
|
|
5
|
+
- cowsay
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# captured 2026-09-12 00:42 AEST on the reporter's machine, read-only: uv tool list --outdated (uv 0.11.15, exit=0)
|
|
2
|
+
# Same text as the 2026-09-11 23:12 baseline. browser-use and mcp are absent: current.
|
|
3
|
+
claude-swap v0.24.1 [latest: 0.26.0]
|
|
4
|
+
- claude-swap
|
|
5
|
+
- cswap
|
|
6
|
+
graphifyy v0.9.50 [latest: 0.9.58]
|
|
7
|
+
- graphify
|
|
8
|
+
- graphify-mcp
|
|
9
|
+
mistral-vibe v2.5.0 [latest: 2.25.3]
|
|
10
|
+
- vibe
|
|
11
|
+
- vibe-acp
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# captured 2026-09-12 00:42 AEST on the reporter's machine, read-only: uv tool list (uv 0.11.15, exit=0)
|
|
2
|
+
browser-use v0.13.10
|
|
3
|
+
- browser
|
|
4
|
+
- browser-use
|
|
5
|
+
- browser-use-tui
|
|
6
|
+
- browseruse
|
|
7
|
+
- bu
|
|
8
|
+
claude-swap v0.24.1
|
|
9
|
+
- claude-swap
|
|
10
|
+
- cswap
|
|
11
|
+
graphifyy v0.9.50
|
|
12
|
+
- graphify
|
|
13
|
+
- graphify-mcp
|
|
14
|
+
mcp v2.2.0
|
|
15
|
+
- mcp
|
|
16
|
+
mistral-vibe v2.5.0
|
|
17
|
+
- vibe
|
|
18
|
+
- vibe-acp
|
|
19
|
+
serena-agent v1.7.0
|
|
20
|
+
- serena
|
|
21
|
+
- serena-agent
|
|
22
|
+
- serena-hooks
|
|
@@ -6,8 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
|
|
7
7
|
import {
|
|
8
8
|
checkGitignore,
|
|
9
|
-
|
|
10
|
-
} from "../services/gitignore-
|
|
9
|
+
formatGitignoreWarning,
|
|
10
|
+
} from "../services/gitignore-check";
|
|
11
11
|
|
|
12
12
|
function git(cwd: string, ...args: string[]): void {
|
|
13
13
|
spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], { cwd });
|
|
@@ -17,7 +17,7 @@ describe("checkGitignore", () => {
|
|
|
17
17
|
let projectDir: string;
|
|
18
18
|
|
|
19
19
|
beforeEach(async () => {
|
|
20
|
-
projectDir = await mkdtemp(join(tmpdir(), "gitig-
|
|
20
|
+
projectDir = await mkdtemp(join(tmpdir(), "gitig-check-"));
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
afterEach(async () => {
|
|
@@ -55,13 +55,15 @@ describe("checkGitignore", () => {
|
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
describe("
|
|
58
|
+
describe("formatGitignoreWarning", () => {
|
|
59
59
|
it("returns null when no violations", () => {
|
|
60
|
-
expect(
|
|
60
|
+
expect(
|
|
61
|
+
formatGitignoreWarning({ violationCount: 0, warnings: [] }),
|
|
62
|
+
).toBeNull();
|
|
61
63
|
});
|
|
62
64
|
|
|
63
65
|
it("returns a warning string when violations exist", () => {
|
|
64
|
-
const w =
|
|
66
|
+
const w = formatGitignoreWarning({ violationCount: 3, warnings: [] });
|
|
65
67
|
expect(w).not.toBeNull();
|
|
66
68
|
expect(w).toContain("3 git state");
|
|
67
69
|
expect(w).toContain("Git State tab");
|
|
@@ -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
|
|
@@ -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
|
+
});
|
|
@@ -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
|
});
|
|
@@ -66,18 +66,18 @@ describe("only the toolchain bootstrap goes through a shell", () => {
|
|
|
66
66
|
expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test("nothing spawns with
|
|
69
|
+
test("nothing spawns with a shell outside utils/run.ts", async () => {
|
|
70
70
|
// Catches someone reaching for `spawn`/`exec` directly rather than
|
|
71
71
|
// through the seam — a second shell path that never names
|
|
72
72
|
// `runShellScript`.
|
|
73
73
|
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
expect(await callersOf(/shell:\s*true/)).toEqual([]);
|
|
74
|
+
// Both spellings: `shell: true` and a named shell (`shell: "/bin/bash"`).
|
|
75
|
+
// The CLI Tools screen used the second for its version probe (`which -a`
|
|
76
|
+
// pipelines, a string-form `exec` of each check command); that probe now
|
|
77
|
+
// lives in services/tool-probe.ts and runs argv only. `shell: false` and
|
|
78
|
+
// the alias writer's `shell: ShellKind` values are not spawn options and
|
|
79
|
+
// do not match.
|
|
80
|
+
expect(await callersOf(/shell:\s*(true|["'`])/)).toEqual([]);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
test("the scan can actually fail", async () => {
|