magus-cli 7.11.1 → 7.11.3
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__/blind/requires-min-version/manifests.test.ts +5 -3
- package/src/__tests__/orphan-user-row-repair.test.ts +102 -0
- package/src/__tests__/scope-uninstall-dependents.test.ts +173 -0
- package/src/__tests__/scope-uninstall-profile.test.ts +103 -0
- package/src/__tests__/scratch-git.ts +48 -0
- package/src/__tests__/worktree-deprecated-remove-reader.test.ts +268 -0
- package/src/__tests__/worktree-deprecated-remove.test.ts +115 -0
- package/src/services/claude-cli.ts +9 -0
- package/src/services/claude-settings.ts +87 -5
- package/src/services/resolver.ts +32 -0
- package/src/services/update-engine.ts +200 -3
- package/src/ui/screens/PluginsScreen.tsx +51 -10
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import { isNotInstalledAnswer } from "../services/claude-cli.js";
|
|
5
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
6
|
+
import {
|
|
7
|
+
inheritedRegistryRows,
|
|
8
|
+
inheritedRowsOf,
|
|
9
|
+
} from "../services/claude-settings.js";
|
|
10
|
+
import { clearWorktreeCache } from "../services/git-worktree.js";
|
|
11
|
+
import {
|
|
12
|
+
type ApplyReporter,
|
|
13
|
+
type PluginApplyDeps,
|
|
14
|
+
applyPlugins,
|
|
15
|
+
} from "../services/update-engine.js";
|
|
16
|
+
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
17
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
18
|
+
import { addWorktree, initRepo, makeScratch } from "./scratch-git.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Removing a deprecated plugin from a linked worktree whose only registry row
|
|
22
|
+
* belongs to the main checkout: the reader that finds that row, the folder the
|
|
23
|
+
* uninstall runs in, and the failures that must not be swallowed.
|
|
24
|
+
*
|
|
25
|
+
* Repository membership is git's answer (`inheritablePaths`), so the cases that
|
|
26
|
+
* depend on it run against real scratch repositories.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const OTHER = "/elsewhere/unrelated";
|
|
30
|
+
const ID = "code-analysis@magus";
|
|
31
|
+
|
|
32
|
+
let scratch: string;
|
|
33
|
+
let MAIN: string;
|
|
34
|
+
let TREE: string;
|
|
35
|
+
// Two INDEPENDENT repositories, the second kept under the first's
|
|
36
|
+
// `.claude/worktrees/` — a path shape that is not repository membership.
|
|
37
|
+
let PARENT: string;
|
|
38
|
+
let NESTED: string;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
scratch = await makeScratch();
|
|
42
|
+
MAIN = path.join(scratch, "repo");
|
|
43
|
+
TREE = path.join(scratch, "feature");
|
|
44
|
+
await initRepo(MAIN);
|
|
45
|
+
addWorktree(MAIN, TREE, "feature");
|
|
46
|
+
PARENT = path.join(scratch, "parent");
|
|
47
|
+
NESTED = path.join(PARENT, ".claude", "worktrees", "child");
|
|
48
|
+
await initRepo(PARENT);
|
|
49
|
+
await initRepo(NESTED);
|
|
50
|
+
clearWorktreeCache();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
if (scratch) await fs.remove(scratch);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const entry = (
|
|
58
|
+
scope: InstalledPluginEntry["scope"],
|
|
59
|
+
projectPath?: string,
|
|
60
|
+
): InstalledPluginEntry => ({
|
|
61
|
+
scope,
|
|
62
|
+
version: "7.3.0",
|
|
63
|
+
projectPath,
|
|
64
|
+
installPath: "/cache/7.3.0",
|
|
65
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
66
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("inheritedRegistryRows", () => {
|
|
70
|
+
test("finds the main checkout's project and local rows", () => {
|
|
71
|
+
const rows = inheritedRegistryRows(
|
|
72
|
+
[entry("project", MAIN), entry("local", MAIN)],
|
|
73
|
+
TREE,
|
|
74
|
+
[MAIN],
|
|
75
|
+
);
|
|
76
|
+
expect(rows.map((r) => r.scope).sort()).toEqual(["local", "project"]);
|
|
77
|
+
expect(rows.every((r) => r.projectPath === MAIN)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("does not return the project's own row", () => {
|
|
81
|
+
const rows = inheritedRegistryRows(
|
|
82
|
+
[entry("project", TREE), entry("project", MAIN)],
|
|
83
|
+
TREE,
|
|
84
|
+
[MAIN, TREE],
|
|
85
|
+
);
|
|
86
|
+
expect(rows.map((r) => r.projectPath)).toEqual([MAIN]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("never returns rows of an unrelated project or the user row", () => {
|
|
90
|
+
const rows = inheritedRegistryRows(
|
|
91
|
+
[entry("project", OTHER), entry("user")],
|
|
92
|
+
TREE,
|
|
93
|
+
[MAIN],
|
|
94
|
+
);
|
|
95
|
+
expect(rows).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns nothing when no same-repository folder is known", () => {
|
|
99
|
+
expect(inheritedRegistryRows([entry("project", MAIN)], TREE, [])).toEqual(
|
|
100
|
+
[],
|
|
101
|
+
);
|
|
102
|
+
expect(inheritedRegistryRows(undefined, TREE, [MAIN])).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a linked worktree inherits its main checkout's row, per git", async () => {
|
|
106
|
+
const rows = await inheritedRowsOf(
|
|
107
|
+
[entry("project", MAIN), entry("project", OTHER)],
|
|
108
|
+
TREE,
|
|
109
|
+
);
|
|
110
|
+
expect(rows.map((r) => r.projectPath)).toEqual([MAIN]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an unrelated parent repository's row is NEVER returned for a nested independent repository", async () => {
|
|
114
|
+
const rows = await inheritedRowsOf(
|
|
115
|
+
[entry("project", PARENT), entry("local", PARENT)],
|
|
116
|
+
NESTED,
|
|
117
|
+
);
|
|
118
|
+
expect(rows).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("isNotInstalledAnswer", () => {
|
|
123
|
+
test("recognises the CLI's per-scope answer only", () => {
|
|
124
|
+
expect(
|
|
125
|
+
isNotInstalledAnswer(
|
|
126
|
+
new Error(`Plugin "${ID}" is not installed in project scope.`),
|
|
127
|
+
),
|
|
128
|
+
).toBe(true);
|
|
129
|
+
expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function harness(
|
|
134
|
+
rows: InstalledPluginEntry[],
|
|
135
|
+
uninstall: (id: string, scope: PluginScope, cwd: string) => Promise<void>,
|
|
136
|
+
) {
|
|
137
|
+
const unused = async () => {
|
|
138
|
+
throw new Error("a removal must not install, update or repair");
|
|
139
|
+
};
|
|
140
|
+
const deps: PluginApplyDeps = {
|
|
141
|
+
install: unused,
|
|
142
|
+
update: unused,
|
|
143
|
+
repair: unused,
|
|
144
|
+
readInstalled: unused,
|
|
145
|
+
saveInstalled: unused,
|
|
146
|
+
repairStaleLoad: unused,
|
|
147
|
+
registryRows: async () => rows,
|
|
148
|
+
remove: {
|
|
149
|
+
uninstall,
|
|
150
|
+
isInstalled: async () => false,
|
|
151
|
+
dropFromProfile: async () => true,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const item: PluginUpdateItem = {
|
|
155
|
+
pluginId: ID,
|
|
156
|
+
pinned: "latest",
|
|
157
|
+
installed: null,
|
|
158
|
+
loaded: null,
|
|
159
|
+
available: null,
|
|
160
|
+
target: null,
|
|
161
|
+
action: "remove",
|
|
162
|
+
reason: "deprecated",
|
|
163
|
+
scopes: [],
|
|
164
|
+
beforeVersions: {},
|
|
165
|
+
note: "no longer published",
|
|
166
|
+
};
|
|
167
|
+
const details: string[] = [];
|
|
168
|
+
const reporter: ApplyReporter = {
|
|
169
|
+
begin: () => {},
|
|
170
|
+
finish: (_ok, _id, detail) => {
|
|
171
|
+
details.push(String(detail));
|
|
172
|
+
},
|
|
173
|
+
detach: (work) => work(),
|
|
174
|
+
};
|
|
175
|
+
return { deps, item, reporter, details };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe("applyPlugins: removing an inherited row", () => {
|
|
179
|
+
test("runs the uninstall in the row's own folder", async () => {
|
|
180
|
+
let rows = [entry("project", MAIN), entry("project", OTHER)];
|
|
181
|
+
const calls: Array<[PluginScope, string]> = [];
|
|
182
|
+
const h = harness(rows, async (_id, scope, cwd) => {
|
|
183
|
+
calls.push([scope, cwd]);
|
|
184
|
+
rows = rows.filter((r) => r.projectPath !== cwd);
|
|
185
|
+
});
|
|
186
|
+
h.deps.registryRows = async () => rows;
|
|
187
|
+
|
|
188
|
+
const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
|
|
189
|
+
|
|
190
|
+
expect(calls).toEqual([["project", MAIN]]);
|
|
191
|
+
expect(result.failed).toEqual([]);
|
|
192
|
+
// The unrelated project's row is untouched.
|
|
193
|
+
expect(rows.map((r) => r.projectPath)).toEqual([OTHER]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a real uninstall error surfaces instead of being swallowed", async () => {
|
|
197
|
+
const rows = [entry("project", MAIN)];
|
|
198
|
+
const h = harness(rows, async () => {
|
|
199
|
+
throw new Error("claude plugin uninstall timed out");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
|
|
203
|
+
|
|
204
|
+
expect(result.failed).toEqual([ID]);
|
|
205
|
+
expect(h.details.join(" ")).toContain("timed out");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("says where it is still installed when the command reported nothing", async () => {
|
|
209
|
+
const rows = [entry("project", MAIN)];
|
|
210
|
+
const h = harness(rows, async () => {});
|
|
211
|
+
|
|
212
|
+
const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
|
|
213
|
+
|
|
214
|
+
expect(result.failed).toEqual([ID]);
|
|
215
|
+
const detail = h.details.join(" ");
|
|
216
|
+
expect(detail).toContain(
|
|
217
|
+
`still installed in ${path.basename(MAIN)}, another folder of this repository`,
|
|
218
|
+
);
|
|
219
|
+
// A folder is named, never an absolute path.
|
|
220
|
+
expect(detail).not.toContain(MAIN);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("an unreadable registry after the uninstalls is a failed removal", async () => {
|
|
224
|
+
const rows = [entry("project", MAIN)];
|
|
225
|
+
let reads = 0;
|
|
226
|
+
const h = harness(rows, async () => {
|
|
227
|
+
throw new Error("claude plugin uninstall timed out");
|
|
228
|
+
});
|
|
229
|
+
h.deps.registryRows = async () => {
|
|
230
|
+
reads++;
|
|
231
|
+
if (reads > 1) throw new Error("registry unreadable");
|
|
232
|
+
return rows;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
|
|
236
|
+
|
|
237
|
+
expect(result.failed).toEqual([ID]);
|
|
238
|
+
expect(result.removed).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("an unreadable registry with no error from the CLI says it could not confirm", async () => {
|
|
242
|
+
const rows = [entry("project", MAIN)];
|
|
243
|
+
let reads = 0;
|
|
244
|
+
const h = harness(rows, async () => {});
|
|
245
|
+
h.deps.registryRows = async () => {
|
|
246
|
+
reads++;
|
|
247
|
+
if (reads > 1) throw new Error("registry unreadable");
|
|
248
|
+
return rows;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
|
|
252
|
+
|
|
253
|
+
expect(result.failed).toEqual([ID]);
|
|
254
|
+
expect(h.details.join(" ")).toContain("could not confirm it was removed");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("never uninstalls an unrelated parent repository's row from a nested independent repository", async () => {
|
|
258
|
+
const rows = [entry("project", PARENT)];
|
|
259
|
+
const calls: string[] = [];
|
|
260
|
+
const h = harness(rows, async (_id, _scope, cwd) => {
|
|
261
|
+
calls.push(cwd);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await applyPlugins([h.item], NESTED, h.deps, h.reporter);
|
|
265
|
+
|
|
266
|
+
expect(calls).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
5
|
+
import { clearWorktreeCache } from "../services/git-worktree.js";
|
|
6
|
+
import {
|
|
7
|
+
type ApplyReporter,
|
|
8
|
+
type PluginApplyDeps,
|
|
9
|
+
applyPlugins,
|
|
10
|
+
} from "../services/update-engine.js";
|
|
11
|
+
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
12
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
13
|
+
import { addWorktree, initRepo, makeScratch } from "./scratch-git.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `d` on a deprecated plugin from a linked worktree, when the only registry
|
|
17
|
+
* row for it belongs to the MAIN checkout.
|
|
18
|
+
*
|
|
19
|
+
* The orphan row exists because the registry row is inherited from the main
|
|
20
|
+
* checkout, but no settings file here turns the plugin on, so the plan carries
|
|
21
|
+
* no scopes. The removal then runs zero uninstalls.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const ID = "code-analysis@magus";
|
|
25
|
+
|
|
26
|
+
// A real repository with a real linked worktree, so `inheritablePaths` answers
|
|
27
|
+
// the way git does rather than from a path shape.
|
|
28
|
+
let scratch: string;
|
|
29
|
+
let MAIN: string;
|
|
30
|
+
let TREE: string;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
scratch = await makeScratch();
|
|
34
|
+
MAIN = path.join(scratch, "repo");
|
|
35
|
+
TREE = path.join(scratch, "feature");
|
|
36
|
+
await initRepo(MAIN);
|
|
37
|
+
addWorktree(MAIN, TREE, "feature");
|
|
38
|
+
clearWorktreeCache();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
if (scratch) await fs.remove(scratch);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function row(projectPath: string): InstalledPluginEntry {
|
|
46
|
+
return {
|
|
47
|
+
scope: "project",
|
|
48
|
+
version: "7.3.0",
|
|
49
|
+
projectPath,
|
|
50
|
+
installPath: "/cache/7.3.0",
|
|
51
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
52
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("applyPlugins: remove from a linked worktree", () => {
|
|
57
|
+
// REGRESSION: worktree d on a deprecated plugin removed nothing and showed no error — Fixed in /dev:fix session dev-fix-20261026-155239-490d2c7e
|
|
58
|
+
test("should not report success while the main checkout's registry row remains when removing from a linked worktree", async () => {
|
|
59
|
+
let rows: InstalledPluginEntry[] = [row(MAIN)];
|
|
60
|
+
const unused = async () => {
|
|
61
|
+
throw new Error("a removal must not install, update or repair");
|
|
62
|
+
};
|
|
63
|
+
const deps: PluginApplyDeps = {
|
|
64
|
+
install: unused,
|
|
65
|
+
update: unused,
|
|
66
|
+
repair: unused,
|
|
67
|
+
readInstalled: unused,
|
|
68
|
+
saveInstalled: unused,
|
|
69
|
+
repairStaleLoad: unused,
|
|
70
|
+
registryRows: async () => rows,
|
|
71
|
+
remove: {
|
|
72
|
+
// Like `claude plugin uninstall --scope project`: only the row
|
|
73
|
+
// pinned to exactly the cwd it runs in can be removed.
|
|
74
|
+
uninstall: async (_id, scope: PluginScope, cwd) => {
|
|
75
|
+
const hit = rows.some(
|
|
76
|
+
(r) => r.scope === scope && r.projectPath === cwd,
|
|
77
|
+
);
|
|
78
|
+
if (!hit) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Plugin "${ID}" is not installed in ${scope} scope.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
rows = rows.filter((r) => r.projectPath !== cwd);
|
|
84
|
+
},
|
|
85
|
+
// Settings-derived: no settings turn the plugin on here.
|
|
86
|
+
isInstalled: async () => false,
|
|
87
|
+
dropFromProfile: async () => true,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const item: PluginUpdateItem = {
|
|
91
|
+
pluginId: ID,
|
|
92
|
+
pinned: "latest",
|
|
93
|
+
installed: null,
|
|
94
|
+
loaded: null,
|
|
95
|
+
available: null,
|
|
96
|
+
target: null,
|
|
97
|
+
action: "remove",
|
|
98
|
+
reason: "deprecated",
|
|
99
|
+
scopes: [],
|
|
100
|
+
beforeVersions: {},
|
|
101
|
+
note: "no longer published",
|
|
102
|
+
};
|
|
103
|
+
const reporter: ApplyReporter = {
|
|
104
|
+
begin: () => {},
|
|
105
|
+
finish: () => {},
|
|
106
|
+
detach: (work) => work(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const result = await applyPlugins([item], TREE, deps, reporter);
|
|
110
|
+
|
|
111
|
+
// Either the row is really gone (uninstall ran in the main checkout), or
|
|
112
|
+
// the removal says it failed. Success with the row still there is the bug.
|
|
113
|
+
expect(rows.length === 0 || result.failed.includes(ID)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -268,6 +268,15 @@ export async function uninstallPluginViaCli(
|
|
|
268
268
|
);
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Is `error` the CLI's answer that the plugin is not installed at the scope it
|
|
273
|
+
* was asked about — the one failure of an uninstall that means "nothing to do".
|
|
274
|
+
*/
|
|
275
|
+
export function isNotInstalledAnswer(error: unknown): boolean {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
return /not installed in (user|project|local) scope/i.test(message);
|
|
278
|
+
}
|
|
279
|
+
|
|
271
280
|
/**
|
|
272
281
|
* Repair a plugin whose files drifted from the marketplace under an unchanged
|
|
273
282
|
* version — uninstall, then install again at the same scope.
|
|
@@ -434,6 +434,74 @@ export function ownRegistryEntry(
|
|
|
434
434
|
);
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/**
|
|
438
|
+
* The project and local rows of one plugin that `projectPath` INHERITS: rows
|
|
439
|
+
* pinned to a same-repository folder (`fallbackPaths`, as
|
|
440
|
+
* {@link pickRegistryEntry} reads them) rather than to `projectPath` itself.
|
|
441
|
+
*
|
|
442
|
+
* These are the rows that make a plugin show up in a linked worktree that never
|
|
443
|
+
* installed it, and `claude plugin uninstall` removes them only when run from the
|
|
444
|
+
* row's own folder. Rows of any other project are never returned, and neither is
|
|
445
|
+
* the project's own row ({@link ownRegistryEntry} answers that one).
|
|
446
|
+
*/
|
|
447
|
+
export function inheritedRegistryRows(
|
|
448
|
+
entries: readonly InstalledPluginEntry[] | undefined,
|
|
449
|
+
projectPath: string,
|
|
450
|
+
fallbackPaths: readonly string[],
|
|
451
|
+
): InstalledPluginEntry[] {
|
|
452
|
+
if (!Array.isArray(entries)) return [];
|
|
453
|
+
const own = path.resolve(projectPath);
|
|
454
|
+
const sources = new Set(fallbackPaths.map((p) => path.resolve(p)));
|
|
455
|
+
sources.delete(own);
|
|
456
|
+
return entries.filter(
|
|
457
|
+
(e) =>
|
|
458
|
+
(e.scope === "project" || e.scope === "local") &&
|
|
459
|
+
e.projectPath !== undefined &&
|
|
460
|
+
sources.has(path.resolve(e.projectPath)),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Same-repository folders whose rows `projectPath` inherits: git's
|
|
466
|
+
* `inheritablePaths`, the one definition the read side ({@link pickRegistryEntry})
|
|
467
|
+
* uses too. `[]` when git cannot answer.
|
|
468
|
+
*/
|
|
469
|
+
async function inheritableFolders(projectPath: string): Promise<string[]> {
|
|
470
|
+
return inheritablePaths(projectPath);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Read-only: the inherited rows of `pluginId` for `projectPath`, read from the
|
|
475
|
+
* registry. `[]` when the registry cannot be read or there are none.
|
|
476
|
+
*/
|
|
477
|
+
export async function readInheritedRegistryRows(
|
|
478
|
+
pluginId: string,
|
|
479
|
+
projectPath: string,
|
|
480
|
+
): Promise<InstalledPluginEntry[]> {
|
|
481
|
+
try {
|
|
482
|
+
const registry = await readInstalledPluginsRegistry();
|
|
483
|
+
return inheritedRegistryRows(
|
|
484
|
+
registry.plugins?.[pluginId],
|
|
485
|
+
projectPath,
|
|
486
|
+
await inheritableFolders(projectPath),
|
|
487
|
+
);
|
|
488
|
+
} catch {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The inherited rows within `rows` already read, for a caller holding them. */
|
|
494
|
+
export async function inheritedRowsOf(
|
|
495
|
+
rows: readonly InstalledPluginEntry[],
|
|
496
|
+
projectPath: string,
|
|
497
|
+
): Promise<InstalledPluginEntry[]> {
|
|
498
|
+
return inheritedRegistryRows(
|
|
499
|
+
rows,
|
|
500
|
+
projectPath,
|
|
501
|
+
await inheritableFolders(projectPath),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
437
505
|
async function overlayRegistryVersions(
|
|
438
506
|
base: Record<string, string>,
|
|
439
507
|
scope: "user" | "project" | "local",
|
|
@@ -1947,6 +2015,12 @@ export interface StaleLoadRepairResult {
|
|
|
1947
2015
|
ok: boolean;
|
|
1948
2016
|
/** Why it failed. Absent when `ok`. */
|
|
1949
2017
|
error?: string;
|
|
2018
|
+
/**
|
|
2019
|
+
* Set when it refused because a machine-wide row wins here, naming that
|
|
2020
|
+
* scope — so a caller can tell a real USER install from an orphan USER row
|
|
2021
|
+
* without matching on `error`.
|
|
2022
|
+
*/
|
|
2023
|
+
blockedBy?: "user" | "managed";
|
|
1950
2024
|
}
|
|
1951
2025
|
|
|
1952
2026
|
/**
|
|
@@ -2087,11 +2161,19 @@ export async function repairStaleLoad(
|
|
|
2087
2161
|
// plugins, which the registry type in types/index.ts does not name — see
|
|
2088
2162
|
// `LoaderScope` in plugin-resolution.ts.
|
|
2089
2163
|
const scope: string = winner.scope;
|
|
2090
|
-
return
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2164
|
+
return scope === "managed"
|
|
2165
|
+
? {
|
|
2166
|
+
...fail(
|
|
2167
|
+
"Claude Code loads the install your administrator manages here — ask them to update it",
|
|
2168
|
+
),
|
|
2169
|
+
blockedBy: "managed",
|
|
2170
|
+
}
|
|
2171
|
+
: {
|
|
2172
|
+
...fail(
|
|
2173
|
+
"Claude Code loads the USER install of it here — update it at USER scope",
|
|
2174
|
+
),
|
|
2175
|
+
blockedBy: "user",
|
|
2176
|
+
};
|
|
2095
2177
|
}
|
|
2096
2178
|
|
|
2097
2179
|
const stale = rows.filter(
|
package/src/services/resolver.ts
CHANGED
|
@@ -195,6 +195,38 @@ export async function requirersHere(
|
|
|
195
195
|
return edges;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The plugins `profileId` declares that need `pluginId` to load, directly or
|
|
200
|
+
* transitively — sorted ids, none when the profile does not exist.
|
|
201
|
+
*
|
|
202
|
+
* `resolveProfile` re-adds every dependency of a declared plugin, so while one
|
|
203
|
+
* of these is declared, the profile cannot stop enabling `pluginId`. Each
|
|
204
|
+
* declared plugin's closure is walked with the same `resolveDependencies` the
|
|
205
|
+
* resolve uses, so the two cannot disagree about what comes back.
|
|
206
|
+
*/
|
|
207
|
+
export async function profileDependentsOf(
|
|
208
|
+
manifest: ProfileManifest,
|
|
209
|
+
profileId: string,
|
|
210
|
+
pluginId: string,
|
|
211
|
+
dependencyResolver: PluginDependencyReader,
|
|
212
|
+
): Promise<string[]> {
|
|
213
|
+
const raw = manifest.profiles[profileId];
|
|
214
|
+
if (!raw) return [];
|
|
215
|
+
const entry = resolveExtends(raw);
|
|
216
|
+
const off = new Set(
|
|
217
|
+
Object.entries(entry.plugins ?? {})
|
|
218
|
+
.filter(([, ver]) => ver === false)
|
|
219
|
+
.map(([id]) => id),
|
|
220
|
+
);
|
|
221
|
+
const dependents: string[] = [];
|
|
222
|
+
for (const id of Object.keys(normalizePlugins(entry.plugins))) {
|
|
223
|
+
if (id === pluginId) continue;
|
|
224
|
+
const report = await resolveDependencies([id], off, dependencyResolver);
|
|
225
|
+
if (report.derived.includes(pluginId)) dependents.push(id);
|
|
226
|
+
}
|
|
227
|
+
return dependents.sort();
|
|
228
|
+
}
|
|
229
|
+
|
|
198
230
|
function hasDependencies(report: DependencyReport): boolean {
|
|
199
231
|
return (
|
|
200
232
|
report.derived.length > 0 ||
|