magus-cli 7.5.3 → 7.5.4
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__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +39 -18
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -6
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +7 -2
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useProfileStartupModal.ts +87 -7
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -145,7 +145,7 @@ describe("strict active-profile check", () => {
|
|
|
145
145
|
profile: "other",
|
|
146
146
|
restored: false,
|
|
147
147
|
});
|
|
148
|
-
expect(prep.lines[0]).toContain('
|
|
148
|
+
expect(prep.lines[0]).toContain('Using profile "other"');
|
|
149
149
|
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
150
150
|
expect(await activeProfile(project)).toBe("other");
|
|
151
151
|
});
|
|
@@ -224,11 +224,11 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
224
224
|
|
|
225
225
|
expect(prep.problems).toEqual([]);
|
|
226
226
|
expect(prep.lines).toContain(
|
|
227
|
-
'Changes Claude Code made
|
|
227
|
+
'Changes Claude Code made were added to profile "team"',
|
|
228
228
|
);
|
|
229
229
|
expect(prep.lines).toContain('Added code-search@magus to profile "team"');
|
|
230
230
|
expect(prep.lines).toContain(
|
|
231
|
-
'
|
|
231
|
+
'Updated profile "team" to the current profile format',
|
|
232
232
|
);
|
|
233
233
|
// In profiles.json...
|
|
234
234
|
const team = (await readManifest(project)).profiles.team;
|
|
@@ -265,9 +265,8 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
265
265
|
enabledPlugins: { "dev@magus": true, "code-search@magus": true },
|
|
266
266
|
permissions: { allow: ["Bash(bun test:*)"] },
|
|
267
267
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
268
|
+
// Said in the user's terms; where the backup went is checked on disk above.
|
|
269
|
+
expect(prep.lines).toContain("A backup of the old profile format was kept");
|
|
271
270
|
expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
|
|
272
271
|
"active.json",
|
|
273
272
|
]);
|
|
@@ -316,7 +315,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
316
315
|
// stands, the key is named, and the older copy is in the backup.
|
|
317
316
|
expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
|
|
318
317
|
expect(prep.lines).toContain(
|
|
319
|
-
'Profile "team": models differs between the
|
|
318
|
+
'Profile "team": models differs between the old profile format and profiles.json — kept profiles.json\'s value (the old one is backed up)',
|
|
320
319
|
);
|
|
321
320
|
const live = await fs.readJson(settingsPath());
|
|
322
321
|
expect(live.enabledPlugins["code-search@magus"]).toBe(true);
|
|
@@ -426,7 +425,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
426
425
|
// Removals are not applied, and the run says so, once.
|
|
427
426
|
expect(
|
|
428
427
|
prep.lines.filter((l) =>
|
|
429
|
-
l.includes("a removal made
|
|
428
|
+
l.includes("a removal made in the old format is not applied"),
|
|
430
429
|
),
|
|
431
430
|
).toHaveLength(1);
|
|
432
431
|
const live = await fs.readJson(settingsPath());
|
|
@@ -478,7 +477,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
478
477
|
"mcpServers.db",
|
|
479
478
|
]) {
|
|
480
479
|
expect(prep.lines).toContain(
|
|
481
|
-
`Profile "team": ${at} differs between the
|
|
480
|
+
`Profile "team": ${at} differs between the old profile format and profiles.json — kept profiles.json's value (the old one is backed up)`,
|
|
482
481
|
);
|
|
483
482
|
}
|
|
484
483
|
// The older copy really is in the backup.
|
|
@@ -577,7 +576,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
577
576
|
const prep = await prepareProfileState(project);
|
|
578
577
|
|
|
579
578
|
expect(prep.problems).toHaveLength(1);
|
|
580
|
-
expect(prep.problems[0]).toContain("did not
|
|
579
|
+
expect(prep.problems[0]).toContain("did not update the old profile format");
|
|
581
580
|
expect(
|
|
582
581
|
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
583
582
|
).toBe(true);
|
|
@@ -623,7 +622,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
623
622
|
}
|
|
624
623
|
|
|
625
624
|
expect(await snapshot()).toEqual(before);
|
|
626
|
-
expect(printed()).toContain("
|
|
625
|
+
expect(printed()).toContain("uses an old magus profile format");
|
|
627
626
|
expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
|
|
628
627
|
});
|
|
629
628
|
|
|
@@ -637,11 +636,9 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
637
636
|
const counts = await reportProfileState(project, false);
|
|
638
637
|
|
|
639
638
|
const out = printed();
|
|
640
|
-
expect(out).toContain(
|
|
641
|
-
'No active-profile marker, but the live config matches profile "other" exactly',
|
|
642
|
-
);
|
|
639
|
+
expect(out).toContain('This project\'s plugins match profile "other"');
|
|
643
640
|
// The contradicting line is gone, and so is the failure.
|
|
644
|
-
expect(out).not.toContain("
|
|
641
|
+
expect(out).not.toContain("match none of the profiles");
|
|
645
642
|
expect(counts).toEqual({ fixable: 0, unfixable: 0 });
|
|
646
643
|
// Report only: the marker is not restored.
|
|
647
644
|
expect(await readActiveMarker(project)).toBeNull();
|
|
@@ -699,8 +696,14 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
699
696
|
const nothingStaged = () =>
|
|
700
697
|
expect(git(project, "diff", "--cached", "--name-only").stdout).toBe("");
|
|
701
698
|
|
|
702
|
-
|
|
703
|
-
|
|
699
|
+
// The notice is short lines: the problem, then the command on its own line.
|
|
700
|
+
const PROBLEM = "git tracks .claude/settings.json, but magus rewrites it";
|
|
701
|
+
const FIX = "git rm --cached .claude/settings.json, then commit";
|
|
702
|
+
/** Both lines, each printed exactly once. */
|
|
703
|
+
const warnedOnce = (out: string) => {
|
|
704
|
+
expect(out.split(PROBLEM).length - 1).toBe(1);
|
|
705
|
+
expect(out.split(FIX).length - 1).toBe(1);
|
|
706
|
+
};
|
|
704
707
|
|
|
705
708
|
test("B7: activating over a tracked settings.json writes it and names the fix", async () => {
|
|
706
709
|
await commitTrackedSettings();
|
|
@@ -710,7 +713,8 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
710
713
|
});
|
|
711
714
|
|
|
712
715
|
expect(result.tracked).toEqual([".claude/settings.json"]);
|
|
713
|
-
expect(activationNotes(result)).toContain(
|
|
716
|
+
expect(activationNotes(result)).toContain(PROBLEM);
|
|
717
|
+
expect(activationNotes(result)).toContain(FIX);
|
|
714
718
|
expect((await fs.readJson(settingsPath())).enabledPlugins).toEqual({
|
|
715
719
|
"dev@magus": true,
|
|
716
720
|
"terminal@magus": false,
|
|
@@ -761,7 +765,7 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
761
765
|
process.chdir(cwd);
|
|
762
766
|
}
|
|
763
767
|
expect(outcome.exitCode).toBe(0);
|
|
764
|
-
|
|
768
|
+
warnedOnce(printed());
|
|
765
769
|
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
766
770
|
nothingStaged();
|
|
767
771
|
});
|
|
@@ -772,7 +776,7 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
772
776
|
// A named profile that does not exist: the command stops right after the
|
|
773
777
|
// warning, before anything reaches the network.
|
|
774
778
|
expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
|
|
775
|
-
|
|
779
|
+
warnedOnce(printed());
|
|
776
780
|
nothingStaged();
|
|
777
781
|
});
|
|
778
782
|
|
|
@@ -824,7 +828,7 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
824
828
|
expect(await activeProfile(project)).toBe(
|
|
825
829
|
Object.keys(manifest.profiles)[0],
|
|
826
830
|
);
|
|
827
|
-
|
|
831
|
+
warnedOnce(printed());
|
|
828
832
|
nothingStaged();
|
|
829
833
|
});
|
|
830
834
|
|
|
@@ -833,7 +837,8 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
833
837
|
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
834
838
|
const counts = await reportProfileState(project, false);
|
|
835
839
|
expect(counts.unfixable).toBe(0);
|
|
836
|
-
expect(printed()).toContain(`• ${
|
|
840
|
+
expect(printed()).toContain(`• ${PROBLEM}`);
|
|
841
|
+
expect(printed()).toContain(FIX);
|
|
837
842
|
});
|
|
838
843
|
|
|
839
844
|
test("B7: writeSettings and writeMcpConfig write a tracked live file", async () => {
|
|
@@ -8,41 +8,37 @@ import { planPluginUpdates, scopeIsBehind } from "../services/update-plan.js";
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The scope keys (u / p / l) and the Enter scope picker both decide between
|
|
11
|
-
* install, update and uninstall. That decision used to read
|
|
12
|
-
*
|
|
13
|
-
* which is the registry-backed answer to "did Claude Code actually install
|
|
14
|
-
* this".
|
|
11
|
+
* install, update and uninstall. That decision used to read the settings flag
|
|
12
|
+
* alone and ignore `scope.version`.
|
|
15
13
|
*
|
|
16
|
-
*
|
|
14
|
+
* The two disagree in a real, common state: a plugin listed in
|
|
17
15
|
* `enabledPlugins` with no entry in `installed_plugins.json` for the current
|
|
18
|
-
* project path
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* project path — installed, with its files missing (hasMissingFiles). Pressing
|
|
17
|
+
* its scope key ran `claude plugin uninstall`. The user asked to repair the
|
|
18
|
+
* thing the UI told them was broken, and it was removed instead.
|
|
21
19
|
*/
|
|
22
20
|
|
|
23
21
|
describe("isInstalledInScope", () => {
|
|
24
|
-
test("
|
|
25
|
-
expect(isInstalledInScope({
|
|
22
|
+
test("installed with a registry version is installed", () => {
|
|
23
|
+
expect(isInstalledInScope({ installed: true, version: "3.0.2" })).toBe(
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
test("
|
|
28
|
+
test("installed with NO version is still installed — files missing", () => {
|
|
29
29
|
// dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
|
|
30
30
|
// installed_plugins.json had no entry resolving for the project path.
|
|
31
|
-
|
|
31
|
+
// Installed is the settings fact; the missing version is the broken
|
|
32
|
+
// sub-state, and the scope key still resolves it to `install` (below).
|
|
33
|
+
expect(isInstalledInScope({ installed: true })).toBe(true);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
test("a registry version with the flag off
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
// The hazard the sibling test above guards — pressing a scope key on
|
|
42
|
-
// enabled-with-no-version and having it run `uninstall` — is untouched:
|
|
43
|
-
// that case has no version, so it is still not installed, and still
|
|
44
|
-
// resolves to `install`.
|
|
45
|
-
expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(true);
|
|
36
|
+
test("a registry version with the settings flag off is NOT installed", () => {
|
|
37
|
+
// A registry row alone never makes Claude Code load a plugin, and it can
|
|
38
|
+
// be inherited from another checkout of the same repository.
|
|
39
|
+
expect(isInstalledInScope({ installed: false, version: "3.0.2" })).toBe(
|
|
40
|
+
false,
|
|
41
|
+
);
|
|
46
42
|
});
|
|
47
43
|
|
|
48
44
|
test("an absent scope is not installed", () => {
|
|
@@ -50,7 +46,9 @@ describe("isInstalledInScope", () => {
|
|
|
50
46
|
});
|
|
51
47
|
|
|
52
48
|
test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
|
|
53
|
-
expect(isInstalledInScope({
|
|
49
|
+
expect(isInstalledInScope({ installed: true, version: "0.0.0" })).toBe(
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
54
52
|
});
|
|
55
53
|
});
|
|
56
54
|
|
|
@@ -59,34 +57,34 @@ describe("resolveScopeAction", () => {
|
|
|
59
57
|
// takes from the classifier (`scopeIsBehind`). It used to be the catalog
|
|
60
58
|
// version, compared here with raw string inequality — a fourth rule.
|
|
61
59
|
|
|
62
|
-
test("REGRESSION:
|
|
60
|
+
test("REGRESSION: files missing resolves to install, never uninstall", () => {
|
|
63
61
|
// This is the reported bug. Before the fix this returned "uninstall".
|
|
64
|
-
expect(resolveScopeAction({
|
|
62
|
+
expect(resolveScopeAction({ installed: true }, false)).toBe("install");
|
|
65
63
|
// Whatever the classifier says: the safety branch comes first.
|
|
66
|
-
expect(resolveScopeAction({
|
|
64
|
+
expect(resolveScopeAction({ installed: true }, true)).toBe("install");
|
|
67
65
|
});
|
|
68
66
|
|
|
69
|
-
test("a scope that was never
|
|
67
|
+
test("a scope that was never installed resolves to install", () => {
|
|
70
68
|
expect(resolveScopeAction(undefined, false)).toBe("install");
|
|
71
|
-
expect(resolveScopeAction({
|
|
69
|
+
expect(resolveScopeAction({ installed: false }, false)).toBe("install");
|
|
72
70
|
});
|
|
73
71
|
|
|
74
72
|
test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
|
|
75
|
-
expect(
|
|
76
|
-
"
|
|
77
|
-
);
|
|
73
|
+
expect(
|
|
74
|
+
resolveScopeAction({ installed: true, version: "3.0.2" }, false),
|
|
75
|
+
).toBe("uninstall");
|
|
78
76
|
});
|
|
79
77
|
|
|
80
78
|
test("a healthy install behind the catalog resolves to update", () => {
|
|
81
|
-
expect(
|
|
82
|
-
"
|
|
83
|
-
);
|
|
79
|
+
expect(
|
|
80
|
+
resolveScopeAction({ installed: true, version: "2.9.0" }, true),
|
|
81
|
+
).toBe("update");
|
|
84
82
|
});
|
|
85
83
|
});
|
|
86
84
|
|
|
87
85
|
/** The screen's path: classify, then ask `scopeIsBehind` for the pressed scope. */
|
|
88
86
|
function keyFor(installed: string, catalogVersion: string | null) {
|
|
89
|
-
const scope = {
|
|
87
|
+
const scope = { installed: true, version: installed };
|
|
90
88
|
const [status] = planPluginUpdates(
|
|
91
89
|
{ "x@magus": "latest" },
|
|
92
90
|
new Map([
|
|
@@ -99,7 +97,6 @@ function keyFor(installed: string, catalogVersion: string | null) {
|
|
|
99
97
|
description: "",
|
|
100
98
|
marketplace: "magus",
|
|
101
99
|
marketplaceDisplay: "Magus",
|
|
102
|
-
enabled: true,
|
|
103
100
|
projectScope: scope,
|
|
104
101
|
} as PluginInfo,
|
|
105
102
|
],
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which registry row a question reads — the pure halves of two fixes whose
|
|
3
|
+
* end-to-end cases are in installed-version-scope.test.tsx.
|
|
4
|
+
*
|
|
5
|
+
* - `ownRegistryEntry`: "does THIS project hold a row at this scope?" — the
|
|
6
|
+
* apply engine's guard before `update`. It used to be answered by the lookup
|
|
7
|
+
* that falls back to inherited rows and then the user row.
|
|
8
|
+
* - `contentDriftEntry`: the row whose commit a content-drift check compares.
|
|
9
|
+
* It used to be the project lookup for every plugin, installed there or not.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { ownRegistryEntry } from "../services/claude-settings.js";
|
|
14
|
+
import {
|
|
15
|
+
type PluginInfo,
|
|
16
|
+
contentDriftEntry,
|
|
17
|
+
} from "../services/plugin-manager.js";
|
|
18
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
19
|
+
|
|
20
|
+
const REPO = "/work/repo";
|
|
21
|
+
const HERE = "/work/repo/sub";
|
|
22
|
+
|
|
23
|
+
const row = (over: Partial<InstalledPluginEntry>): InstalledPluginEntry => ({
|
|
24
|
+
scope: "user",
|
|
25
|
+
installPath: "/cache",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
installedAt: "",
|
|
28
|
+
lastUpdated: "",
|
|
29
|
+
...over,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("ownRegistryEntry — this project's own row, exactly", () => {
|
|
33
|
+
test("never falls back to the user row for project or local scope", () => {
|
|
34
|
+
const rows = [row({ scope: "user" })];
|
|
35
|
+
expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
|
|
36
|
+
expect(ownRegistryEntry(rows, "local", HERE)).toBeUndefined();
|
|
37
|
+
expect(ownRegistryEntry(rows, "user", HERE)?.scope).toBe("user");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("never takes a row inherited from the repository root", () => {
|
|
41
|
+
const rows = [row({ scope: "project", projectPath: REPO })];
|
|
42
|
+
expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("matches scope and path exactly — a local row is not a project row", () => {
|
|
46
|
+
const local = row({ scope: "local", projectPath: HERE, version: "3.0.0" });
|
|
47
|
+
const project = row({
|
|
48
|
+
scope: "project",
|
|
49
|
+
projectPath: `${HERE}/`,
|
|
50
|
+
version: "2.0.0",
|
|
51
|
+
});
|
|
52
|
+
expect(ownRegistryEntry([local], "project", HERE)).toBeUndefined();
|
|
53
|
+
expect(ownRegistryEntry([local, project], "project", HERE)?.version).toBe(
|
|
54
|
+
"2.0.0",
|
|
55
|
+
);
|
|
56
|
+
expect(ownRegistryEntry([local, project], "local", HERE)?.version).toBe(
|
|
57
|
+
"3.0.0",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("contentDriftEntry — the row behind an installed scope", () => {
|
|
63
|
+
const USER = row({ scope: "user", version: "0.9.0", gitCommitSha: "user" });
|
|
64
|
+
const INHERITED = row({
|
|
65
|
+
scope: "project",
|
|
66
|
+
projectPath: REPO,
|
|
67
|
+
version: "0.8.1",
|
|
68
|
+
gitCommitSha: "inherited",
|
|
69
|
+
});
|
|
70
|
+
const OWN = row({
|
|
71
|
+
scope: "project",
|
|
72
|
+
projectPath: HERE,
|
|
73
|
+
version: "0.9.0",
|
|
74
|
+
gitCommitSha: "own",
|
|
75
|
+
});
|
|
76
|
+
const plugin = (over: Partial<PluginInfo>): PluginInfo => ({
|
|
77
|
+
id: "probe@magus",
|
|
78
|
+
name: "probe",
|
|
79
|
+
version: "0.9.0",
|
|
80
|
+
description: "",
|
|
81
|
+
marketplace: "magus",
|
|
82
|
+
marketplaceDisplay: "Magus",
|
|
83
|
+
installedVersion: "0.9.0",
|
|
84
|
+
...over,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("installed at user, beside an inherited project row of another version: the user row", () => {
|
|
88
|
+
const entry = contentDriftEntry(
|
|
89
|
+
plugin({
|
|
90
|
+
userScope: { installed: true, version: "0.9.0" },
|
|
91
|
+
projectScope: { installed: false, version: "0.8.1" },
|
|
92
|
+
}),
|
|
93
|
+
[USER, INHERITED],
|
|
94
|
+
HERE,
|
|
95
|
+
[REPO],
|
|
96
|
+
);
|
|
97
|
+
expect(entry?.gitCommitSha).toBe("user");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("installed at both at the same version: this directory's row first", () => {
|
|
101
|
+
const entry = contentDriftEntry(
|
|
102
|
+
plugin({
|
|
103
|
+
userScope: { installed: true, version: "0.9.0" },
|
|
104
|
+
projectScope: { installed: true, version: "0.9.0" },
|
|
105
|
+
}),
|
|
106
|
+
[USER, OWN],
|
|
107
|
+
HERE,
|
|
108
|
+
[REPO],
|
|
109
|
+
);
|
|
110
|
+
expect(entry?.gitCommitSha).toBe("own");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("no installed scope with a version: no row", () => {
|
|
114
|
+
const entry = contentDriftEntry(
|
|
115
|
+
plugin({
|
|
116
|
+
installedVersion: undefined,
|
|
117
|
+
userScope: { installed: true },
|
|
118
|
+
projectScope: { installed: false, version: "0.8.1" },
|
|
119
|
+
}),
|
|
120
|
+
[INHERITED],
|
|
121
|
+
HERE,
|
|
122
|
+
[REPO],
|
|
123
|
+
);
|
|
124
|
+
expect(entry).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `selectOption` always puts the terminal back.
|
|
3
|
+
*
|
|
4
|
+
* It sets raw mode, attaches a keypress listener and hides the cursor, and only
|
|
5
|
+
* Enter, Esc and Ctrl+C undid them. A throw while drawing, a SIGTERM or a SIGHUP
|
|
6
|
+
* left the shell in raw mode with no cursor — typing blind until `reset`.
|
|
7
|
+
*
|
|
8
|
+
* `process.stdin` and `process.stdout` are swapped for fakes for the length of
|
|
9
|
+
* each case, so no real terminal is touched; `process.exit` is stubbed wherever
|
|
10
|
+
* a signal handler would call it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import { selectOption } from "../cli/select.js";
|
|
16
|
+
|
|
17
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
18
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
19
|
+
/** Every frame row is written with this prefix; control sequences are not. */
|
|
20
|
+
const FRAME_ROW = "\x1b[2K";
|
|
21
|
+
|
|
22
|
+
class FakeStdin extends EventEmitter {
|
|
23
|
+
isTTY = true;
|
|
24
|
+
isRaw = false;
|
|
25
|
+
setRawMode(raw: boolean): this {
|
|
26
|
+
this.isRaw = raw;
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
resume(): this {
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
pause(): this {
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
key(name: string, extra: Record<string, unknown> = {}): void {
|
|
36
|
+
this.emit("keypress", "", { name, ...extra });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class FakeStdout {
|
|
41
|
+
isTTY = true;
|
|
42
|
+
writes: string[] = [];
|
|
43
|
+
frames = 0;
|
|
44
|
+
/** Throw on this frame (1-based): the first draw, or a redraw. */
|
|
45
|
+
constructor(private readonly throwOnFrame = 0) {}
|
|
46
|
+
write(chunk: string): boolean {
|
|
47
|
+
if (chunk.includes(FRAME_ROW) && ++this.frames === this.throwOnFrame) {
|
|
48
|
+
throw new Error("the terminal went away");
|
|
49
|
+
}
|
|
50
|
+
this.writes.push(chunk);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const WATCHED = ["exit", "SIGTERM", "SIGHUP"] as const;
|
|
56
|
+
const listenerCounts = () =>
|
|
57
|
+
Object.fromEntries(WATCHED.map((e) => [e, process.listenerCount(e)]));
|
|
58
|
+
|
|
59
|
+
const originals = {
|
|
60
|
+
stdin: Object.getOwnPropertyDescriptor(process, "stdin"),
|
|
61
|
+
stdout: Object.getOwnPropertyDescriptor(process, "stdout"),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function useFakes(stdout = new FakeStdout()) {
|
|
65
|
+
const stdin = new FakeStdin();
|
|
66
|
+
Object.defineProperty(process, "stdin", {
|
|
67
|
+
value: stdin,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
});
|
|
71
|
+
Object.defineProperty(process, "stdout", {
|
|
72
|
+
value: stdout,
|
|
73
|
+
configurable: true,
|
|
74
|
+
writable: true,
|
|
75
|
+
});
|
|
76
|
+
return { stdin, stdout };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let exitSpy: ReturnType<typeof spyOn> | null = null;
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
if (originals.stdin) Object.defineProperty(process, "stdin", originals.stdin);
|
|
83
|
+
if (originals.stdout)
|
|
84
|
+
Object.defineProperty(process, "stdout", originals.stdout);
|
|
85
|
+
exitSpy?.mockRestore();
|
|
86
|
+
exitSpy = null;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const ITEMS = [{ label: "One" }, { label: "Two" }, { label: "Three" }];
|
|
90
|
+
|
|
91
|
+
/** Raw mode off, no keypress listener, the cursor shown after it was hidden. */
|
|
92
|
+
function expectRestored(stdin: FakeStdin, stdout: FakeStdout): void {
|
|
93
|
+
expect(stdin.isRaw).toBe(false);
|
|
94
|
+
expect(stdin.listenerCount("keypress")).toBe(0);
|
|
95
|
+
const hidden = stdout.writes.lastIndexOf(HIDE_CURSOR);
|
|
96
|
+
expect(hidden).toBeGreaterThanOrEqual(0);
|
|
97
|
+
expect(stdout.writes.lastIndexOf(SHOW_CURSOR)).toBeGreaterThan(hidden);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("an answer still behaves as it did", () => {
|
|
101
|
+
test("Enter picks the current row, and the terminal is back", async () => {
|
|
102
|
+
const { stdin, stdout } = useFakes();
|
|
103
|
+
const baseline = listenerCounts();
|
|
104
|
+
const answer = selectOption("Pick", ITEMS);
|
|
105
|
+
stdin.key("down");
|
|
106
|
+
stdin.key("return");
|
|
107
|
+
|
|
108
|
+
expect(await answer).toBe(1);
|
|
109
|
+
expectRestored(stdin, stdout);
|
|
110
|
+
// The cursor is shown last, after the one-line answer.
|
|
111
|
+
expect(stdout.writes.at(-1)).toBe(SHOW_CURSOR);
|
|
112
|
+
expect(stdout.writes.at(-2)).toContain("Two");
|
|
113
|
+
expect(listenerCounts()).toEqual(baseline);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("Esc and Ctrl+C cancel with null, and the terminal is back", async () => {
|
|
117
|
+
for (const key of [{ name: "escape" }, { name: "c", ctrl: true }]) {
|
|
118
|
+
const { stdin, stdout } = useFakes();
|
|
119
|
+
const answer = selectOption("Pick", ITEMS);
|
|
120
|
+
stdin.key(key.name, key);
|
|
121
|
+
expect(await answer).toBeNull();
|
|
122
|
+
expectRestored(stdin, stdout);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("keys after the answer in the same read are not handled", async () => {
|
|
127
|
+
const { stdin, stdout } = useFakes();
|
|
128
|
+
const answer = selectOption("Pick", ITEMS);
|
|
129
|
+
stdin.key("return");
|
|
130
|
+
stdin.key("down");
|
|
131
|
+
stdin.key("return");
|
|
132
|
+
expect(await answer).toBe(0);
|
|
133
|
+
expect(stdout.writes.filter((w) => w.includes("One")).length).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("anything else that ends it puts the terminal back too", () => {
|
|
138
|
+
test("a throw on the first draw rejects, and the terminal is back", async () => {
|
|
139
|
+
const { stdin, stdout } = useFakes(new FakeStdout(1));
|
|
140
|
+
const baseline = listenerCounts();
|
|
141
|
+
|
|
142
|
+
await expect(selectOption("Pick", ITEMS)).rejects.toThrow(
|
|
143
|
+
"the terminal went away",
|
|
144
|
+
);
|
|
145
|
+
expectRestored(stdin, stdout);
|
|
146
|
+
expect(listenerCounts()).toEqual(baseline);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a throw redrawing after a key rejects instead of escaping the listener", async () => {
|
|
150
|
+
const { stdin, stdout } = useFakes(new FakeStdout(2));
|
|
151
|
+
const answer = selectOption("Pick", ITEMS);
|
|
152
|
+
|
|
153
|
+
expect(() => stdin.key("down")).not.toThrow();
|
|
154
|
+
await expect(answer).rejects.toThrow("the terminal went away");
|
|
155
|
+
expectRestored(stdin, stdout);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
for (const [signal, code] of [
|
|
159
|
+
["SIGTERM", 143],
|
|
160
|
+
["SIGHUP", 129],
|
|
161
|
+
] as const) {
|
|
162
|
+
test(`${signal} puts the terminal back, then exits ${code}`, () => {
|
|
163
|
+
const { stdin, stdout } = useFakes();
|
|
164
|
+
const baseline = listenerCounts();
|
|
165
|
+
exitSpy = spyOn(process, "exit").mockImplementation(
|
|
166
|
+
(() => undefined) as never,
|
|
167
|
+
);
|
|
168
|
+
void selectOption("Pick", ITEMS);
|
|
169
|
+
|
|
170
|
+
process.emit(signal);
|
|
171
|
+
|
|
172
|
+
expectRestored(stdin, stdout);
|
|
173
|
+
expect(exitSpy).toHaveBeenCalledWith(code);
|
|
174
|
+
// One-shot, and the others went with it.
|
|
175
|
+
expect(listenerCounts()).toEqual(baseline);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
test("the process exiting while it is open puts the terminal back", () => {
|
|
180
|
+
const { stdin, stdout } = useFakes();
|
|
181
|
+
const before = process.listeners("exit");
|
|
182
|
+
void selectOption("Pick", ITEMS);
|
|
183
|
+
const added = process.listeners("exit").filter((l) => !before.includes(l));
|
|
184
|
+
|
|
185
|
+
// Its own listener only: emitting "exit" would run everyone's.
|
|
186
|
+
expect(added).toHaveLength(1);
|
|
187
|
+
(added[0] as (code: number) => void)(0);
|
|
188
|
+
|
|
189
|
+
expectRestored(stdin, stdout);
|
|
190
|
+
expect(process.listeners("exit")).toEqual(before);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("the handlers live exactly as long as the list", () => {
|
|
195
|
+
test("one each while it is open, none after it answers", async () => {
|
|
196
|
+
const { stdin } = useFakes();
|
|
197
|
+
const baseline = listenerCounts();
|
|
198
|
+
const answer = selectOption("Pick", ITEMS);
|
|
199
|
+
|
|
200
|
+
expect(listenerCounts()).toEqual({
|
|
201
|
+
exit: baseline.exit + 1,
|
|
202
|
+
SIGTERM: baseline.SIGTERM + 1,
|
|
203
|
+
SIGHUP: baseline.SIGHUP + 1,
|
|
204
|
+
});
|
|
205
|
+
stdin.key("return");
|
|
206
|
+
await answer;
|
|
207
|
+
expect(listenerCounts()).toEqual(baseline);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -874,10 +874,9 @@ describe("the popup count equals the Plugins header count", () => {
|
|
|
874
874
|
description: "",
|
|
875
875
|
marketplace: id.split("@")[1] ?? "magus",
|
|
876
876
|
marketplaceDisplay: "",
|
|
877
|
-
enabled: true,
|
|
878
877
|
installedVersion: mine,
|
|
879
878
|
loadedVersion: loaded[id] ?? null,
|
|
880
|
-
projectScope: {
|
|
879
|
+
projectScope: { installed: true, version: mine },
|
|
881
880
|
} as PluginInfo;
|
|
882
881
|
},
|
|
883
882
|
);
|
|
@@ -177,7 +177,14 @@ describe("scope handling", () => {
|
|
|
177
177
|
test("a fresh install goes to project scope only", async () => {
|
|
178
178
|
const { deps, installed } = stubDeps("2.0.0");
|
|
179
179
|
await applyPlugins(
|
|
180
|
-
[
|
|
180
|
+
[
|
|
181
|
+
item({
|
|
182
|
+
action: "install",
|
|
183
|
+
reason: "absent",
|
|
184
|
+
installed: null,
|
|
185
|
+
scopes: [],
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
181
188
|
PROJECT,
|
|
182
189
|
deps,
|
|
183
190
|
);
|
|
@@ -228,7 +235,14 @@ describe("scope handling", () => {
|
|
|
228
235
|
test("a fresh install runs `install`, never `update`", async () => {
|
|
229
236
|
const { deps, installed, updated } = stubDeps("2.0.0");
|
|
230
237
|
await applyPlugins(
|
|
231
|
-
[
|
|
238
|
+
[
|
|
239
|
+
item({
|
|
240
|
+
action: "install",
|
|
241
|
+
reason: "absent",
|
|
242
|
+
installed: null,
|
|
243
|
+
scopes: [],
|
|
244
|
+
}),
|
|
245
|
+
],
|
|
232
246
|
PROJECT,
|
|
233
247
|
deps,
|
|
234
248
|
);
|
|
@@ -715,6 +729,7 @@ describe("applyPlugins — read-back against beforeVersions", () => {
|
|
|
715
729
|
[
|
|
716
730
|
item({
|
|
717
731
|
action: "install",
|
|
732
|
+
reason: "absent",
|
|
718
733
|
installed: null,
|
|
719
734
|
scopes: [],
|
|
720
735
|
beforeVersions: {},
|
|
@@ -733,6 +748,7 @@ describe("applyPlugins — read-back against beforeVersions", () => {
|
|
|
733
748
|
[
|
|
734
749
|
item({
|
|
735
750
|
action: "install",
|
|
751
|
+
reason: "absent",
|
|
736
752
|
installed: null,
|
|
737
753
|
scopes: [],
|
|
738
754
|
beforeVersions: {},
|