magus-cli 7.11.4 → 7.11.6
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__/deprecated-remove-all-profiles.test.ts +171 -0
- package/src/__tests__/mcp-registry-install-screen.test.tsx +732 -0
- package/src/__tests__/mcp-registry-install.test.ts +551 -0
- package/src/__tests__/mcp-registry-screen-debounce.test.tsx +195 -0
- package/src/__tests__/mcp-registry-screen.test.ts +209 -0
- package/src/__tests__/not-installed-answer-wrong-scope.test.ts +46 -0
- package/src/__tests__/update-removes-deprecated-other-profiles.test.ts +401 -0
- package/src/cli/update.ts +14 -2
- package/src/services/active-closure.ts +12 -2
- package/src/services/claude-cli.ts +12 -1
- package/src/services/mcp-registry.ts +150 -0
- package/src/services/profile-state.ts +2 -12
- package/src/services/resolver.ts +31 -0
- package/src/services/update-engine.ts +146 -12
- package/src/services/update-plan.ts +42 -2
- package/src/ui/keymap.ts +1 -0
- package/src/ui/renderers/mcpRenderers.tsx +6 -1
- package/src/ui/screens/McpRegistryScreen.tsx +146 -26
- package/src/ui/screens/PluginsScreen.tsx +24 -38
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { act, useEffect } from "react";
|
|
7
|
+
import { KeyRouter } from "../ui/input/KeyRouter.js";
|
|
8
|
+
import McpRegistryScreen from "../ui/screens/McpRegistryScreen.js";
|
|
9
|
+
import { AppProvider, useNavigation } from "../ui/state/AppContext.js";
|
|
10
|
+
import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
|
|
11
|
+
|
|
12
|
+
// REGRESSION: MTL-337 debounce window and cursor jump — round 2 of /dev:fix session dev-fix-20261101-153739-a08a5829
|
|
13
|
+
//
|
|
14
|
+
// The real McpRegistryScreen is mounted under the real providers, with a fake
|
|
15
|
+
// `fetch` for the registry endpoint and a throwaway project path. Keys go
|
|
16
|
+
// through the test renderer's mock keyboard; the outcome is read from what the
|
|
17
|
+
// screen wrote to .mcp.json.
|
|
18
|
+
|
|
19
|
+
// Real-looking hosts for the servers whose host decides "official"; every
|
|
20
|
+
// other name gets an example.org host.
|
|
21
|
+
const HOSTS: Record<string, string> = {
|
|
22
|
+
"com.notion/mcp": "https://mcp.notion.com/mcp",
|
|
23
|
+
"ai.smithery/smithery-notion": "https://server.smithery.ai/notion/mcp",
|
|
24
|
+
"com.linear/mcp": "https://mcp.linear.com/mcp",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const server = (name: string) => ({
|
|
28
|
+
name,
|
|
29
|
+
url: HOSTS[name] ?? `https://${name.replace(/[^a-z]/g, "")}.example.org/mcp`,
|
|
30
|
+
short_description: `${name} description`,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
type Deferred = { query: string; resolve: (names: string[]) => void };
|
|
34
|
+
|
|
35
|
+
let cleanup: Array<() => Promise<void> | void> = [];
|
|
36
|
+
const realFetch = globalThis.fetch;
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
globalThis.fetch = realFetch;
|
|
40
|
+
for (const fn of cleanup.reverse()) await fn();
|
|
41
|
+
cleanup = [];
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function GoToRegistry() {
|
|
45
|
+
const { navigateToScreen } = useNavigation();
|
|
46
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: navigate once on mount
|
|
47
|
+
useEffect(() => navigateToScreen("mcp-registry"), []);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function mount(initialNames: string[]) {
|
|
52
|
+
const project = mkdtempSync(join(tmpdir(), "mcp-registry-screen-"));
|
|
53
|
+
cleanup.push(() => rmSync(project, { recursive: true, force: true }));
|
|
54
|
+
|
|
55
|
+
const pending: Deferred[] = [];
|
|
56
|
+
globalThis.fetch = (async (input: unknown) => {
|
|
57
|
+
const url = new URL(String(input));
|
|
58
|
+
const query = url.searchParams.get("q") ?? "";
|
|
59
|
+
const names = await new Promise<string[]>((resolve) => {
|
|
60
|
+
if (query === "") resolve(initialNames);
|
|
61
|
+
else pending.push({ query, resolve });
|
|
62
|
+
});
|
|
63
|
+
return new Response(JSON.stringify({ servers: names.map(server) }), {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: { "content-type": "application/json" },
|
|
66
|
+
});
|
|
67
|
+
}) as typeof fetch;
|
|
68
|
+
|
|
69
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
70
|
+
await testRender(
|
|
71
|
+
<AppProvider initialProjectPath={project}>
|
|
72
|
+
<DimensionsProvider>
|
|
73
|
+
<KeyRouter>
|
|
74
|
+
<GoToRegistry />
|
|
75
|
+
<McpRegistryScreen />
|
|
76
|
+
</KeyRouter>
|
|
77
|
+
</DimensionsProvider>
|
|
78
|
+
</AppProvider>,
|
|
79
|
+
{ width: 110, height: 30 },
|
|
80
|
+
);
|
|
81
|
+
cleanup.push(async () => {
|
|
82
|
+
await act(async () => renderer.destroy());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const settle = async (ms = 30) => {
|
|
86
|
+
for (let i = 0; i < 3; i++) {
|
|
87
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
88
|
+
await renderOnce();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const send = async (fn: () => void | Promise<void>, ms?: number) =>
|
|
92
|
+
act(async () => {
|
|
93
|
+
await fn();
|
|
94
|
+
await settle(ms);
|
|
95
|
+
});
|
|
96
|
+
await act(() => settle());
|
|
97
|
+
|
|
98
|
+
const mcpJson = () => {
|
|
99
|
+
const path = join(project, ".mcp.json");
|
|
100
|
+
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
101
|
+
};
|
|
102
|
+
return { mockInput, send, settle, pending, mcpJson, frame: captureCharFrame };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("registry screen: the list is not actionable while a query change is pending", () => {
|
|
106
|
+
test("typing a query then Enter, Enter inside the debounce installs nothing from the old list", async () => {
|
|
107
|
+
const m = await mount(["com.old/alpha", "com.old/beta"]);
|
|
108
|
+
|
|
109
|
+
// "/" opens the search box, the text is typed, the first Enter leaves it,
|
|
110
|
+
// the second Enter installs the row under the cursor. All of it happens
|
|
111
|
+
// well inside the 300 ms debounce, before loadServers has started.
|
|
112
|
+
await m.send(() => m.mockInput.typeText("/notion"), 5);
|
|
113
|
+
await m.send(() => m.mockInput.pressEnter(), 5);
|
|
114
|
+
await m.send(() => m.mockInput.pressEnter(), 5);
|
|
115
|
+
|
|
116
|
+
expect(m.pending).toHaveLength(0); // debounce has not fired yet
|
|
117
|
+
expect(m.mcpJson()).not.toContain("com.old/");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("after the new query's response lands, Enter installs the new list's first row", async () => {
|
|
121
|
+
const m = await mount(["com.old/alpha", "com.old/beta"]);
|
|
122
|
+
await m.send(() => m.mockInput.typeText("/notion"), 5);
|
|
123
|
+
await m.send(() => m.mockInput.pressEnter(), 5);
|
|
124
|
+
await m.settle(400); // debounce fires, request is now pending
|
|
125
|
+
expect(m.pending).toHaveLength(1);
|
|
126
|
+
await m.send(() => m.pending[0]?.resolve(["com.notion/mcp"]));
|
|
127
|
+
await m.send(() => m.mockInput.pressEnter(), 100);
|
|
128
|
+
|
|
129
|
+
expect(m.mcpJson()).toContain("com.notion/mcp");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("registry screen: the cursor follows the name only when the query is unchanged", () => {
|
|
134
|
+
test("a new query puts the cursor on row 0 of the new results, even when the old row-0 server is in them", async () => {
|
|
135
|
+
const m = await mount(["com.old/alpha", "com.old/beta"]);
|
|
136
|
+
await m.send(() => m.mockInput.typeText("/x"), 5);
|
|
137
|
+
await m.send(() => m.mockInput.pressEnter(), 5);
|
|
138
|
+
await m.settle(400);
|
|
139
|
+
expect(m.pending).toHaveLength(1);
|
|
140
|
+
// The old row-0 server comes back, but second.
|
|
141
|
+
await m.send(() =>
|
|
142
|
+
m.pending[0]?.resolve(["com.new/first", "com.old/alpha"]),
|
|
143
|
+
);
|
|
144
|
+
await m.send(() => m.mockInput.pressEnter(), 100);
|
|
145
|
+
|
|
146
|
+
expect(m.mcpJson()).toContain("com.new/first");
|
|
147
|
+
expect(m.mcpJson()).not.toContain("com.old/alpha");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("registry screen: the official badge follows the search", () => {
|
|
152
|
+
const rowLines = (frame: string, needle: string) =>
|
|
153
|
+
frame.split("\n").filter((line) => line.includes(needle));
|
|
154
|
+
|
|
155
|
+
test("the unfiltered list shows no official badge on any row", async () => {
|
|
156
|
+
const m = await mount([
|
|
157
|
+
"com.linear/mcp",
|
|
158
|
+
"ai.smithery/smithery-notion",
|
|
159
|
+
"com.notion/mcp",
|
|
160
|
+
]);
|
|
161
|
+
await m.send(() => m.settle(400)); // the initial load runs behind the debounce
|
|
162
|
+
expect(m.frame()).toContain("notion/mcp"); // the rows did render
|
|
163
|
+
expect(m.frame()).not.toContain("official");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("after searching notion only the real notion server is badged, and it is first", async () => {
|
|
167
|
+
const m = await mount([
|
|
168
|
+
"com.linear/mcp",
|
|
169
|
+
"ai.smithery/smithery-notion",
|
|
170
|
+
"com.notion/mcp",
|
|
171
|
+
]);
|
|
172
|
+
await m.send(() => m.mockInput.typeText("/notion"), 5);
|
|
173
|
+
await m.send(() => m.mockInput.pressEnter(), 5);
|
|
174
|
+
await m.settle(400);
|
|
175
|
+
expect(m.pending).toHaveLength(1);
|
|
176
|
+
// The endpoint answers with the third-party server before the real one.
|
|
177
|
+
await m.send(() =>
|
|
178
|
+
m.pending[0]?.resolve(["ai.smithery/smithery-notion", "com.notion/mcp"]),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await m.send(() => m.settle(100));
|
|
182
|
+
const frame = m.frame();
|
|
183
|
+
const lines = frame.split("\n");
|
|
184
|
+
const notionRow = lines.findIndex((l) => l.includes("com.notion/mcp"));
|
|
185
|
+
const smitheryRow = lines.findIndex((l) =>
|
|
186
|
+
l.includes("ai.smithery/smithery-notion"),
|
|
187
|
+
);
|
|
188
|
+
expect(notionRow).toBeGreaterThanOrEqual(0);
|
|
189
|
+
expect(notionRow).toBeLessThan(smitheryRow); // official first
|
|
190
|
+
expect(lines[notionRow]).toContain("official");
|
|
191
|
+
expect(rowLines(frame, "smithery-notion").join("\n")).not.toContain(
|
|
192
|
+
"official",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseKeypress } from "@opentui/core";
|
|
3
|
+
import {
|
|
4
|
+
createLatestOnlyGuard,
|
|
5
|
+
isOfficialMcpServer,
|
|
6
|
+
officialFirst,
|
|
7
|
+
} from "../services/mcp-registry.js";
|
|
8
|
+
import type { KeyPress } from "../ui/input/key-dispatch.js";
|
|
9
|
+
import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
|
|
10
|
+
|
|
11
|
+
// REGRESSION: MTL-337 registry screen installs the wrong server / no remove / no official first — Fixed in /dev:fix session dev-fix-20261101-153739-a08a5829
|
|
12
|
+
|
|
13
|
+
const srv = (name: string, url: string) => ({
|
|
14
|
+
name,
|
|
15
|
+
url,
|
|
16
|
+
short_description: "",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const press = (raw: string): KeyPress => {
|
|
20
|
+
const k = parseKeypress(raw);
|
|
21
|
+
return {
|
|
22
|
+
name: k?.name ?? "",
|
|
23
|
+
sequence: k?.sequence ?? "",
|
|
24
|
+
ctrl: k?.ctrl === true,
|
|
25
|
+
meta: k?.meta === true,
|
|
26
|
+
shift: k?.shift === true,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("isOfficialMcpServer", () => {
|
|
31
|
+
const notion = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
|
|
32
|
+
|
|
33
|
+
test("an empty or whitespace query makes nothing official", () => {
|
|
34
|
+
expect(isOfficialMcpServer(notion, "")).toBe(false);
|
|
35
|
+
expect(isOfficialMcpServer(notion, " ")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("com.notion/mcp served from mcp.notion.com is official for the query notion", () => {
|
|
39
|
+
expect(isOfficialMcpServer(notion, "notion")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a third-party host is not official for the brand it mentions", () => {
|
|
43
|
+
expect(
|
|
44
|
+
isOfficialMcpServer(
|
|
45
|
+
srv("ai.smithery/smithery-notion", "https://server.smithery.ai/notion"),
|
|
46
|
+
"notion",
|
|
47
|
+
),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
expect(
|
|
50
|
+
isOfficialMcpServer(
|
|
51
|
+
srv("com.foreverworks/notion", "https://mcp.foreverworks.com/notion"),
|
|
52
|
+
"notion",
|
|
53
|
+
),
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a server is official for its own brand, but not for another brand's query", () => {
|
|
58
|
+
const smithery = srv(
|
|
59
|
+
"ai.smithery/smithery-notion",
|
|
60
|
+
"https://server.smithery.ai/notion",
|
|
61
|
+
);
|
|
62
|
+
expect(isOfficialMcpServer(smithery, "smithery")).toBe(true);
|
|
63
|
+
expect(isOfficialMcpServer(notion, "smithery")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the query is case-insensitive and split into words", () => {
|
|
67
|
+
expect(isOfficialMcpServer(notion, "Notion MCP")).toBe(true);
|
|
68
|
+
expect(isOfficialMcpServer(notion, "mcp-NOTION")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a word must equal the brand, a substring does not count", () => {
|
|
72
|
+
expect(isOfficialMcpServer(notion, "notional")).toBe(false);
|
|
73
|
+
expect(isOfficialMcpServer(notion, "not")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("com.mcparmory/notion packaged on pypi is not official", () => {
|
|
77
|
+
expect(
|
|
78
|
+
isOfficialMcpServer(
|
|
79
|
+
srv("com.mcparmory/notion", "pypi:mcparmory-notion"),
|
|
80
|
+
"mcparmory notion",
|
|
81
|
+
),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an io.github.<owner> namespace is never official, even when the host matches", () => {
|
|
86
|
+
expect(
|
|
87
|
+
isOfficialMcpServer(
|
|
88
|
+
srv("io.github.someone/notion", "https://github.io/someone/notion"),
|
|
89
|
+
"github someone notion",
|
|
90
|
+
),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
expect(
|
|
93
|
+
isOfficialMcpServer(
|
|
94
|
+
srv("io.github.notion/mcp", "https://mcp.github.io/mcp"),
|
|
95
|
+
"github notion",
|
|
96
|
+
),
|
|
97
|
+
).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("npm: and pypi: sources are not official", () => {
|
|
101
|
+
expect(
|
|
102
|
+
isOfficialMcpServer(srv("com.notion/mcp", "npm:@notion/mcp"), "notion"),
|
|
103
|
+
).toBe(false);
|
|
104
|
+
expect(
|
|
105
|
+
isOfficialMcpServer(srv("com.notion/mcp", "pypi:notion-mcp"), "notion"),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an unparsable URL is not official", () => {
|
|
110
|
+
expect(
|
|
111
|
+
isOfficialMcpServer(srv("com.notion/mcp", "not a url"), "notion"),
|
|
112
|
+
).toBe(false);
|
|
113
|
+
expect(isOfficialMcpServer(srv("com.notion/mcp", ""), "notion")).toBe(
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a namespace that is only a string prefix of the host does not match on a label boundary", () => {
|
|
119
|
+
expect(
|
|
120
|
+
isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "not"),
|
|
121
|
+
).toBe(false);
|
|
122
|
+
expect(
|
|
123
|
+
isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "notion"),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("officialFirst", () => {
|
|
129
|
+
const a = srv("io.github.a/one", "https://a.example.org/mcp");
|
|
130
|
+
const o1 = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
|
|
131
|
+
const b = srv("com.mcparmory/notion", "pypi:mcparmory-notion");
|
|
132
|
+
const o2 = srv("com.notion/second", "https://api.notion.com/second");
|
|
133
|
+
const other = srv("com.linear/mcp", "https://mcp.linear.com/mcp");
|
|
134
|
+
|
|
135
|
+
test("official servers move to the front, endpoint order kept inside each group", () => {
|
|
136
|
+
expect(officialFirst([a, o1, b, other, o2], "notion")).toEqual([
|
|
137
|
+
o1,
|
|
138
|
+
o2,
|
|
139
|
+
a,
|
|
140
|
+
b,
|
|
141
|
+
other,
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("with an empty query the order is unchanged", () => {
|
|
146
|
+
expect(officialFirst([a, o1, b, o2], "")).toEqual([a, o1, b, o2]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("does not mutate its input", () => {
|
|
150
|
+
const input = [a, o1, b];
|
|
151
|
+
const snapshot = [...input];
|
|
152
|
+
const out = officialFirst(input, "notion");
|
|
153
|
+
expect(input).toEqual(snapshot);
|
|
154
|
+
expect(out).not.toBe(input);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("an empty list stays empty", () => {
|
|
158
|
+
expect(officialFirst([], "notion")).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("MCP registry keymap", () => {
|
|
163
|
+
test("d is bound to a remove action on the mcp-registry screen", () => {
|
|
164
|
+
expect(matchBinding(SCREEN_BINDINGS["mcp-registry"], press("d"))?.id).toBe(
|
|
165
|
+
"remove",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("stale registry responses", () => {
|
|
171
|
+
// The screen (McpRegistryScreen.tsx) is a React/OpenTUI component whose
|
|
172
|
+
// loadServers closes over component state; the repo has no harness that
|
|
173
|
+
// drives it with fake fetches. The guard is therefore tested as the small
|
|
174
|
+
// pure helper the screen will call: take a token before each request, apply
|
|
175
|
+
// the response only when isLatest(token) still holds.
|
|
176
|
+
test("only the newest request's response is applied when responses arrive out of order", async () => {
|
|
177
|
+
const guard = createLatestOnlyGuard();
|
|
178
|
+
const deferred = <T>() => {
|
|
179
|
+
let resolve!: (v: T) => void;
|
|
180
|
+
const promise = new Promise<T>((r) => {
|
|
181
|
+
resolve = r;
|
|
182
|
+
});
|
|
183
|
+
return { promise, resolve };
|
|
184
|
+
};
|
|
185
|
+
const first = deferred<string[]>();
|
|
186
|
+
const second = deferred<string[]>();
|
|
187
|
+
let applied: string[] | null = null;
|
|
188
|
+
const appliedFrom: string[] = [];
|
|
189
|
+
|
|
190
|
+
const load = async (label: string, p: Promise<string[]>) => {
|
|
191
|
+
const token = guard.next();
|
|
192
|
+
const result = await p;
|
|
193
|
+
if (guard.isLatest(token)) {
|
|
194
|
+
applied = result;
|
|
195
|
+
appliedFrom.push(label);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const a = load("old", first.promise);
|
|
200
|
+
const b = load("new", second.promise);
|
|
201
|
+
second.resolve(["new-server"]); // newest answers first
|
|
202
|
+
await b;
|
|
203
|
+
first.resolve(["old-server"]); // stale answer lands last
|
|
204
|
+
await a;
|
|
205
|
+
|
|
206
|
+
expect(applied).toEqual(["new-server"]);
|
|
207
|
+
expect(appliedFrom).toEqual(["new"]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isNotInstalledAnswer } from "../services/claude-cli.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `isNotInstalledAnswer` classifies the CLI's answer to an uninstall at a
|
|
6
|
+
* scope that holds nothing for this folder. Claude Code has two wordings for
|
|
7
|
+
* it, and both mean "nothing to do here", never a failure.
|
|
8
|
+
*
|
|
9
|
+
* MEASURED on Claude Code 2.1.312, from a folder with no registry row while
|
|
10
|
+
* other folders hold PROJECT rows for the plugin: `--scope user` and
|
|
11
|
+
* `--scope local` exit 1 with the strings below. The older wording is what the
|
|
12
|
+
* CLI says when no folder holds a row at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ID = "code-analysis@magus";
|
|
16
|
+
|
|
17
|
+
/** The exact text `claude plugin uninstall … --scope user` printed. */
|
|
18
|
+
const MEASURED_USER = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not user. Use --scope project to uninstall.`;
|
|
19
|
+
/** The exact text `claude plugin uninstall … --scope local` printed. */
|
|
20
|
+
const MEASURED_LOCAL = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not local. Use --scope project to uninstall.`;
|
|
21
|
+
|
|
22
|
+
describe("isNotInstalledAnswer", () => {
|
|
23
|
+
// REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
|
|
24
|
+
test("should treat the measured 'installed in project scope, not user' answer as nothing to remove", () => {
|
|
25
|
+
expect(isNotInstalledAnswer(new Error(MEASURED_USER))).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
|
|
29
|
+
test("should treat the measured 'installed in project scope, not local' answer as nothing to remove", () => {
|
|
30
|
+
expect(isNotInstalledAnswer(new Error(MEASURED_LOCAL))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
|
|
34
|
+
test("should still treat the older 'not installed in user scope' answer as nothing to remove", () => {
|
|
35
|
+
expect(
|
|
36
|
+
isNotInstalledAnswer(
|
|
37
|
+
new Error(`Plugin "${ID}" is not installed in user scope.`),
|
|
38
|
+
),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
|
|
43
|
+
test("should still count an unrelated error as a failure", () => {
|
|
44
|
+
expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|