magus-cli 7.11.4 → 7.11.5
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__/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/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/screens/PluginsScreen.tsx +24 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.11.
|
|
3
|
+
"version": "7.11.5",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.11.
|
|
69
|
-
"magus-cli-darwin-x64": "7.11.
|
|
70
|
-
"magus-cli-linux-x64": "7.11.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.11.5",
|
|
69
|
+
"magus-cli-darwin-x64": "7.11.5",
|
|
70
|
+
"magus-cli-linux-x64": "7.11.5"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
7
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
8
|
+
import { reconcileActiveProfile } from "../services/profile-absorb.js";
|
|
9
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
10
|
+
import { removeDeprecatedPlugin } from "../services/update-engine.js";
|
|
11
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `d` on a deprecated plugin that is installed at no scope in this folder, but
|
|
15
|
+
* shows as a row because the profile render writes `"<id>": false` for it.
|
|
16
|
+
*
|
|
17
|
+
* The render writes `false` for every id in `allPluginIds`, the union of every
|
|
18
|
+
* profile in `.claude/profiles.json` (`renderProfile`), so a plugin a profile
|
|
19
|
+
* that is NOT active still lists keeps its key in the live settings.json. The
|
|
20
|
+
* Plugins screen turns that key into a row. Uninstalling touches nothing:
|
|
21
|
+
* there is no install here, and the CLI answers every scope with "nothing at
|
|
22
|
+
* this scope here", in the wording measured on Claude Code 2.1.312.
|
|
23
|
+
*
|
|
24
|
+
* CONTRACT the fix must provide, in `services/update-engine.ts`, for the
|
|
25
|
+
* Plugins screen's `d` to call when the plan carries no `remove` item:
|
|
26
|
+
*
|
|
27
|
+
* export async function removeDeprecatedPlugin(
|
|
28
|
+
* pluginId: string,
|
|
29
|
+
* projectPath: string,
|
|
30
|
+
* deps?: {
|
|
31
|
+
* // default `uninstallPluginViaCli`
|
|
32
|
+
* uninstall?: (id: string, scope: PluginScope, cwd: string) => Promise<void>;
|
|
33
|
+
* // default `readInheritedRegistryRows`
|
|
34
|
+
* inheritedRows?: (id: string, projectPath: string) => Promise<InstalledPluginEntry[]>;
|
|
35
|
+
* },
|
|
36
|
+
* ): Promise<{ problems: unknown[]; unlistedFrom: string[] }>
|
|
37
|
+
*
|
|
38
|
+
* 1. Uninstalls at user, project and local in `projectPath`; an error
|
|
39
|
+
* `isNotInstalledAnswer` accepts is not a problem.
|
|
40
|
+
* 2. Uninstalls each inherited row in its own folder; errors are problems.
|
|
41
|
+
* 3. Removes the id from EVERY profile in the project's profiles.json that
|
|
42
|
+
* lists it (`removePluginFromProfile` per profile); `unlistedFrom` names
|
|
43
|
+
* them. Profiles that do not list it are not written.
|
|
44
|
+
* 4. Re-applies the active profile, so the live settings.json no longer
|
|
45
|
+
* carries a key for the id.
|
|
46
|
+
*
|
|
47
|
+
* Only for a deprecated (no longer published) plugin: the caller decides that.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const ID = "code-analysis@magus";
|
|
51
|
+
|
|
52
|
+
/** Measured, Claude Code 2.1.312, from a folder with no row while others hold PROJECT rows. */
|
|
53
|
+
const wrongScope = (scope: PluginScope) =>
|
|
54
|
+
new Error(
|
|
55
|
+
`✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not ${scope}. Use --scope project to uninstall.`,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** The magus-src shape: the active profile does not list it, two others do. */
|
|
59
|
+
const manifest = (): ProfileManifest => ({
|
|
60
|
+
version: 2,
|
|
61
|
+
profiles: {
|
|
62
|
+
default: { name: "Default", plugins: { "dev@magus": "latest" } },
|
|
63
|
+
maintainer: {
|
|
64
|
+
name: "Maintainer",
|
|
65
|
+
plugins: { "dev@magus": "latest", [ID]: "latest" },
|
|
66
|
+
},
|
|
67
|
+
lean: { name: "Lean", plugins: { [ID]: "latest" } },
|
|
68
|
+
minimal: { name: "Minimal", plugins: { "setup@magus": "latest" } },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("removeDeprecatedPlugin: a deprecated plugin only other profiles list", () => {
|
|
73
|
+
let project: string;
|
|
74
|
+
let log: ReturnType<typeof spyOn>;
|
|
75
|
+
const settingsPath = () => join(project, ".claude", "settings.json");
|
|
76
|
+
const profilesPath = () => join(project, ".claude", "profiles.json");
|
|
77
|
+
const mcpPath = () => join(project, ".mcp.json");
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
project = await mkdtemp(join(tmpdir(), "deprecated-all-profiles-"));
|
|
81
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
82
|
+
await writeManifest(manifest(), project);
|
|
83
|
+
await activateProfile(manifest(), "default", project, {
|
|
84
|
+
onDisplace: () => {},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
log.mockRestore();
|
|
89
|
+
await rm(project, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 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
|
|
93
|
+
test("should report no failure, unlist it from every profile, and leave no settings key for it", async () => {
|
|
94
|
+
// Seeded red: the render wrote the key that makes the row.
|
|
95
|
+
expect((await fs.readJson(settingsPath())).enabledPlugins[ID]).toBe(false);
|
|
96
|
+
const before = (await fs.readJson(profilesPath())).profiles;
|
|
97
|
+
|
|
98
|
+
const calls: Array<{ scope: PluginScope; cwd: string }> = [];
|
|
99
|
+
const result = await removeDeprecatedPlugin(ID, project, {
|
|
100
|
+
uninstall: async (_id, scope, cwd) => {
|
|
101
|
+
calls.push({ scope, cwd });
|
|
102
|
+
if (scope === "project") {
|
|
103
|
+
throw new Error(`Plugin "${ID}" is not installed in project scope.`);
|
|
104
|
+
}
|
|
105
|
+
throw wrongScope(scope);
|
|
106
|
+
},
|
|
107
|
+
inheritedRows: async () => [],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result.problems).toEqual([]);
|
|
111
|
+
expect([...result.unlistedFrom].sort()).toEqual(["lean", "maintainer"]);
|
|
112
|
+
expect(calls.map((c) => c.scope).sort()).toEqual([
|
|
113
|
+
"local",
|
|
114
|
+
"project",
|
|
115
|
+
"user",
|
|
116
|
+
]);
|
|
117
|
+
expect(calls.every((c) => c.cwd === project)).toBe(true);
|
|
118
|
+
|
|
119
|
+
const after = (await readManifest(project)).profiles;
|
|
120
|
+
for (const id of Object.keys(after)) {
|
|
121
|
+
expect(after[id]?.plugins?.[ID]).toBeUndefined();
|
|
122
|
+
}
|
|
123
|
+
expect(after.maintainer?.plugins).toEqual({ "dev@magus": "latest" });
|
|
124
|
+
// Profiles that never listed it are not written at all.
|
|
125
|
+
const raw = (await fs.readJson(profilesPath())).profiles;
|
|
126
|
+
expect(raw.default).toEqual(before.default);
|
|
127
|
+
expect(raw.minimal).toEqual(before.minimal);
|
|
128
|
+
|
|
129
|
+
const live = (await fs.readJson(settingsPath())).enabledPlugins;
|
|
130
|
+
expect(Object.hasOwn(live, ID)).toBe(false);
|
|
131
|
+
expect(live["dev@magus"]).toBe(true);
|
|
132
|
+
|
|
133
|
+
// The row does not come back: the next entry point's reconcile and a
|
|
134
|
+
// fresh activation leave the key out.
|
|
135
|
+
await reconcileActiveProfile(project);
|
|
136
|
+
await activateProfile(await readManifest(project), "default", project, {
|
|
137
|
+
onDisplace: () => {},
|
|
138
|
+
});
|
|
139
|
+
expect(
|
|
140
|
+
Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// REGRESSION: deprecated plugin `d` re-rendered the live files without absorbing them, losing edits Claude Code made after the TUI started — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
|
|
145
|
+
test("should keep edits made to the live files after the profile was activated", async () => {
|
|
146
|
+
// Written straight into the live files, as Claude Code does mid-session.
|
|
147
|
+
const settings = await fs.readJson(settingsPath());
|
|
148
|
+
settings.permissions = { allow: ["Bash(ls:*)"] };
|
|
149
|
+
settings.enabledPlugins["other@magus"] = true;
|
|
150
|
+
await fs.writeJson(settingsPath(), settings, { spaces: 2 });
|
|
151
|
+
const mcp = await fs.readJson(mcpPath());
|
|
152
|
+
mcp.mcpServers = { ...mcp.mcpServers, local: { command: "local-mcp" } };
|
|
153
|
+
await fs.writeJson(mcpPath(), mcp, { spaces: 2 });
|
|
154
|
+
|
|
155
|
+
const result = await removeDeprecatedPlugin(ID, project, {
|
|
156
|
+
uninstall: async (_id, scope) => {
|
|
157
|
+
throw wrongScope(scope);
|
|
158
|
+
},
|
|
159
|
+
inheritedRows: async () => [],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result.problems).toEqual([]);
|
|
163
|
+
const live = await fs.readJson(settingsPath());
|
|
164
|
+
expect(live.permissions?.allow).toEqual(["Bash(ls:*)"]);
|
|
165
|
+
expect(live.enabledPlugins["other@magus"]).toBe(true);
|
|
166
|
+
expect(Object.hasOwn(live.enabledPlugins, ID)).toBe(false);
|
|
167
|
+
expect((await fs.readJson(mcpPath())).mcpServers?.local).toEqual({
|
|
168
|
+
command: "local-mcp",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
|
|
7
|
+
import { planReport } from "../cli/update-view.js";
|
|
8
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
9
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
10
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
11
|
+
import { reconcileActiveProfile } from "../services/profile-absorb.js";
|
|
12
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
13
|
+
import { withCliBookkeeping } from "../services/profile-state.js";
|
|
14
|
+
import {
|
|
15
|
+
pluginsAnyProfileLists,
|
|
16
|
+
resolveProfile,
|
|
17
|
+
} from "../services/resolver.js";
|
|
18
|
+
import {
|
|
19
|
+
type ApplyReporter,
|
|
20
|
+
type PluginApplyDeps,
|
|
21
|
+
REAL_REMOVE_DEPS,
|
|
22
|
+
applyPlugins,
|
|
23
|
+
describeRemoved,
|
|
24
|
+
} from "../services/update-engine.js";
|
|
25
|
+
import {
|
|
26
|
+
type PluginUpdateItem,
|
|
27
|
+
type UpdatePlan,
|
|
28
|
+
classifyPluginRows,
|
|
29
|
+
planIsBehind,
|
|
30
|
+
planPluginUpdates,
|
|
31
|
+
} from "../services/update-plan.js";
|
|
32
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `magus update` and the Plugins screen plan a deprecated plugin that only a
|
|
36
|
+
* profile which is NOT active lists.
|
|
37
|
+
*
|
|
38
|
+
* The profile render writes `"<id>": false` into `.claude/settings.json` for
|
|
39
|
+
* every id ANY profile lists, and the Plugins screen shows that key as a
|
|
40
|
+
* deprecated row. The planner used to see only the active profile's pins and
|
|
41
|
+
* the plugins installed somewhere, so `magus update --check` answered "Nothing
|
|
42
|
+
* missing and nothing behind." while the row stayed, and `magus update` never
|
|
43
|
+
* removed it. When the active profile did list it, the removal dropped it from
|
|
44
|
+
* that profile only, so another profile kept it and the `false` came back on
|
|
45
|
+
* the next render.
|
|
46
|
+
*
|
|
47
|
+
* CONTRACT:
|
|
48
|
+
* - `pluginsAnyProfileLists(manifest)` (resolver.ts): every plugin id some
|
|
49
|
+
* profile turns on, `extends` resolved.
|
|
50
|
+
* - `planPluginUpdates(pins, catalog, requiredBy, listedByProfiles)` and
|
|
51
|
+
* `classifyPluginRows(plugins, pins, requiredBy, listedByProfiles)`: an id
|
|
52
|
+
* in `listedByProfiles` that is neither pinned nor installed gets a
|
|
53
|
+
* `remove` item with `scopes: []` when it is deprecated, and NO item
|
|
54
|
+
* otherwise (published, moved, still required, marketplace unread).
|
|
55
|
+
* - applying a `remove` drops the id from EVERY profile listing it, absorbing
|
|
56
|
+
* the live files first, so the user's live edits survive and the key is
|
|
57
|
+
* gone from settings.json afterwards.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
const ID = "code-analysis@magus";
|
|
61
|
+
|
|
62
|
+
/** The reporter's shape: the active profile does not list it, two others do. */
|
|
63
|
+
const manifest = (activeListsIt = false): ProfileManifest => ({
|
|
64
|
+
version: 2,
|
|
65
|
+
profiles: {
|
|
66
|
+
default: {
|
|
67
|
+
name: "Default",
|
|
68
|
+
plugins: {
|
|
69
|
+
"dev@magus": "latest",
|
|
70
|
+
...(activeListsIt ? { [ID]: "latest" } : {}),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
maintainer: {
|
|
74
|
+
name: "Maintainer",
|
|
75
|
+
plugins: { "dev@magus": "latest", [ID]: "latest" },
|
|
76
|
+
},
|
|
77
|
+
lean: { name: "Lean", plugins: { [ID]: "latest" } },
|
|
78
|
+
minimal: { name: "Minimal", plugins: { "setup@magus": "latest" } },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
83
|
+
return {
|
|
84
|
+
name: over.id.split("@")[0] as string,
|
|
85
|
+
version: null,
|
|
86
|
+
description: "",
|
|
87
|
+
marketplace: over.id.split("@")[1] ?? "magus",
|
|
88
|
+
marketplaceDisplay: "",
|
|
89
|
+
...over,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Published and installed here — what makes "magus" a marketplace we read. */
|
|
94
|
+
const DEV = info({
|
|
95
|
+
id: "dev@magus",
|
|
96
|
+
version: "1.0.0",
|
|
97
|
+
projectScope: { installed: true, version: "1.0.0" },
|
|
98
|
+
});
|
|
99
|
+
/** Published, installed nowhere. */
|
|
100
|
+
const SETUP = info({ id: "setup@magus", version: "2.0.0" });
|
|
101
|
+
|
|
102
|
+
/** The id's catalog entry as the screen sees it: a `false` key, an orphan. */
|
|
103
|
+
const ORPHAN = info({ id: ID, isOrphaned: true });
|
|
104
|
+
|
|
105
|
+
const CATALOG: PluginInfo[] = [DEV, SETUP, ORPHAN];
|
|
106
|
+
|
|
107
|
+
function itemsFor(items: PluginUpdateItem[], id: string): PluginUpdateItem[] {
|
|
108
|
+
return items.filter((i) => i.pluginId === id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
afterEach(() => setColorEnabled(null));
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// 1. The planner
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("planner: a plugin only an inactive profile lists", () => {
|
|
118
|
+
// REGRESSION: `magus update --check` said "Nothing missing and nothing behind." while a deprecated plugin only other profiles list stayed a row — MTL-332
|
|
119
|
+
test("should plan one remove with no scopes for a deprecated id", () => {
|
|
120
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
|
|
121
|
+
ID,
|
|
122
|
+
"dev@magus",
|
|
123
|
+
"setup@magus",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const found = itemsFor(items, ID);
|
|
127
|
+
expect(found).toHaveLength(1);
|
|
128
|
+
expect({
|
|
129
|
+
action: found[0]?.action,
|
|
130
|
+
reason: found[0]?.reason,
|
|
131
|
+
scopes: found[0]?.scopes,
|
|
132
|
+
}).toEqual({ action: "remove", reason: "deprecated", scopes: [] });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("should plan a deprecated id with no catalog entry at all", () => {
|
|
136
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, [DEV], {}, [ID]);
|
|
137
|
+
expect(itemsFor(items, ID).map((i) => i.action)).toEqual(["remove"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("should plan nothing for a PUBLISHED id only an inactive profile lists", () => {
|
|
141
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
|
|
142
|
+
ID,
|
|
143
|
+
"setup@magus",
|
|
144
|
+
]);
|
|
145
|
+
expect(itemsFor(items, "setup@magus")).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("should not plan a remove for one that moved to another marketplace", () => {
|
|
149
|
+
const moved = info({ id: "code-analysis@other", version: "3.0.0" });
|
|
150
|
+
const items = planPluginUpdates(
|
|
151
|
+
{ "dev@magus": "latest" },
|
|
152
|
+
[...CATALOG, moved],
|
|
153
|
+
{},
|
|
154
|
+
[ID],
|
|
155
|
+
);
|
|
156
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "remove")).toEqual(
|
|
157
|
+
[],
|
|
158
|
+
);
|
|
159
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "install")).toEqual(
|
|
160
|
+
[],
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("should not plan a remove for one a staying plugin still requires", () => {
|
|
165
|
+
const items = planPluginUpdates(
|
|
166
|
+
{ "dev@magus": "latest" },
|
|
167
|
+
CATALOG,
|
|
168
|
+
{ [ID]: ["dev@magus"] },
|
|
169
|
+
[ID],
|
|
170
|
+
);
|
|
171
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "remove")).toEqual(
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "install")).toEqual(
|
|
175
|
+
[],
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("should plan nothing when the id's marketplace was not read", () => {
|
|
180
|
+
const unread = "thing@unread-market";
|
|
181
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
|
|
182
|
+
unread,
|
|
183
|
+
]);
|
|
184
|
+
expect(itemsFor(items, unread)).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("should make --check behind and report it under the removed wording", () => {
|
|
188
|
+
setColorEnabled(false);
|
|
189
|
+
const plan: UpdatePlan = {
|
|
190
|
+
profileId: "default",
|
|
191
|
+
plugins: planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [ID]),
|
|
192
|
+
bins: [],
|
|
193
|
+
skills: [],
|
|
194
|
+
binSpecs: new Map(),
|
|
195
|
+
};
|
|
196
|
+
expect(planIsBehind(plan)).toBe(true);
|
|
197
|
+
const report = planReport(plan).map(stripAnsi).join("\n");
|
|
198
|
+
expect(report).toContain("code-analysis");
|
|
199
|
+
expect(describeRemoved([ID])).toBe(
|
|
200
|
+
"Removed code-analysis — no longer published",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("pluginsAnyProfileLists", () => {
|
|
206
|
+
test("should list every id some profile turns on, and none turned off", () => {
|
|
207
|
+
const m = manifest();
|
|
208
|
+
const entry = m.profiles.minimal;
|
|
209
|
+
if (entry) entry.plugins = { ...entry.plugins, "off@magus": false };
|
|
210
|
+
expect([...pluginsAnyProfileLists(m)].sort()).toEqual([
|
|
211
|
+
ID,
|
|
212
|
+
"dev@magus",
|
|
213
|
+
"setup@magus",
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// 2. Parity: the CLI's plan and the screen's rows
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe("parity: magus update and the Plugins screen", () => {
|
|
223
|
+
test("should agree on a deprecated id only an inactive profile lists", () => {
|
|
224
|
+
const pins = { "dev@magus": "latest" };
|
|
225
|
+
const listed = pluginsAnyProfileLists(manifest());
|
|
226
|
+
const cli = planPluginUpdates(pins, CATALOG, {}, listed);
|
|
227
|
+
const rows = classifyPluginRows(CATALOG, pins, {}, listed);
|
|
228
|
+
|
|
229
|
+
expect(rows.get(ID)).toEqual(itemsFor(cli, ID)[0]);
|
|
230
|
+
expect(rows.get(ID)?.action).toBe("remove");
|
|
231
|
+
expect(rows.has("setup@magus")).toBe(
|
|
232
|
+
itemsFor(cli, "setup@magus").length > 0,
|
|
233
|
+
);
|
|
234
|
+
expect(rows.has("setup@magus")).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// 3 + 4. Applying the plan on a real project
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Measured, Claude Code 2.1.312, from a folder with no row while others hold PROJECT rows. */
|
|
243
|
+
const wrongScope = (scope: PluginScope) =>
|
|
244
|
+
new Error(
|
|
245
|
+
`✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not ${scope}. Use --scope project to uninstall.`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const SILENT: ApplyReporter = {
|
|
249
|
+
begin: () => {},
|
|
250
|
+
finish: () => {},
|
|
251
|
+
detach: (work) => work(),
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** The real profile half; Claude Code's commands are doubles. */
|
|
255
|
+
function deps(): PluginApplyDeps {
|
|
256
|
+
const unused = async () => {
|
|
257
|
+
throw new Error("a removal must not install, update or repair anything");
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
install: unused,
|
|
261
|
+
update: unused,
|
|
262
|
+
repair: unused,
|
|
263
|
+
readInstalled: unused,
|
|
264
|
+
saveInstalled: unused,
|
|
265
|
+
repairStaleLoad: unused,
|
|
266
|
+
registryRows: async () => [],
|
|
267
|
+
remove: {
|
|
268
|
+
...REAL_REMOVE_DEPS,
|
|
269
|
+
uninstall: async (_id, scope) => {
|
|
270
|
+
throw wrongScope(scope);
|
|
271
|
+
},
|
|
272
|
+
isInstalled: async () => false,
|
|
273
|
+
forgetVersion: async () => {},
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Both callers: `magus update` (under bookkeeping) and the Plugins screen. */
|
|
279
|
+
const SURFACES = [
|
|
280
|
+
["magus update", true],
|
|
281
|
+
["the Plugins screen", false],
|
|
282
|
+
] as const;
|
|
283
|
+
|
|
284
|
+
describe("applying the plan removes it from every profile", () => {
|
|
285
|
+
let project: string;
|
|
286
|
+
let log: ReturnType<typeof spyOn>;
|
|
287
|
+
const settingsPath = () => join(project, ".claude", "settings.json");
|
|
288
|
+
const profilesPath = () => join(project, ".claude", "profiles.json");
|
|
289
|
+
|
|
290
|
+
async function setUp(activeListsIt: boolean) {
|
|
291
|
+
await writeManifest(manifest(activeListsIt), project);
|
|
292
|
+
await activateProfile(manifest(activeListsIt), "default", project, {
|
|
293
|
+
onDisplace: () => {},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The plan exactly as `magus update` makes it. */
|
|
298
|
+
async function plan(): Promise<PluginUpdateItem[]> {
|
|
299
|
+
const m = await readManifest(project);
|
|
300
|
+
const closure = await resolveProfile(m, "default");
|
|
301
|
+
return planPluginUpdates(
|
|
302
|
+
closure.plugins,
|
|
303
|
+
CATALOG,
|
|
304
|
+
{},
|
|
305
|
+
pluginsAnyProfileLists(m),
|
|
306
|
+
).filter((i) => i.action === "remove");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function apply(items: PluginUpdateItem[], bookkept: boolean) {
|
|
310
|
+
const run = () =>
|
|
311
|
+
applyPlugins(items, project, deps(), SILENT, { profile: "default" });
|
|
312
|
+
return bookkept ? withCliBookkeeping(project, run) : run();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
beforeEach(async () => {
|
|
316
|
+
project = await mkdtemp(join(tmpdir(), "update-deprecated-others-"));
|
|
317
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
318
|
+
});
|
|
319
|
+
afterEach(async () => {
|
|
320
|
+
log.mockRestore();
|
|
321
|
+
await rm(project, { recursive: true, force: true });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
for (const [surface, bookkept] of SURFACES) {
|
|
325
|
+
// REGRESSION: `magus update` never removed a deprecated plugin only inactive profiles list; its `false` key stayed in settings.json — MTL-332
|
|
326
|
+
test(`${surface}: no profile lists it, untouched profiles are byte-equal, no settings key`, async () => {
|
|
327
|
+
await setUp(false);
|
|
328
|
+
// Seeded red: the render wrote the key that makes the row.
|
|
329
|
+
expect((await fs.readJson(settingsPath())).enabledPlugins[ID]).toBe(
|
|
330
|
+
false,
|
|
331
|
+
);
|
|
332
|
+
const before = (await fs.readJson(profilesPath())).profiles;
|
|
333
|
+
|
|
334
|
+
const items = await plan();
|
|
335
|
+
expect(items.map((i) => i.pluginId)).toEqual([ID]);
|
|
336
|
+
const result = await apply(items, bookkept);
|
|
337
|
+
|
|
338
|
+
expect(result.failed).toEqual([]);
|
|
339
|
+
expect(result.removed).toEqual([ID]);
|
|
340
|
+
const after = (await readManifest(project)).profiles;
|
|
341
|
+
for (const id of Object.keys(after)) {
|
|
342
|
+
expect(after[id]?.plugins?.[ID]).toBeUndefined();
|
|
343
|
+
}
|
|
344
|
+
expect(after.maintainer?.plugins).toEqual({ "dev@magus": "latest" });
|
|
345
|
+
const raw = (await fs.readJson(profilesPath())).profiles;
|
|
346
|
+
expect(raw.default).toEqual(before.default);
|
|
347
|
+
expect(raw.minimal).toEqual(before.minimal);
|
|
348
|
+
|
|
349
|
+
const live = (await fs.readJson(settingsPath())).enabledPlugins;
|
|
350
|
+
expect(Object.hasOwn(live, ID)).toBe(false);
|
|
351
|
+
expect(live["dev@magus"]).toBe(true);
|
|
352
|
+
|
|
353
|
+
// It does not come back on the next entry point.
|
|
354
|
+
await reconcileActiveProfile(project);
|
|
355
|
+
expect(
|
|
356
|
+
Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
|
|
357
|
+
).toBe(false);
|
|
358
|
+
expect(await plan()).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// REGRESSION: removing a deprecated plugin must not lose live edits made after the profile was activated — MTL-332
|
|
362
|
+
test(`${surface}: a live edit survives the removal`, async () => {
|
|
363
|
+
await setUp(false);
|
|
364
|
+
const settings = await fs.readJson(settingsPath());
|
|
365
|
+
settings.permissions = { allow: ["Bash(ls:*)"] };
|
|
366
|
+
settings.enabledPlugins["other@magus"] = true;
|
|
367
|
+
await fs.writeJson(settingsPath(), settings, { spaces: 2 });
|
|
368
|
+
|
|
369
|
+
const result = await apply(await plan(), bookkept);
|
|
370
|
+
|
|
371
|
+
expect(result.failed).toEqual([]);
|
|
372
|
+
const live = await fs.readJson(settingsPath());
|
|
373
|
+
expect(live.permissions?.allow).toEqual(["Bash(ls:*)"]);
|
|
374
|
+
expect(live.enabledPlugins["other@magus"]).toBe(true);
|
|
375
|
+
expect(Object.hasOwn(live.enabledPlugins, ID)).toBe(false);
|
|
376
|
+
for (const entry of Object.values(
|
|
377
|
+
(await readManifest(project)).profiles,
|
|
378
|
+
)) {
|
|
379
|
+
expect(entry.plugins?.[ID]).toBeUndefined();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// REGRESSION: removing a deprecated plugin the active profile lists dropped it from that profile only, so another profile kept it — MTL-332
|
|
384
|
+
test(`${surface}: the active profile and another both list it, neither does afterwards`, async () => {
|
|
385
|
+
await setUp(true);
|
|
386
|
+
const items = await plan();
|
|
387
|
+
expect(items.map((i) => i.pluginId)).toEqual([ID]);
|
|
388
|
+
|
|
389
|
+
const result = await apply(items, bookkept);
|
|
390
|
+
|
|
391
|
+
expect(result.failed).toEqual([]);
|
|
392
|
+
const after = (await readManifest(project)).profiles;
|
|
393
|
+
expect(after.default?.plugins?.[ID]).toBeUndefined();
|
|
394
|
+
expect(after.maintainer?.plugins?.[ID]).toBeUndefined();
|
|
395
|
+
expect(after.lean?.plugins?.[ID]).toBeUndefined();
|
|
396
|
+
expect(
|
|
397
|
+
Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
|
|
398
|
+
).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
});
|
package/src/cli/update.ts
CHANGED
|
@@ -63,7 +63,11 @@ import {
|
|
|
63
63
|
switchHint,
|
|
64
64
|
withCliBookkeeping,
|
|
65
65
|
} from "../services/profile-state.js";
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
pluginsAnyProfileLists,
|
|
68
|
+
requirersHere,
|
|
69
|
+
resolveProfile,
|
|
70
|
+
} from "../services/resolver.js";
|
|
67
71
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
68
72
|
import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
69
73
|
import { managedBins } from "../services/toolchain.js";
|
|
@@ -503,7 +507,15 @@ async function updateProfile(
|
|
|
503
507
|
);
|
|
504
508
|
const plan: UpdatePlan = {
|
|
505
509
|
profileId: targetId,
|
|
506
|
-
|
|
510
|
+
// Every profile's plugins, not only this one's: a deprecated plugin a
|
|
511
|
+
// profile that is not active lists is still a row the render keeps, and
|
|
512
|
+
// is planned for removal.
|
|
513
|
+
plugins: planPluginUpdates(
|
|
514
|
+
closure.plugins,
|
|
515
|
+
catalog,
|
|
516
|
+
requiredBy,
|
|
517
|
+
pluginsAnyProfileLists(current),
|
|
518
|
+
),
|
|
507
519
|
bins: planBinUpdates(tools, toolFacts),
|
|
508
520
|
skills: planSkillUpdates(skillRefs, installedSkills),
|
|
509
521
|
binSpecs: new Map(tools.map((b) => [b.name, b])),
|
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
} from "../types/index.js";
|
|
22
22
|
import { readManifest, validateManifest } from "./manifest.js";
|
|
23
23
|
import { activeProfile } from "./profile-live.js";
|
|
24
|
-
import { resolveProfile } from "./resolver.js";
|
|
24
|
+
import { pluginsAnyProfileLists, resolveProfile } from "./resolver.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The profile to use: the explicit one, else the active one, else the only one.
|
|
@@ -45,6 +45,12 @@ export function selectProfileId(
|
|
|
45
45
|
export interface ActiveClosure {
|
|
46
46
|
profileId: string;
|
|
47
47
|
closure: ResolvedClosure;
|
|
48
|
+
/**
|
|
49
|
+
* Every plugin id ANY profile in the manifest lists
|
|
50
|
+
* (`pluginsAnyProfileLists`) — the planner's `listedByProfiles`, the same
|
|
51
|
+
* set `magus update` passes it.
|
|
52
|
+
*/
|
|
53
|
+
listedByProfiles: string[];
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export interface ActiveClosureDeps {
|
|
@@ -110,7 +116,11 @@ export async function loadActiveClosure(
|
|
|
110
116
|
profileId,
|
|
111
117
|
opts.resolveBins === false ? { binResolver: NO_BINS } : {},
|
|
112
118
|
);
|
|
113
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
profileId,
|
|
121
|
+
closure,
|
|
122
|
+
listedByProfiles: pluginsAnyProfileLists(manifest),
|
|
123
|
+
};
|
|
114
124
|
} catch {
|
|
115
125
|
return null;
|
|
116
126
|
}
|
|
@@ -271,10 +271,21 @@ export async function uninstallPluginViaCli(
|
|
|
271
271
|
/**
|
|
272
272
|
* Is `error` the CLI's answer that the plugin is not installed at the scope it
|
|
273
273
|
* was asked about — the one failure of an uninstall that means "nothing to do".
|
|
274
|
+
*
|
|
275
|
+
* Claude Code has two wordings for it. With no row anywhere: `Plugin "<id>" is
|
|
276
|
+
* not installed in <scope> scope`. With rows only at another scope or in other
|
|
277
|
+
* folders (measured, Claude Code 2.1.312): `Plugin "<id>" is installed in
|
|
278
|
+
* <other> scope, not <scope>. Use --scope <other> to uninstall.` Both mean the
|
|
279
|
+
* asked scope holds nothing here; anything else is a real failure.
|
|
274
280
|
*/
|
|
275
281
|
export function isNotInstalledAnswer(error: unknown): boolean {
|
|
276
282
|
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
-
return
|
|
283
|
+
return (
|
|
284
|
+
/not installed in (user|project|local) scope/i.test(message) ||
|
|
285
|
+
/is installed in (user|project|local) scope, not (user|project|local)\b/i.test(
|
|
286
|
+
message,
|
|
287
|
+
)
|
|
288
|
+
);
|
|
278
289
|
}
|
|
279
290
|
|
|
280
291
|
/**
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import type { ProfileManifest
|
|
29
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
30
30
|
import { selectProfileId } from "./active-closure.js";
|
|
31
31
|
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
32
32
|
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
skillFolder,
|
|
75
75
|
skillIgnoreLine,
|
|
76
76
|
} from "./profile-skills.js";
|
|
77
|
-
import {
|
|
77
|
+
import { profileLists } from "./resolver.js";
|
|
78
78
|
import type { SettingsParseError } from "./settings-file.js";
|
|
79
79
|
|
|
80
80
|
export type ActiveProfileCheck =
|
|
@@ -674,16 +674,6 @@ export async function withCliBookkeeping<T>(
|
|
|
674
674
|
}
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
-
/** Whether a profile entry, `extends` included, turns the plugin on. */
|
|
678
|
-
function profileLists(
|
|
679
|
-
entry: ProfileManifestEntry | undefined,
|
|
680
|
-
pluginId: string,
|
|
681
|
-
): boolean {
|
|
682
|
-
if (!entry) return false;
|
|
683
|
-
const value = resolveExtends(entry).plugins?.[pluginId];
|
|
684
|
-
return value !== undefined && value !== false;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
677
|
/**
|
|
688
678
|
* Stop a profile listing `pluginId`, so its next activation does not turn the
|
|
689
679
|
* plugin back on. Answers whether the profile no longer lists it — read back
|
package/src/services/resolver.ts
CHANGED
|
@@ -285,6 +285,37 @@ export function resolveExtends(
|
|
|
285
285
|
};
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/** Whether a profile entry, `extends` included, turns the plugin on. */
|
|
289
|
+
export function profileLists(
|
|
290
|
+
entry: ProfileManifestEntry | undefined,
|
|
291
|
+
pluginId: string,
|
|
292
|
+
): boolean {
|
|
293
|
+
if (!entry) return false;
|
|
294
|
+
const value = resolveExtends(entry).plugins?.[pluginId];
|
|
295
|
+
return value !== undefined && value !== false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Every plugin id some profile in the manifest turns on, `extends` included —
|
|
300
|
+
* by {@link profileLists}, the test a removal uses to find the profiles it
|
|
301
|
+
* must edit.
|
|
302
|
+
*
|
|
303
|
+
* The planner's view of the profiles that are NOT active. The render writes
|
|
304
|
+
* `false` for each of these into the live settings.json, so a deprecated one
|
|
305
|
+
* shows as a row on the Plugins screen even though the active profile never
|
|
306
|
+
* names it; `magus update` and the screen both pass this set to the planner,
|
|
307
|
+
* which is how such a plugin gets planned for removal at all.
|
|
308
|
+
*/
|
|
309
|
+
export function pluginsAnyProfileLists(manifest: ProfileManifest): string[] {
|
|
310
|
+
const out = new Set<string>();
|
|
311
|
+
for (const entry of Object.values(manifest.profiles)) {
|
|
312
|
+
for (const pluginId of Object.keys(resolveExtends(entry).plugins ?? {})) {
|
|
313
|
+
if (profileLists(entry, pluginId)) out.add(pluginId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return [...out];
|
|
317
|
+
}
|
|
318
|
+
|
|
288
319
|
/** The pin a selected requirement installs at, if its chosen option carries one. */
|
|
289
320
|
export function pinOfBin(bin: SelectedBin): string | undefined {
|
|
290
321
|
const option = bin.chosen;
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
inheritedRowsOf,
|
|
58
58
|
loadedVersionHere,
|
|
59
59
|
notDownloadedReason,
|
|
60
|
+
readInheritedRegistryRows,
|
|
60
61
|
readInstalledPluginsRegistry,
|
|
61
62
|
repairStaleLoad,
|
|
62
63
|
rowBelongsToRepository,
|
|
@@ -69,9 +70,15 @@ import {
|
|
|
69
70
|
readInstalledVersionForScope,
|
|
70
71
|
saveInstalledPluginVersionForScope,
|
|
71
72
|
} from "./plugin-manager.js";
|
|
72
|
-
import {
|
|
73
|
+
import { reconcileActiveProfile, refuseOnProblems } from "./profile-absorb.js";
|
|
74
|
+
import { regenerateLiveFiles } from "./profile-activate.js";
|
|
75
|
+
import {
|
|
76
|
+
activeProfile,
|
|
77
|
+
hasCurrentMarker,
|
|
78
|
+
readActiveMarker,
|
|
79
|
+
} from "./profile-live.js";
|
|
73
80
|
import { removePluginFromProfile } from "./profile-state.js";
|
|
74
|
-
import { profileDependentsOf } from "./resolver.js";
|
|
81
|
+
import { profileDependentsOf, profileLists } from "./resolver.js";
|
|
75
82
|
import { installSkill } from "./skills-manager.js";
|
|
76
83
|
import {
|
|
77
84
|
type ToolFacts,
|
|
@@ -296,6 +303,17 @@ export interface PluginRemoveDeps {
|
|
|
296
303
|
projectPath: string,
|
|
297
304
|
profile: string | null | undefined,
|
|
298
305
|
) => Promise<boolean>;
|
|
306
|
+
/**
|
|
307
|
+
* Stop EVERY profile listing a deprecated plugin, the live files absorbed
|
|
308
|
+
* first (`unlistFromEveryProfile`); answers whether none lists it, read
|
|
309
|
+
* back, and throws the first failure. What {@link applyRemove} runs in
|
|
310
|
+
* place of `dropFromProfile` when present. Optional so a double that models
|
|
311
|
+
* one profile keeps doing it through `dropFromProfile`.
|
|
312
|
+
*/
|
|
313
|
+
unlistEverywhere?: (
|
|
314
|
+
pluginId: string,
|
|
315
|
+
projectPath: string,
|
|
316
|
+
) => Promise<boolean>;
|
|
299
317
|
/**
|
|
300
318
|
* Clear magus's OWN `installedPluginVersions` claim for the plugin at a
|
|
301
319
|
* scope it was removed from (`forgetInstalledPluginVersion`). Claude Code
|
|
@@ -338,6 +356,12 @@ export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
|
|
|
338
356
|
(await enabledAt(scope, projectPath))[pluginId] === true,
|
|
339
357
|
dropFromProfile: (pluginId, projectPath, profile) =>
|
|
340
358
|
removePluginFromProfile(projectPath, pluginId, profile),
|
|
359
|
+
unlistEverywhere: async (pluginId, projectPath) => {
|
|
360
|
+
const { problems } = await unlistFromEveryProfile(pluginId, projectPath);
|
|
361
|
+
if (problems.length > 0) throw problems[0];
|
|
362
|
+
const { profiles } = await readManifest(projectPath);
|
|
363
|
+
return !Object.values(profiles).some((e) => profileLists(e, pluginId));
|
|
364
|
+
},
|
|
341
365
|
forgetVersion: forgetInstalledPluginVersion,
|
|
342
366
|
// The profile picked as `removePluginFromProfile` picks it, so the check and
|
|
343
367
|
// the drop judge the same profile. A parse error throws, as it does there.
|
|
@@ -856,13 +880,14 @@ async function applyStaleLoad(
|
|
|
856
880
|
* Remove ONE deprecated plugin — the `remove` half of {@link applyPlugins}.
|
|
857
881
|
*
|
|
858
882
|
* Uninstalls it at every scope the plan found it installed at, through the
|
|
859
|
-
* Claude Code CLI wrapper, then stops
|
|
860
|
-
* even when the first fails part-way: a
|
|
861
|
-
* marketplace publishes only ever asks for an
|
|
883
|
+
* Claude Code CLI wrapper, then stops every profile listing it
|
|
884
|
+
* (`unlistEverywhere`). Both halves run even when the first fails part-way: a
|
|
885
|
+
* profile that keeps a plugin no marketplace publishes only ever asks for an
|
|
886
|
+
* install that cannot happen.
|
|
862
887
|
*
|
|
863
888
|
* Success is READ BACK, never "the calls returned": installed at no scope here
|
|
864
|
-
* afterwards — all three are checked, not just the planned ones — and
|
|
865
|
-
* profile
|
|
889
|
+
* afterwards — all three are checked, not just the planned ones — and no
|
|
890
|
+
* profile lists it.
|
|
866
891
|
*/
|
|
867
892
|
async function applyRemove(
|
|
868
893
|
item: PluginUpdateItem,
|
|
@@ -914,11 +939,11 @@ async function applyRemove(
|
|
|
914
939
|
inheritedError ??= error;
|
|
915
940
|
}
|
|
916
941
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
942
|
+
// Every profile that lists it, or one left listing it brings the row back
|
|
943
|
+
// on the next render.
|
|
944
|
+
const unlisted = remove.unlistEverywhere
|
|
945
|
+
? await remove.unlistEverywhere(item.pluginId, projectPath)
|
|
946
|
+
: await remove.dropFromProfile(item.pluginId, projectPath, profile);
|
|
922
947
|
|
|
923
948
|
const still: PluginScope[] = [];
|
|
924
949
|
for (const scope of REMOVE_SCOPES) {
|
|
@@ -1007,6 +1032,115 @@ async function applyRemove(
|
|
|
1007
1032
|
/** Every scope a removal must leave the plugin uninstalled at. */
|
|
1008
1033
|
const REMOVE_SCOPES: readonly PluginScope[] = ["user", "project", "local"];
|
|
1009
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* Remove an orphan the plan holds no `remove` item for — the Plugins screen's
|
|
1037
|
+
* `d` on a row the planner keeps rather than removes: it moved to another
|
|
1038
|
+
* marketplace, another plugin still requires it, or its marketplace was not
|
|
1039
|
+
* read. The user asked for it gone, so it goes.
|
|
1040
|
+
*
|
|
1041
|
+
* Uninstalls at every scope here, then each inherited row from its own folder,
|
|
1042
|
+
* then {@link unlistFromEveryProfile}: a key the render writes `false` for is
|
|
1043
|
+
* cleared only once no profile lists the id.
|
|
1044
|
+
*
|
|
1045
|
+
* `problems` holds every real failure; the CLI's "nothing at this scope here"
|
|
1046
|
+
* answer is not one. `unlistedFrom` names the profiles that listed it.
|
|
1047
|
+
*/
|
|
1048
|
+
export async function removeDeprecatedPlugin(
|
|
1049
|
+
pluginId: string,
|
|
1050
|
+
projectPath: string,
|
|
1051
|
+
deps: {
|
|
1052
|
+
uninstall?: (id: string, scope: PluginScope, cwd: string) => Promise<void>;
|
|
1053
|
+
inheritedRows?: (
|
|
1054
|
+
id: string,
|
|
1055
|
+
projectPath: string,
|
|
1056
|
+
) => Promise<InstalledPluginEntry[]>;
|
|
1057
|
+
} = {},
|
|
1058
|
+
): Promise<{ problems: unknown[]; unlistedFrom: string[] }> {
|
|
1059
|
+
const uninstall = deps.uninstall ?? uninstallPluginViaCli;
|
|
1060
|
+
const inheritedRows = deps.inheritedRows ?? readInheritedRegistryRows;
|
|
1061
|
+
const problems: unknown[] = [];
|
|
1062
|
+
const unlistedFrom: string[] = [];
|
|
1063
|
+
|
|
1064
|
+
for (const scope of REMOVE_SCOPES) {
|
|
1065
|
+
try {
|
|
1066
|
+
await uninstall(pluginId, scope, projectPath);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
if (!isNotInstalledAnswer(error)) problems.push(error);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// A folder of the same repository may hold the install this one only
|
|
1072
|
+
// shows: the CLI removes it only when run from that folder.
|
|
1073
|
+
for (const row of await inheritedRows(pluginId, projectPath)) {
|
|
1074
|
+
if (!row.projectPath) continue;
|
|
1075
|
+
try {
|
|
1076
|
+
await uninstall(pluginId, row.scope, row.projectPath);
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
problems.push(error);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const unlisted = await unlistFromEveryProfile(pluginId, projectPath);
|
|
1083
|
+
problems.push(...unlisted.problems);
|
|
1084
|
+
return { problems, unlistedFrom: unlisted.unlistedFrom };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Stop EVERY profile in the project listing a deprecated plugin, keeping the
|
|
1089
|
+
* user's live edits — the profile half of both removals, {@link applyRemove}
|
|
1090
|
+
* (`magus update`, the Plugins screen's `a` and `d`) and
|
|
1091
|
+
* {@link removeDeprecatedPlugin}.
|
|
1092
|
+
*
|
|
1093
|
+
* Every profile, not only the active one: the render writes `false` for each
|
|
1094
|
+
* id ANY profile lists, so one profile left listing it brings the row back on
|
|
1095
|
+
* the next render. A deprecated plugin can never be installed, so no profile
|
|
1096
|
+
* loses anything it could use.
|
|
1097
|
+
*
|
|
1098
|
+
* The live files are absorbed into the active profile BEFORE any profile is
|
|
1099
|
+
* written, and rendered again after, so what Claude Code or the user wrote
|
|
1100
|
+
* since the last render survives and the id's key is gone. Under
|
|
1101
|
+
* `withCliBookkeeping` (`magus update`) both are left to the wrapper, whose
|
|
1102
|
+
* reconcile absorbs before it renders: reconciling here would write a fresh
|
|
1103
|
+
* marker mid-run, clearing the bookkeeping flag, so the installs that follow
|
|
1104
|
+
* would read as the user's own edits.
|
|
1105
|
+
*
|
|
1106
|
+
* `unlistedFrom` names the profiles that listed it, each READ BACK as no longer
|
|
1107
|
+
* listing it; `problems` holds every failure, a refused reconcile included,
|
|
1108
|
+
* in which case nothing was written.
|
|
1109
|
+
*/
|
|
1110
|
+
async function unlistFromEveryProfile(
|
|
1111
|
+
pluginId: string,
|
|
1112
|
+
projectPath: string,
|
|
1113
|
+
): Promise<{ problems: unknown[]; unlistedFrom: string[] }> {
|
|
1114
|
+
const problems: unknown[] = [];
|
|
1115
|
+
const unlistedFrom: string[] = [];
|
|
1116
|
+
try {
|
|
1117
|
+
const marker = (await hasCurrentMarker(projectPath))
|
|
1118
|
+
? await readActiveMarker(projectPath)
|
|
1119
|
+
: null;
|
|
1120
|
+
const wrapperReconciles = marker?.cliBookkeeping === true;
|
|
1121
|
+
// Refuses, writing nothing, on an unreadable live file.
|
|
1122
|
+
if (!wrapperReconciles) {
|
|
1123
|
+
refuseOnProblems(await reconcileActiveProfile(projectPath));
|
|
1124
|
+
}
|
|
1125
|
+
const manifest = await readManifest(projectPath);
|
|
1126
|
+
for (const [id, entry] of Object.entries(manifest.profiles)) {
|
|
1127
|
+
if (!profileLists(entry, pluginId)) continue;
|
|
1128
|
+
if (await removePluginFromProfile(projectPath, pluginId, id)) {
|
|
1129
|
+
unlistedFrom.push(id);
|
|
1130
|
+
} else {
|
|
1131
|
+
problems.push(
|
|
1132
|
+
new Error(`the ${entry.name ?? id} profile still lists it`),
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Renders nothing, and answers false, when no profile is active.
|
|
1137
|
+
if (!wrapperReconciles) await regenerateLiveFiles(projectPath);
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
problems.push(error);
|
|
1140
|
+
}
|
|
1141
|
+
return { problems, unlistedFrom };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1010
1144
|
/** Options for {@link applyPlugins}. */
|
|
1011
1145
|
export interface ApplyPluginsOptions {
|
|
1012
1146
|
/**
|
|
@@ -879,6 +879,8 @@ export function classifyPlugin(
|
|
|
879
879
|
* Every plugin the active profile declares, under its pin, and every plugin
|
|
880
880
|
* installed in any scope (user, project, local), under "latest" — the rule
|
|
881
881
|
* {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
|
|
882
|
+
* A plugin only other profiles list joins the plan only to be removed, when it
|
|
883
|
+
* is deprecated ({@link onlyOtherProfilesList}).
|
|
882
884
|
*
|
|
883
885
|
* ONE list, read by both surfaces: the screen's rows, header counts and
|
|
884
886
|
* "update all", and the CLI's report and apply. It used to be two — the CLI
|
|
@@ -891,11 +893,16 @@ export function classifyPlugin(
|
|
|
891
893
|
* @param catalog getAvailablePlugins() output — keyed by id, or as a list
|
|
892
894
|
* @param requiredBy closure.dependencies?.requiredBy — which plugin needs each
|
|
893
895
|
* dependency the profile does not name itself
|
|
896
|
+
* @param listedByProfiles every plugin id ANY profile in the manifest lists
|
|
897
|
+
* (`pluginsAnyProfileLists`). One neither pinned nor
|
|
898
|
+
* installed is planned only as a deprecated `remove`; see
|
|
899
|
+
* {@link onlyOtherProfilesList}.
|
|
894
900
|
*/
|
|
895
901
|
export function planPluginUpdates(
|
|
896
902
|
pinned: Readonly<Record<string, string>>,
|
|
897
903
|
catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
|
|
898
904
|
requiredBy: RequiredBy = {},
|
|
905
|
+
listedByProfiles: readonly string[] = [],
|
|
899
906
|
): PluginUpdateItem[] {
|
|
900
907
|
const index = indexCatalog(catalog);
|
|
901
908
|
const plugins =
|
|
@@ -908,13 +915,42 @@ export function planPluginUpdates(
|
|
|
908
915
|
const pin = pinForRow(info, pinned);
|
|
909
916
|
if (pin !== null) pins.set(info.id, pin);
|
|
910
917
|
}
|
|
918
|
+
const othersOnly = new Set<string>();
|
|
919
|
+
for (const pluginId of listedByProfiles) {
|
|
920
|
+
if (pins.has(pluginId)) continue;
|
|
921
|
+
othersOnly.add(pluginId);
|
|
922
|
+
pins.set(pluginId, "latest");
|
|
923
|
+
}
|
|
911
924
|
return settleDeprecated(
|
|
912
925
|
[...pins].map(([pluginId, pin]) =>
|
|
913
926
|
classifyPlugin(pluginId, pin, index, requiredBy),
|
|
914
927
|
),
|
|
915
928
|
index,
|
|
916
929
|
requiredBy,
|
|
917
|
-
)
|
|
930
|
+
)
|
|
931
|
+
.filter(
|
|
932
|
+
(item) => !othersOnly.has(item.pluginId) || onlyOtherProfilesList(item),
|
|
933
|
+
)
|
|
934
|
+
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The one item a plugin gets when only profiles that are NOT active list it,
|
|
939
|
+
* and nothing here installs it: a deprecated `remove`.
|
|
940
|
+
*
|
|
941
|
+
* The render writes `false` for it into the live settings.json, so the Plugins
|
|
942
|
+
* screen shows it as a row, and a profile that keeps a plugin no marketplace
|
|
943
|
+
* publishes only ever asks for an install that cannot happen. Removing it here
|
|
944
|
+
* drops it from every profile that lists it, which is what clears the row.
|
|
945
|
+
*
|
|
946
|
+
* Anything else — published, moved, still required, a marketplace we did not
|
|
947
|
+
* read — is no work of this profile's: it is not active, so nothing is
|
|
948
|
+
* installed, updated or kept for it, and it gets no item at all. Judged after
|
|
949
|
+
* {@link settleDeprecated}, so retired plugins that only require each other are
|
|
950
|
+
* still removed together.
|
|
951
|
+
*/
|
|
952
|
+
function onlyOtherProfilesList(item: PluginUpdateItem): boolean {
|
|
953
|
+
return item.action === "remove" && item.reason === "deprecated";
|
|
918
954
|
}
|
|
919
955
|
|
|
920
956
|
/**
|
|
@@ -951,9 +987,13 @@ export function classifyPluginRows(
|
|
|
951
987
|
plugins: readonly PluginInfo[],
|
|
952
988
|
pins: Readonly<Record<string, string>>,
|
|
953
989
|
requiredBy: RequiredBy = {},
|
|
990
|
+
listedByProfiles: readonly string[] = [],
|
|
954
991
|
): Map<string, PluginUpdateItem> {
|
|
955
992
|
return new Map(
|
|
956
|
-
planPluginUpdates(pins, plugins, requiredBy).map((i) => [
|
|
993
|
+
planPluginUpdates(pins, plugins, requiredBy, listedByProfiles).map((i) => [
|
|
994
|
+
i.pluginId,
|
|
995
|
+
i,
|
|
996
|
+
]),
|
|
957
997
|
);
|
|
958
998
|
}
|
|
959
999
|
|
|
@@ -9,14 +9,8 @@ import {
|
|
|
9
9
|
type PluginScope,
|
|
10
10
|
addMarketplace as cliAddMarketplace,
|
|
11
11
|
repairPlugin as cliRepairPlugin,
|
|
12
|
-
isNotInstalledAnswer,
|
|
13
|
-
uninstallPluginViaCli,
|
|
14
12
|
} from "../../services/claude-cli.js";
|
|
15
|
-
import {
|
|
16
|
-
readInheritedRegistryRows,
|
|
17
|
-
readSettings,
|
|
18
|
-
setMcpEnvVar,
|
|
19
|
-
} from "../../services/claude-settings.js";
|
|
13
|
+
import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
|
|
20
14
|
import { clearContentDriftCache } from "../../services/content-drift.js";
|
|
21
15
|
import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
|
|
22
16
|
import {
|
|
@@ -49,6 +43,7 @@ import {
|
|
|
49
43
|
applyPlugins,
|
|
50
44
|
installPluginInScope,
|
|
51
45
|
pluginNeedsWork,
|
|
46
|
+
removeDeprecatedPlugin,
|
|
52
47
|
uninstallPluginInScope,
|
|
53
48
|
updatePluginInScope,
|
|
54
49
|
} from "../../services/update-engine.js";
|
|
@@ -176,6 +171,14 @@ export function PluginsScreen() {
|
|
|
176
171
|
const [requiredBy, setRequiredBy] = useState<
|
|
177
172
|
Readonly<Record<string, readonly string[]>>
|
|
178
173
|
>({});
|
|
174
|
+
/**
|
|
175
|
+
* Every plugin any profile lists — the same set `magus update` plans with,
|
|
176
|
+
* so a deprecated plugin only another profile keeps is a `remove` row here
|
|
177
|
+
* and a removal there.
|
|
178
|
+
*/
|
|
179
|
+
const [listedByProfiles, setListedByProfiles] = useState<readonly string[]>(
|
|
180
|
+
[],
|
|
181
|
+
);
|
|
179
182
|
|
|
180
183
|
// Fetch data (always fetches all scopes). Only the most recently started
|
|
181
184
|
// load may touch the screen.
|
|
@@ -209,6 +212,7 @@ export function PluginsScreen() {
|
|
|
209
212
|
setPins(active?.closure.plugins ?? null);
|
|
210
213
|
setActiveProfileId(active?.profileId ?? null);
|
|
211
214
|
setRequiredBy(required);
|
|
215
|
+
setListedByProfiles(active?.listedByProfiles ?? []);
|
|
212
216
|
|
|
213
217
|
// Which catalogs answered, and which only appeared to. Read straight
|
|
214
218
|
// after the fetch, while the record still describes this attempt.
|
|
@@ -300,9 +304,14 @@ export function PluginsScreen() {
|
|
|
300
304
|
const statuses = useMemo(
|
|
301
305
|
(): Map<string, PluginUpdateItem> =>
|
|
302
306
|
shownPlugins
|
|
303
|
-
? classifyPluginRows(
|
|
307
|
+
? classifyPluginRows(
|
|
308
|
+
shownPlugins,
|
|
309
|
+
pins ?? {},
|
|
310
|
+
requiredBy,
|
|
311
|
+
listedByProfiles,
|
|
312
|
+
)
|
|
304
313
|
: new Map(),
|
|
305
|
-
[shownPlugins, pins, requiredBy],
|
|
314
|
+
[shownPlugins, pins, requiredBy, listedByProfiles],
|
|
306
315
|
);
|
|
307
316
|
|
|
308
317
|
/**
|
|
@@ -1265,39 +1274,16 @@ export function PluginsScreen() {
|
|
|
1265
1274
|
return;
|
|
1266
1275
|
}
|
|
1267
1276
|
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1277
|
+
// An orphan the plan keeps rather than removes — it moved, another plugin
|
|
1278
|
+
// still requires it, or its marketplace was not read — and the user asked
|
|
1279
|
+
// for it gone. The engine uninstalls everywhere, drops it from every
|
|
1280
|
+
// profile and renders the active one again, so the row does not come back.
|
|
1271
1281
|
try {
|
|
1272
|
-
|
|
1273
|
-
const
|
|
1274
|
-
for (const scope of scopes) {
|
|
1275
|
-
try {
|
|
1276
|
-
modal.loading(
|
|
1277
|
-
`Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
|
|
1278
|
-
);
|
|
1279
|
-
await uninstallPluginViaCli(plugin.id, scope, state.projectPath);
|
|
1280
|
-
} catch (error) {
|
|
1281
|
-
if (!isNotInstalledAnswer(error)) problems.push(error);
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
// A folder of the same repository may hold the install this one only
|
|
1285
|
-
// shows: the CLI removes it only when run from that folder.
|
|
1286
|
-
const inherited = await readInheritedRegistryRows(
|
|
1282
|
+
modal.loading(`Removing ${plugin.name}…`);
|
|
1283
|
+
const { problems } = await removeDeprecatedPlugin(
|
|
1287
1284
|
plugin.id,
|
|
1288
1285
|
state.projectPath || process.cwd(),
|
|
1289
1286
|
);
|
|
1290
|
-
for (const row of inherited) {
|
|
1291
|
-
if (!row.projectPath) continue;
|
|
1292
|
-
try {
|
|
1293
|
-
modal.loading(
|
|
1294
|
-
`Removing ${plugin.name} from the repository's main folder, which this folder shares…\nclaude plugin uninstall ${plugin.id} --scope ${row.scope}`,
|
|
1295
|
-
);
|
|
1296
|
-
await uninstallPluginViaCli(plugin.id, row.scope, row.projectPath);
|
|
1297
|
-
} catch (error) {
|
|
1298
|
-
problems.push(error);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
1287
|
|
|
1302
1288
|
modal.hideModal();
|
|
1303
1289
|
fetchData();
|