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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.11.
|
|
3
|
+
"version": "7.11.3",
|
|
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.3",
|
|
69
|
+
"magus-cli-darwin-x64": "7.11.3",
|
|
70
|
+
"magus-cli-linux-x64": "7.11.3"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -40,8 +40,10 @@ describe("shipped plugin manifests under schema 2", () => {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// REQ-26: claudish gets a minVersion floor; no other requirement gets one. The floor
|
|
43
|
-
// moved from 10.7.0 to 11.1.0 with claudish plugin 2.5.0, which needs SlotRow.recent
|
|
44
|
-
|
|
43
|
+
// moved from 10.7.0 to 11.1.0 with claudish plugin 2.5.0, which needs SlotRow.recent, and
|
|
44
|
+
// to 12.1.0 with claudish plugin 2.6.2, for resize_session, and to 12.3.0 with 2.8.1, whose
|
|
45
|
+
// resize goes down to 40 columns.
|
|
46
|
+
test("REQ-26 claudish's claudish requirement has minVersion 12.3.0", () => {
|
|
45
47
|
const claudish = declaring.find(({ name }) => name === "claudish");
|
|
46
48
|
if (!claudish) throw new Error("plugins/claudish declares no requires");
|
|
47
49
|
const result = parseRequires(claudish.json);
|
|
@@ -51,7 +53,7 @@ describe("shipped plugin manifests under schema 2", () => {
|
|
|
51
53
|
| (Record<string, unknown> & { name: string })
|
|
52
54
|
| undefined;
|
|
53
55
|
expect(req).toBeDefined();
|
|
54
|
-
expect(req?.minVersion).toBe("
|
|
56
|
+
expect(req?.minVersion).toBe("12.3.0");
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
test("REQ-26 no other shipped requirement declares a floor", () => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// REGRESSION: MTL-221 orphan USER registry row blocks PROJECT install/repair; scope uninstall must drop from profile — Fixed in /dev:fix session dev-fix-20261022-magus-scope-7c3e
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An orphan USER registry row — a `user` row in installed_plugins.json for a
|
|
5
|
+
* plugin USER settings do not turn on — wins Claude Code's load resolution.
|
|
6
|
+
* The stale-load repair refused on it ("update it at USER scope") without
|
|
7
|
+
* asking whether USER actually installs the plugin, so every PROJECT install
|
|
8
|
+
* failed. The engine must remove the orphan through `claude plugin uninstall
|
|
9
|
+
* --scope user` and retry, and keep refusing when USER really installs it.
|
|
10
|
+
*
|
|
11
|
+
* Doubles only: nothing here reads or writes the real ~/.claude.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
15
|
+
import type { StaleLoadRepairResult } from "../services/claude-settings.js";
|
|
16
|
+
import {
|
|
17
|
+
type PluginApplyDeps,
|
|
18
|
+
type PluginRemoveDeps,
|
|
19
|
+
installPluginInScope,
|
|
20
|
+
} from "../services/update-engine.js";
|
|
21
|
+
|
|
22
|
+
const PROJECT = "/tmp/mtl-221-project";
|
|
23
|
+
const ID = "dev@magus";
|
|
24
|
+
const OLD = "9.2.0";
|
|
25
|
+
const NEW = "9.4.0";
|
|
26
|
+
|
|
27
|
+
/** The refusal `repairStaleLoad` gives when the winning row is USER's. */
|
|
28
|
+
function userRefusal(): StaleLoadRepairResult & { blockedBy: "user" } {
|
|
29
|
+
return {
|
|
30
|
+
pluginId: ID,
|
|
31
|
+
target: NEW,
|
|
32
|
+
rewritten: 0,
|
|
33
|
+
backup: null,
|
|
34
|
+
loaded: null,
|
|
35
|
+
ok: false,
|
|
36
|
+
error:
|
|
37
|
+
"Claude Code loads the USER install of it here — update it at USER scope",
|
|
38
|
+
blockedBy: "user",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function repaired(): StaleLoadRepairResult {
|
|
43
|
+
return {
|
|
44
|
+
pluginId: ID,
|
|
45
|
+
target: NEW,
|
|
46
|
+
rewritten: 0,
|
|
47
|
+
backup: null,
|
|
48
|
+
loaded: NEW,
|
|
49
|
+
ok: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setup(userReallyInstalls: boolean) {
|
|
54
|
+
let repairCalls = 0;
|
|
55
|
+
const remove: PluginRemoveDeps = {
|
|
56
|
+
uninstall: mock(async () => {}),
|
|
57
|
+
isInstalled: mock(async (_id: string, scope: string) =>
|
|
58
|
+
scope === "user" ? userReallyInstalls : scope === "project",
|
|
59
|
+
),
|
|
60
|
+
dropFromProfile: mock(async () => true),
|
|
61
|
+
forgetVersion: mock(async () => {}),
|
|
62
|
+
};
|
|
63
|
+
const deps: PluginApplyDeps = {
|
|
64
|
+
install: mock(async () => {}),
|
|
65
|
+
update: mock(async () => {}),
|
|
66
|
+
repair: mock(async () => {}),
|
|
67
|
+
readInstalled: mock(async () => NEW),
|
|
68
|
+
saveInstalled: mock(async () => {}),
|
|
69
|
+
// The orphan USER row still wins until something removes it.
|
|
70
|
+
loadedVersion: mock(async () => OLD),
|
|
71
|
+
repairStaleLoad: mock(async () => {
|
|
72
|
+
repairCalls += 1;
|
|
73
|
+
return repairCalls === 1 ? userRefusal() : repaired();
|
|
74
|
+
}),
|
|
75
|
+
remove,
|
|
76
|
+
};
|
|
77
|
+
return { deps, remove, repairCalls: () => repairCalls };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("MTL-221: PROJECT install behind an orphan USER registry row", () => {
|
|
81
|
+
test("a. orphan USER row: uninstalled at USER once, repair retried, install succeeds", async () => {
|
|
82
|
+
const { deps, remove, repairCalls } = setup(false);
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
installPluginInScope(ID, "project", PROJECT, deps),
|
|
86
|
+
).resolves.toBe(NEW);
|
|
87
|
+
|
|
88
|
+
expect(remove.uninstall).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(remove.uninstall).toHaveBeenCalledWith(ID, "user", PROJECT);
|
|
90
|
+
expect(repairCalls()).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("b. real USER install: never uninstalled, the install still refuses naming USER", async () => {
|
|
94
|
+
const { deps, remove } = setup(true);
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
installPluginInScope(ID, "project", PROJECT, deps),
|
|
98
|
+
).rejects.toThrow(/USER/);
|
|
99
|
+
|
|
100
|
+
expect(remove.uninstall).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// REGRESSION: MTL-221 uninstalling a plugin a declared plugin depends on must refuse, not leave it declared — Fixed in /dev:fix session dev-fix-20261022-magus-scope-7c3e
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A profile's resolved closure re-adds every dependency of a plugin it
|
|
5
|
+
* declares (`resolveProfile` → `resolveDependencies` → `dependencies.derived`).
|
|
6
|
+
* `dev@magus` depends on `claudish@magus`, so dropping claudish from a profile
|
|
7
|
+
* that declares dev cannot take effect: claudish comes straight back.
|
|
8
|
+
*
|
|
9
|
+
* When an uninstall would need the profile to stop declaring the plugin
|
|
10
|
+
* (scope PROJECT, or no OTHER scope installs it) and a plugin the profile
|
|
11
|
+
* declares depends on it, `uninstallPluginInScope` refuses before doing
|
|
12
|
+
* anything, naming both plugins by name. A USER/LOCAL uninstall while PROJECT
|
|
13
|
+
* still installs it needs no profile change, so it is never refused.
|
|
14
|
+
*
|
|
15
|
+
* Doubles only: nothing here reads or writes the real ~/.claude.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
19
|
+
import * as resolver from "../services/resolver.js";
|
|
20
|
+
import * as engine from "../services/update-engine.js";
|
|
21
|
+
import type {
|
|
22
|
+
PluginApplyDeps,
|
|
23
|
+
PluginRemoveDeps,
|
|
24
|
+
} from "../services/update-engine.js";
|
|
25
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
26
|
+
|
|
27
|
+
const PROJECT = "/tmp/mtl-221-project";
|
|
28
|
+
const ID = "claudish@magus";
|
|
29
|
+
const PROFILE = "team";
|
|
30
|
+
|
|
31
|
+
type Scope = "user" | "project" | "local";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Remove deps over an in-memory install set, plus the assumed new optional
|
|
35
|
+
* `dependents` seam answering which declared plugins need `pluginId`.
|
|
36
|
+
*/
|
|
37
|
+
function setup(installedAt: Scope[], dependentIds: string[]) {
|
|
38
|
+
const installed = new Set<Scope>(installedAt);
|
|
39
|
+
const calls: string[] = [];
|
|
40
|
+
const dependents = mock(
|
|
41
|
+
async (
|
|
42
|
+
_pluginId: string,
|
|
43
|
+
_projectPath: string,
|
|
44
|
+
_profile: string | null | undefined,
|
|
45
|
+
) => dependentIds,
|
|
46
|
+
);
|
|
47
|
+
const remove = {
|
|
48
|
+
uninstall: mock(async (id: string, scope: Scope, path: string) => {
|
|
49
|
+
calls.push(`uninstall:${id}:${scope}:${path}`);
|
|
50
|
+
installed.delete(scope);
|
|
51
|
+
}),
|
|
52
|
+
isInstalled: mock(async (_id: string, scope: Scope) =>
|
|
53
|
+
installed.has(scope),
|
|
54
|
+
),
|
|
55
|
+
dropFromProfile: mock(async () => {
|
|
56
|
+
calls.push("dropFromProfile");
|
|
57
|
+
return true;
|
|
58
|
+
}),
|
|
59
|
+
forgetVersion: mock(async () => {
|
|
60
|
+
calls.push("forgetVersion");
|
|
61
|
+
}),
|
|
62
|
+
dependents,
|
|
63
|
+
} as PluginRemoveDeps & { dependents: typeof dependents };
|
|
64
|
+
const deps: PluginApplyDeps = {
|
|
65
|
+
install: mock(async () => {}),
|
|
66
|
+
update: mock(async () => {}),
|
|
67
|
+
repair: mock(async () => {}),
|
|
68
|
+
readInstalled: mock(async () => null),
|
|
69
|
+
saveInstalled: mock(async () => {}),
|
|
70
|
+
repairStaleLoad: mock(async () => {
|
|
71
|
+
throw new Error("repairStaleLoad must not run on an uninstall");
|
|
72
|
+
}),
|
|
73
|
+
remove,
|
|
74
|
+
};
|
|
75
|
+
return { deps, remove, calls };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const uninstallPluginInScope = engine.uninstallPluginInScope;
|
|
79
|
+
|
|
80
|
+
describe("MTL-221: uninstalling a plugin a declared plugin depends on", () => {
|
|
81
|
+
test("f. PROJECT uninstall of a dependency of a declared plugin: refuses before doing anything", async () => {
|
|
82
|
+
const { deps, remove, calls } = setup(["project"], ["dev@magus"]);
|
|
83
|
+
|
|
84
|
+
let message: string | null = null;
|
|
85
|
+
try {
|
|
86
|
+
await uninstallPluginInScope(ID, "project", PROJECT, deps, PROFILE);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
message = err instanceof Error ? err.message : String(err);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(message).not.toBeNull();
|
|
92
|
+
expect(message).toContain("dev needs claudish");
|
|
93
|
+
expect(message).toContain("uninstall dev first");
|
|
94
|
+
// Plugin names, not ids.
|
|
95
|
+
expect(message).not.toContain("@magus");
|
|
96
|
+
expect(remove.uninstall).not.toHaveBeenCalled();
|
|
97
|
+
expect(remove.forgetVersion).not.toHaveBeenCalled();
|
|
98
|
+
expect(remove.dropFromProfile).not.toHaveBeenCalled();
|
|
99
|
+
expect(calls).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("g. PROJECT uninstall with no declared dependent: uninstalls, then drops from the profile once", async () => {
|
|
103
|
+
const { deps, remove, calls } = setup(["project"], []);
|
|
104
|
+
|
|
105
|
+
await uninstallPluginInScope(ID, "project", PROJECT, deps, PROFILE);
|
|
106
|
+
|
|
107
|
+
expect(remove.uninstall).toHaveBeenCalledWith(ID, "project", PROJECT);
|
|
108
|
+
expect(remove.dropFromProfile).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(calls).toEqual([
|
|
110
|
+
`uninstall:${ID}:project:${PROJECT}`,
|
|
111
|
+
"forgetVersion",
|
|
112
|
+
"dropFromProfile",
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("h. USER uninstall while PROJECT still installs it: not refused, profile untouched", async () => {
|
|
117
|
+
const { deps, remove } = setup(["user", "project"], ["dev@magus"]);
|
|
118
|
+
|
|
119
|
+
await uninstallPluginInScope(ID, "user", PROJECT, deps, PROFILE);
|
|
120
|
+
|
|
121
|
+
expect(remove.uninstall).toHaveBeenCalledWith(ID, "user", PROJECT);
|
|
122
|
+
expect(remove.dropFromProfile).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Assumed signature, consistent with `resolveProfile` /
|
|
128
|
+
* `ResolveOptions.dependencyResolver` (a `PluginDependencyReader`):
|
|
129
|
+
*
|
|
130
|
+
* profileDependentsOf(
|
|
131
|
+
* manifest: ProfileManifest,
|
|
132
|
+
* profileId: string,
|
|
133
|
+
* pluginId: string,
|
|
134
|
+
* dependencyResolver: PluginDependencyReader,
|
|
135
|
+
* ): Promise<string[]>
|
|
136
|
+
*
|
|
137
|
+
* Answers the ids of plugins the profile declares that need `pluginId`.
|
|
138
|
+
*/
|
|
139
|
+
const profileDependentsOf = (resolver as Record<string, unknown>)
|
|
140
|
+
.profileDependentsOf as (
|
|
141
|
+
manifest: ProfileManifest,
|
|
142
|
+
profileId: string,
|
|
143
|
+
pluginId: string,
|
|
144
|
+
dependencyResolver: (
|
|
145
|
+
pluginId: string,
|
|
146
|
+
projectPath?: string,
|
|
147
|
+
) => Promise<string[] | null>,
|
|
148
|
+
) => Promise<string[]>;
|
|
149
|
+
|
|
150
|
+
describe("MTL-221: profileDependentsOf", () => {
|
|
151
|
+
test("i. names the declared plugin that depends on the given one", async () => {
|
|
152
|
+
const manifest: ProfileManifest = {
|
|
153
|
+
version: 2,
|
|
154
|
+
profiles: {
|
|
155
|
+
[PROFILE]: {
|
|
156
|
+
name: PROFILE,
|
|
157
|
+
plugins: { "dev@magus": "latest", "claudish@magus": "latest" },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const graph: Record<string, string[]> = {
|
|
162
|
+
"dev@magus": ["claudish@magus", "mnemex@magus"],
|
|
163
|
+
"claudish@magus": [],
|
|
164
|
+
"mnemex@magus": [],
|
|
165
|
+
};
|
|
166
|
+
const dependencyResolver = async (id: string) => graph[id] ?? [];
|
|
167
|
+
|
|
168
|
+
expect(typeof profileDependentsOf).toBe("function");
|
|
169
|
+
expect(
|
|
170
|
+
await profileDependentsOf(manifest, PROFILE, ID, dependencyResolver),
|
|
171
|
+
).toEqual(["dev@magus"]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// REGRESSION: MTL-221 orphan USER registry row blocks PROJECT install/repair; scope uninstall must drop from profile — Fixed in /dev:fix session dev-fix-20261022-magus-scope-7c3e
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Plugins screen's scope uninstall never dropped the plugin from the
|
|
5
|
+
* active profile, so a profile-declared plugin came straight back as "not
|
|
6
|
+
* installed — profile declares it". `uninstallPluginInScope` is the one engine
|
|
7
|
+
* function both screen call sites will use: uninstall at the scope, read back,
|
|
8
|
+
* and drop from the profile when the scope was PROJECT or nothing installs the
|
|
9
|
+
* plugin any more — never while PROJECT still installs it.
|
|
10
|
+
*
|
|
11
|
+
* Doubles only: nothing here reads or writes the real ~/.claude.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
15
|
+
import * as engine from "../services/update-engine.js";
|
|
16
|
+
import type {
|
|
17
|
+
PluginApplyDeps,
|
|
18
|
+
PluginRemoveDeps,
|
|
19
|
+
} from "../services/update-engine.js";
|
|
20
|
+
|
|
21
|
+
const PROJECT = "/tmp/mtl-221-project";
|
|
22
|
+
const ID = "dev@magus";
|
|
23
|
+
const PROFILE = "team";
|
|
24
|
+
|
|
25
|
+
type Scope = "user" | "project" | "local";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remove deps over an in-memory install set: `uninstall` takes the scope out,
|
|
29
|
+
* `isInstalled` reads the set back.
|
|
30
|
+
*/
|
|
31
|
+
function setup(installedAt: Scope[]) {
|
|
32
|
+
const installed = new Set<Scope>(installedAt);
|
|
33
|
+
const calls: string[] = [];
|
|
34
|
+
const remove: PluginRemoveDeps = {
|
|
35
|
+
uninstall: mock(async (id: string, scope: Scope, path: string) => {
|
|
36
|
+
calls.push(`uninstall:${id}:${scope}:${path}`);
|
|
37
|
+
installed.delete(scope);
|
|
38
|
+
}),
|
|
39
|
+
isInstalled: mock(async (_id: string, scope: Scope) =>
|
|
40
|
+
installed.has(scope),
|
|
41
|
+
),
|
|
42
|
+
dropFromProfile: mock(async () => {
|
|
43
|
+
calls.push("dropFromProfile");
|
|
44
|
+
return true;
|
|
45
|
+
}),
|
|
46
|
+
forgetVersion: mock(async () => {}),
|
|
47
|
+
};
|
|
48
|
+
const deps: PluginApplyDeps = {
|
|
49
|
+
install: mock(async () => {}),
|
|
50
|
+
update: mock(async () => {}),
|
|
51
|
+
repair: mock(async () => {}),
|
|
52
|
+
readInstalled: mock(async () => null),
|
|
53
|
+
saveInstalled: mock(async () => {}),
|
|
54
|
+
repairStaleLoad: mock(async () => {
|
|
55
|
+
throw new Error("repairStaleLoad must not run on an uninstall");
|
|
56
|
+
}),
|
|
57
|
+
remove,
|
|
58
|
+
};
|
|
59
|
+
return { deps, remove, calls };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Looked up off the namespace so the file loads before the export exists (RED).
|
|
63
|
+
const uninstallPluginInScope = (engine as Record<string, unknown>)
|
|
64
|
+
.uninstallPluginInScope as (
|
|
65
|
+
pluginId: string,
|
|
66
|
+
scope: Scope,
|
|
67
|
+
projectPath: string,
|
|
68
|
+
deps?: PluginApplyDeps,
|
|
69
|
+
profile?: string | null,
|
|
70
|
+
) => Promise<unknown>;
|
|
71
|
+
|
|
72
|
+
describe("MTL-221: uninstallPluginInScope drops the plugin from the profile", () => {
|
|
73
|
+
test("c. PROJECT uninstall: uninstalls at PROJECT, then drops from the profile once", async () => {
|
|
74
|
+
const { deps, remove, calls } = setup(["project"]);
|
|
75
|
+
|
|
76
|
+
await uninstallPluginInScope(ID, "project", PROJECT, deps, PROFILE);
|
|
77
|
+
|
|
78
|
+
expect(remove.uninstall).toHaveBeenCalledWith(ID, "project", PROJECT);
|
|
79
|
+
expect(remove.dropFromProfile).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(calls).toEqual([
|
|
81
|
+
`uninstall:${ID}:project:${PROJECT}`,
|
|
82
|
+
"dropFromProfile",
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("d. USER uninstall while PROJECT still installs it: the profile is untouched", async () => {
|
|
87
|
+
const { deps, remove } = setup(["user", "project"]);
|
|
88
|
+
|
|
89
|
+
await uninstallPluginInScope(ID, "user", PROJECT, deps, PROFILE);
|
|
90
|
+
|
|
91
|
+
expect(remove.uninstall).toHaveBeenCalledWith(ID, "user", PROJECT);
|
|
92
|
+
expect(remove.dropFromProfile).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("e. LOCAL uninstall leaving it installed nowhere: drops from the profile once", async () => {
|
|
96
|
+
const { deps, remove } = setup(["local"]);
|
|
97
|
+
|
|
98
|
+
await uninstallPluginInScope(ID, "local", PROJECT, deps, PROFILE);
|
|
99
|
+
|
|
100
|
+
expect(remove.uninstall).toHaveBeenCalledWith(ID, "local", PROJECT);
|
|
101
|
+
expect(remove.dropFromProfile).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Real scratch git repositories for tests that need `inheritablePaths` to
|
|
8
|
+
* answer for real. Everything lives under os.tmpdir(); nothing touches the
|
|
9
|
+
* operator's home or config.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Isolate from the operator's git config (signing, default branch, excludes).
|
|
13
|
+
const GIT_CFG = [
|
|
14
|
+
"-c",
|
|
15
|
+
"user.name=test",
|
|
16
|
+
"-c",
|
|
17
|
+
"user.email=test@example.com",
|
|
18
|
+
"-c",
|
|
19
|
+
"commit.gpgsign=false",
|
|
20
|
+
"-c",
|
|
21
|
+
"init.defaultBranch=main",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const git = (cwd: string, ...args: string[]): string =>
|
|
25
|
+
execFileSync("git", [...GIT_CFG, ...args], {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
}).trim();
|
|
30
|
+
|
|
31
|
+
/** A new scratch directory; realpath'd because git reports real paths. */
|
|
32
|
+
export async function makeScratch(): Promise<string> {
|
|
33
|
+
return fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "magus-remove-")));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `git init` plus one commit at `dir`. */
|
|
37
|
+
export async function initRepo(dir: string): Promise<void> {
|
|
38
|
+
await fs.ensureDir(dir);
|
|
39
|
+
git(dir, "init", "-q");
|
|
40
|
+
await fs.writeFile(path.join(dir, "README.md"), "hello\n");
|
|
41
|
+
git(dir, "add", "-A");
|
|
42
|
+
git(dir, "commit", "-qm", "initial");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A linked worktree of `main` at `dir`, on a new branch. */
|
|
46
|
+
export function addWorktree(main: string, dir: string, branch: string): void {
|
|
47
|
+
git(main, "worktree", "add", "-q", "-b", branch, dir);
|
|
48
|
+
}
|