magus-cli 7.4.1 → 7.5.1
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/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* their configuration:
|
|
6
6
|
*
|
|
7
7
|
* - an unparseable file must be REFUSED, and left byte-for-byte alone;
|
|
8
|
-
* - a symlinked settings.json must be written THROUGH the link
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* replace the link with a plain file and detach the project from its
|
|
12
|
-
* profile, which nothing downstream would notice.
|
|
8
|
+
* - a symlinked settings.json must be written THROUGH the link: a symlink a
|
|
9
|
+
* person set up deliberately is theirs. Remove-then-create would replace
|
|
10
|
+
* the link with a plain file, which nothing downstream would notice.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
@@ -154,7 +152,7 @@ describe("updateSettingsFile", () => {
|
|
|
154
152
|
expect(readFileSync(file, "utf8")).toBe(original);
|
|
155
153
|
});
|
|
156
154
|
|
|
157
|
-
test("S-6: a symlinked settings.json is written THROUGH the link
|
|
155
|
+
test("S-6: a symlinked settings.json is written THROUGH the link", async () => {
|
|
158
156
|
const dir = tempDir();
|
|
159
157
|
const target = path.join(dir, "profile-settings.json");
|
|
160
158
|
const link = path.join(dir, "settings.json");
|
|
@@ -168,7 +166,7 @@ describe("updateSettingsFile", () => {
|
|
|
168
166
|
settings.outputStyle = "composed";
|
|
169
167
|
});
|
|
170
168
|
|
|
171
|
-
// The
|
|
169
|
+
// The link target received the write …
|
|
172
170
|
expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
|
|
173
171
|
enabledPlugins: { "dev@magus": true },
|
|
174
172
|
outputStyle: "composed",
|
|
@@ -13,6 +13,7 @@ import path from "node:path";
|
|
|
13
13
|
import fs from "fs-extra";
|
|
14
14
|
import {
|
|
15
15
|
findStaleLoads,
|
|
16
|
+
loadedVersionHere,
|
|
16
17
|
repairAllStaleLoads,
|
|
17
18
|
repairStaleLoad,
|
|
18
19
|
writeInstalledPluginsRegistry,
|
|
@@ -24,6 +25,11 @@ import {
|
|
|
24
25
|
repoRootForPath,
|
|
25
26
|
resolveLoadedVersions,
|
|
26
27
|
} from "../services/plugin-resolution.js";
|
|
28
|
+
import {
|
|
29
|
+
type PluginApplyDeps,
|
|
30
|
+
applyPlugins,
|
|
31
|
+
bringLoadForward,
|
|
32
|
+
} from "../services/update-engine.js";
|
|
27
33
|
import {
|
|
28
34
|
classifyPluginRows,
|
|
29
35
|
countPluginStates,
|
|
@@ -1075,3 +1081,102 @@ describe("repairAllStaleLoads", () => {
|
|
|
1075
1081
|
expect(await backups()).toEqual([]);
|
|
1076
1082
|
});
|
|
1077
1083
|
});
|
|
1084
|
+
|
|
1085
|
+
describe("B4: an update brings the loader forward in the same run", () => {
|
|
1086
|
+
/**
|
|
1087
|
+
* `claude plugin update --scope project`, as far as the registry sees it:
|
|
1088
|
+
* this project's row moves to `version`, every other row stays.
|
|
1089
|
+
*/
|
|
1090
|
+
async function cliUpdateHere(version: string): Promise<void> {
|
|
1091
|
+
const registry = await readRegistry();
|
|
1092
|
+
registry.plugins[PLUGIN] = registry.plugins[PLUGIN].map((e) =>
|
|
1093
|
+
e.projectPath === here
|
|
1094
|
+
? { ...e, version, installPath: cacheFor(version) }
|
|
1095
|
+
: e,
|
|
1096
|
+
);
|
|
1097
|
+
await fs.outputJson(registryFile, registry, { spaces: 2 });
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** The real repair and loader question, against this case's plugins root. */
|
|
1101
|
+
function realDeps(): PluginApplyDeps {
|
|
1102
|
+
return {
|
|
1103
|
+
install: async () => {},
|
|
1104
|
+
update: async () => cliUpdateHere("0.5.0"),
|
|
1105
|
+
repair: async () => {},
|
|
1106
|
+
readInstalled: async () => {
|
|
1107
|
+
const rows = (await readRegistry()).plugins[PLUGIN];
|
|
1108
|
+
return rows.find((e) => e.projectPath === here)?.version ?? null;
|
|
1109
|
+
},
|
|
1110
|
+
saveInstalled: async () => {},
|
|
1111
|
+
repairStaleLoad: (id, target, projectPath) =>
|
|
1112
|
+
repairStaleLoad(id, target, projectPath, root),
|
|
1113
|
+
loadedVersion: (id, projectPath) =>
|
|
1114
|
+
loadedVersionHere(id, projectPath, root),
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
test("B4: the deleted worktree's older row stops winning, and the update says so", async () => {
|
|
1119
|
+
await writeRegistry([
|
|
1120
|
+
row({ version: "0.3.0", projectPath: gone }),
|
|
1121
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
1122
|
+
]);
|
|
1123
|
+
const details: string[] = [];
|
|
1124
|
+
|
|
1125
|
+
const result = await applyPlugins(
|
|
1126
|
+
[
|
|
1127
|
+
{
|
|
1128
|
+
pluginId: PLUGIN,
|
|
1129
|
+
pinned: "latest",
|
|
1130
|
+
installed: "0.4.0",
|
|
1131
|
+
available: "0.5.0",
|
|
1132
|
+
target: "0.5.0",
|
|
1133
|
+
action: "update",
|
|
1134
|
+
reason: "behind",
|
|
1135
|
+
scopes: ["project"],
|
|
1136
|
+
beforeVersions: { project: "0.4.0" },
|
|
1137
|
+
},
|
|
1138
|
+
],
|
|
1139
|
+
here,
|
|
1140
|
+
realDeps(),
|
|
1141
|
+
{
|
|
1142
|
+
begin: () => {},
|
|
1143
|
+
finish: (_ok, _name, detail) => details.push(detail),
|
|
1144
|
+
detach: (work) => work(),
|
|
1145
|
+
},
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
expect(result.ok).toBe(1);
|
|
1149
|
+
expect(result.failed).toEqual([]);
|
|
1150
|
+
expect(details).toEqual([
|
|
1151
|
+
"project → 0.5.0 · Claude Code now loads v0.5.0 here (1 older record moved forward)",
|
|
1152
|
+
]);
|
|
1153
|
+
expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
|
|
1154
|
+
// Moved forward, nothing removed.
|
|
1155
|
+
expect(
|
|
1156
|
+
(await readRegistry()).plugins[PLUGIN].map((e) => [
|
|
1157
|
+
e.projectPath,
|
|
1158
|
+
e.version,
|
|
1159
|
+
]),
|
|
1160
|
+
).toEqual([
|
|
1161
|
+
[gone, "0.5.0"],
|
|
1162
|
+
[here, "0.5.0"],
|
|
1163
|
+
]);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test("B4: without the repair step the same update leaves the old version loading (negative control)", async () => {
|
|
1167
|
+
await writeRegistry([
|
|
1168
|
+
row({ version: "0.3.0", projectPath: gone }),
|
|
1169
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
1170
|
+
]);
|
|
1171
|
+
await cliUpdateHere("0.5.0");
|
|
1172
|
+
expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.3.0");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test("B4: a loader already on the target is left alone — no write, no backup", async () => {
|
|
1176
|
+
await writeRegistry([row({ version: "0.5.0", projectPath: here })]);
|
|
1177
|
+
expect(
|
|
1178
|
+
await bringLoadForward(PLUGIN, "0.5.0", here, realDeps()),
|
|
1179
|
+
).toBeNull();
|
|
1180
|
+
expect(await backups()).toEqual([]);
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
@@ -4,6 +4,13 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import fs from "fs-extra";
|
|
6
6
|
import { EMBEDDED_PRESETS } from "../data/styles/index.js";
|
|
7
|
+
import { writeManifest } from "../services/manifest.js";
|
|
8
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
9
|
+
import {
|
|
10
|
+
liveHash,
|
|
11
|
+
readActiveMarker,
|
|
12
|
+
writeActiveMarker,
|
|
13
|
+
} from "../services/profile-live.js";
|
|
7
14
|
import {
|
|
8
15
|
INTEGRITY_BLOCK,
|
|
9
16
|
type ImportedStyle,
|
|
@@ -19,6 +26,7 @@ import {
|
|
|
19
26
|
splitFrontmatter,
|
|
20
27
|
validateSelection,
|
|
21
28
|
} from "../services/styles-manager.js";
|
|
29
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
22
30
|
import {
|
|
23
31
|
buildStyleBrowserItems,
|
|
24
32
|
firstSelectableIndex,
|
|
@@ -405,20 +413,26 @@ describe("applyStyles", () => {
|
|
|
405
413
|
).rejects.toThrow(/Nothing selected/);
|
|
406
414
|
});
|
|
407
415
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
416
|
+
const DEV: ProfileManifest = {
|
|
417
|
+
version: 2,
|
|
418
|
+
profiles: { dev: { name: "dev", plugins: {} } },
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
test("an older settings symlink is converted to a real file, not written through", async () => {
|
|
422
|
+
// Claude Code refuses to write through a symlink, so the style must not
|
|
423
|
+
// be applied by writing through one either: the file is rendered from
|
|
424
|
+
// the manifest in place of the link.
|
|
425
|
+
const oldSettings = join(
|
|
413
426
|
dir,
|
|
414
427
|
".claude",
|
|
415
428
|
"_profiles",
|
|
416
429
|
"dev",
|
|
417
430
|
"settings.json",
|
|
418
431
|
);
|
|
419
|
-
await fs.outputJson(
|
|
432
|
+
await fs.outputJson(oldSettings, { enabledPlugins: {} });
|
|
420
433
|
const link = join(dir, ".claude", "settings.json");
|
|
421
434
|
await symlink("_profiles/dev/settings.json", link);
|
|
435
|
+
await writeManifest(DEV, dir);
|
|
422
436
|
|
|
423
437
|
const result = await applyStyles({
|
|
424
438
|
projectPath: dir,
|
|
@@ -428,33 +442,13 @@ describe("applyStyles", () => {
|
|
|
428
442
|
|
|
429
443
|
expect(result.profile).toBe("dev");
|
|
430
444
|
expect(result.styleName).toBe("composed-dev");
|
|
431
|
-
expect((await fs.lstat(link)).isSymbolicLink()).toBe(
|
|
432
|
-
|
|
433
|
-
// profile" means.
|
|
434
|
-
expect((await fs.readJson(profileSettings)).outputStyle).toBe(
|
|
435
|
-
"composed-dev",
|
|
436
|
-
);
|
|
445
|
+
expect((await fs.lstat(link)).isSymbolicLink()).toBe(false);
|
|
446
|
+
expect((await fs.readJson(link)).outputStyle).toBe("composed-dev");
|
|
437
447
|
});
|
|
438
448
|
|
|
439
|
-
test("
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const profileSettings = join(
|
|
443
|
-
dir,
|
|
444
|
-
".claude",
|
|
445
|
-
"_profiles",
|
|
446
|
-
"dev",
|
|
447
|
-
"settings.json",
|
|
448
|
-
);
|
|
449
|
-
await fs.outputJson(profileSettings, { enabledPlugins: {} });
|
|
450
|
-
await symlink(
|
|
451
|
-
"_profiles/dev/settings.json",
|
|
452
|
-
join(dir, ".claude", "settings.json"),
|
|
453
|
-
);
|
|
454
|
-
await fs.outputJson(join(dir, ".claude", "profiles.json"), {
|
|
455
|
-
version: 2,
|
|
456
|
-
profiles: { dev: { name: "dev", plugins: {} } },
|
|
457
|
-
});
|
|
449
|
+
test("with an activated profile, the live file stays real and is recorded", async () => {
|
|
450
|
+
await writeManifest(DEV, dir);
|
|
451
|
+
await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
|
|
458
452
|
|
|
459
453
|
const result = await applyStyles({
|
|
460
454
|
projectPath: dir,
|
|
@@ -462,67 +456,46 @@ describe("applyStyles", () => {
|
|
|
462
456
|
imports: [],
|
|
463
457
|
});
|
|
464
458
|
|
|
459
|
+
const live = join(dir, ".claude", "settings.json");
|
|
465
460
|
expect(result.recordedInManifest).toBe(true);
|
|
466
|
-
|
|
467
|
-
expect(
|
|
461
|
+
expect((await fs.lstat(live)).isFile()).toBe(true);
|
|
462
|
+
expect((await fs.readJson(live)).outputStyle).toBe("composed-dev");
|
|
463
|
+
// Rendered from the manifest and recorded, so it reads as unchanged.
|
|
464
|
+
expect((await readActiveMarker(dir))?.files.settings).toBe(
|
|
465
|
+
(await liveHash("settings", dir)) ?? "",
|
|
466
|
+
);
|
|
468
467
|
});
|
|
469
468
|
|
|
470
|
-
test("
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
".claude",
|
|
474
|
-
"_profiles",
|
|
475
|
-
"dev",
|
|
476
|
-
"settings.json",
|
|
477
|
-
);
|
|
478
|
-
await fs.outputJson(profileSettings, {});
|
|
479
|
-
await symlink(
|
|
480
|
-
"_profiles/dev/settings.json",
|
|
481
|
-
join(dir, ".claude", "settings.json"),
|
|
482
|
-
);
|
|
469
|
+
test("records the style in profiles.json, where rendering reads it", async () => {
|
|
470
|
+
await writeManifest(DEV, dir);
|
|
471
|
+
await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
|
|
483
472
|
|
|
484
473
|
const result = await applyStyles({
|
|
485
474
|
projectPath: dir,
|
|
486
475
|
presets: [preset({ name: "direct" })],
|
|
487
476
|
imports: [],
|
|
488
477
|
});
|
|
489
|
-
expect(result.profile).toBe("dev");
|
|
490
|
-
expect(result.recordedInManifest).toBe(false);
|
|
491
|
-
});
|
|
492
|
-
});
|
|
493
478
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
presets: [preset({ name: "direct" })],
|
|
499
|
-
imports: [],
|
|
500
|
-
});
|
|
501
|
-
await clearStyle(dir);
|
|
479
|
+
expect(result.recordedInManifest).toBe(true);
|
|
480
|
+
const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
|
|
481
|
+
expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
|
|
482
|
+
});
|
|
502
483
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
484
|
+
test("a marker naming a profile profiles.json lacks is no active profile", async () => {
|
|
485
|
+
await writeManifest(DEV, dir);
|
|
486
|
+
await writeActiveMarker(
|
|
487
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
488
|
+
dir,
|
|
506
489
|
);
|
|
507
|
-
|
|
508
|
-
expect(JSON.parse(raw)).toEqual({});
|
|
509
|
-
});
|
|
490
|
+
await fs.outputJson(join(dir, ".claude", "settings.json"), {});
|
|
510
491
|
|
|
511
|
-
test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
|
|
512
492
|
const result = await applyStyles({
|
|
513
493
|
projectPath: dir,
|
|
514
494
|
presets: [preset({ name: "direct" })],
|
|
515
495
|
imports: [],
|
|
516
496
|
});
|
|
517
|
-
|
|
518
|
-
expect(
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
test("is a no-op on a project that never had a style", async () => {
|
|
522
|
-
await clearStyle(dir);
|
|
523
|
-
expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
|
|
524
|
-
false,
|
|
525
|
-
);
|
|
497
|
+
expect(result.profile).toBeNull();
|
|
498
|
+
expect(result.recordedInManifest).toBe(false);
|
|
526
499
|
});
|
|
527
500
|
});
|
|
528
501
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { parseKeypress } from "@opentui/core";
|
|
6
6
|
import fs from "fs-extra";
|
|
7
|
+
import { writeManifest } from "../services/manifest.js";
|
|
8
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
7
9
|
import {
|
|
8
10
|
applyStyles,
|
|
9
11
|
clearStyle,
|
|
10
12
|
communityCacheDirOrNull,
|
|
11
13
|
loadStyles,
|
|
12
14
|
} from "../services/styles-manager.js";
|
|
15
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
13
16
|
import type { KeyPress } from "../ui/input/key-dispatch.js";
|
|
14
17
|
import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
|
|
15
18
|
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
@@ -291,16 +294,14 @@ describe("loadStyles reports what is actually in force", () => {
|
|
|
291
294
|
});
|
|
292
295
|
|
|
293
296
|
test("looks for the per-profile style when a profile is active", async () => {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
{
|
|
297
|
-
outputStyle: "composed-dev",
|
|
297
|
+
const manifest: ProfileManifest = {
|
|
298
|
+
version: 2,
|
|
299
|
+
profiles: {
|
|
300
|
+
dev: { name: "dev", settings: { outputStyle: "composed-dev" } },
|
|
298
301
|
},
|
|
299
|
-
|
|
300
|
-
await
|
|
301
|
-
|
|
302
|
-
join(dir, ".claude", "settings.json"),
|
|
303
|
-
);
|
|
302
|
+
};
|
|
303
|
+
await writeManifest(manifest, dir);
|
|
304
|
+
await activateProfile(manifest, "dev", dir, { onDisplace: () => {} });
|
|
304
305
|
await fs.outputFile(
|
|
305
306
|
join(dir, ".claude", "output-styles", "composed-dev.md"),
|
|
306
307
|
"---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loaded before every test file (bunfig.toml `[test] preload`).
|
|
3
|
+
*
|
|
4
|
+
* Rendering a profile reads each plugin's plugin.json to find its
|
|
5
|
+
* dependencies, from `~/.claude/plugins` — different on every machine, and
|
|
6
|
+
* absent on CI. Every test starts from "no plugin declares a dependency"; a
|
|
7
|
+
* test about dependencies installs its own reader and restores this one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { setPluginDependencyReader } from "../../services/plugin-dependencies.js";
|
|
11
|
+
|
|
12
|
+
export const NO_DEPENDENCIES = async (): Promise<string[]> => [];
|
|
13
|
+
|
|
14
|
+
setPluginDependencyReader(NO_DEPENDENCIES);
|
|
@@ -780,3 +780,148 @@ describe("applyPlugins — read-back against beforeVersions", () => {
|
|
|
780
780
|
expect(reasons.lines(["other@magus"])).toBe("other@magus");
|
|
781
781
|
});
|
|
782
782
|
});
|
|
783
|
+
|
|
784
|
+
// ── B4: installed must mean LOADED ──────────────────────────────────────────
|
|
785
|
+
//
|
|
786
|
+
// Claude Code's loader takes the FIRST registry row relevant to the repository,
|
|
787
|
+
// and rows from this repository's other checkouts (deleted worktrees included)
|
|
788
|
+
// often precede this project's own. `claude plugin update` writes only this
|
|
789
|
+
// project's row, so an update could show "updated" and load the old version at
|
|
790
|
+
// once. The same operation now runs the stale-load repair as its last step.
|
|
791
|
+
|
|
792
|
+
describe("B4: an install or update ends with the loader on the new version", () => {
|
|
793
|
+
/** `loaded` is what the loader resolves until the repair has run. */
|
|
794
|
+
function loaderDeps(loaded: string | null, repairOk = true) {
|
|
795
|
+
const stub = stubDeps("2.0.0", (pluginId, target) =>
|
|
796
|
+
repairOk
|
|
797
|
+
? {
|
|
798
|
+
pluginId,
|
|
799
|
+
target,
|
|
800
|
+
rewritten: 3,
|
|
801
|
+
backup: "/tmp/installed_plugins.json.backup.1",
|
|
802
|
+
loaded: target,
|
|
803
|
+
ok: true,
|
|
804
|
+
}
|
|
805
|
+
: {
|
|
806
|
+
pluginId,
|
|
807
|
+
target,
|
|
808
|
+
rewritten: 0,
|
|
809
|
+
backup: null,
|
|
810
|
+
loaded,
|
|
811
|
+
ok: false,
|
|
812
|
+
error: `the loader still resolves v${loaded} here, not v${target}`,
|
|
813
|
+
},
|
|
814
|
+
);
|
|
815
|
+
stub.deps.loadedVersion = mock(async () => loaded);
|
|
816
|
+
return stub;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
test("B4: an update the loader does not pick up moves the older records forward and says so", async () => {
|
|
820
|
+
const { deps, rowsRepaired } = loaderDeps("1.0.0");
|
|
821
|
+
const { reporter, rows } = recordingReporter();
|
|
822
|
+
|
|
823
|
+
const result = await applyPlugins([item({})], PROJECT, deps, reporter);
|
|
824
|
+
|
|
825
|
+
expect(rowsRepaired).toEqual([
|
|
826
|
+
{ id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
|
|
827
|
+
]);
|
|
828
|
+
expect(result.ok).toBe(1);
|
|
829
|
+
expect(result.failed).toEqual([]);
|
|
830
|
+
expect(rows).toEqual([
|
|
831
|
+
{
|
|
832
|
+
ok: true,
|
|
833
|
+
name: "dev@magus",
|
|
834
|
+
detail:
|
|
835
|
+
"project → 2.0.0 · Claude Code now loads v2.0.0 here (3 older records moved forward)",
|
|
836
|
+
},
|
|
837
|
+
]);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("B4: a loader already on the landed version runs no repair", async () => {
|
|
841
|
+
const { deps, rowsRepaired } = loaderDeps("2.0.0");
|
|
842
|
+
const { reporter, rows } = recordingReporter();
|
|
843
|
+
|
|
844
|
+
const result = await applyPlugins([item({})], PROJECT, deps, reporter);
|
|
845
|
+
|
|
846
|
+
expect(rowsRepaired).toEqual([]);
|
|
847
|
+
expect(result.ok).toBe(1);
|
|
848
|
+
expect(rows[0]?.detail).toBe("project → 2.0.0");
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("B4: a repair that cannot move the loader makes the update a failure, never 'updated'", async () => {
|
|
852
|
+
const { deps } = loaderDeps("1.0.0", false);
|
|
853
|
+
const { reporter, rows } = recordingReporter();
|
|
854
|
+
|
|
855
|
+
const result = await applyPlugins([item({})], PROJECT, deps, reporter);
|
|
856
|
+
|
|
857
|
+
expect(result.ok).toBe(0);
|
|
858
|
+
expect(result.failed).toEqual(["dev@magus"]);
|
|
859
|
+
expect(rows).toEqual([
|
|
860
|
+
{
|
|
861
|
+
ok: false,
|
|
862
|
+
name: "dev@magus",
|
|
863
|
+
detail:
|
|
864
|
+
"installed v2.0.0, but Claude Code still loads v1.0.0 here — the loader still resolves v1.0.0 here, not v2.0.0",
|
|
865
|
+
},
|
|
866
|
+
]);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("B4: an install whose version did not move still brings the loader forward, and is not 'no change'", async () => {
|
|
870
|
+
// The project row already held 2.0.0; the older row ahead of it is what
|
|
871
|
+
// loaded. Reporting "no change" would leave the plugin loading 1.0.0.
|
|
872
|
+
const { deps, rowsRepaired } = loaderDeps("1.0.0");
|
|
873
|
+
|
|
874
|
+
const result = await applyPlugins(
|
|
875
|
+
[item({ beforeVersions: { project: "2.0.0" } })],
|
|
876
|
+
PROJECT,
|
|
877
|
+
deps,
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
expect(rowsRepaired).toHaveLength(1);
|
|
881
|
+
expect(result.unchanged).toEqual([]);
|
|
882
|
+
expect(result.ok).toBe(1);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("B4: the TUI's per-scope install and update helpers repair at project scope", async () => {
|
|
886
|
+
const a = loaderDeps("1.0.0");
|
|
887
|
+
expect(
|
|
888
|
+
await updatePluginInScope("dev@magus", "project", PROJECT, a.deps),
|
|
889
|
+
).toBe("2.0.0");
|
|
890
|
+
expect(a.rowsRepaired).toEqual([
|
|
891
|
+
{ id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
|
|
892
|
+
]);
|
|
893
|
+
|
|
894
|
+
const b = loaderDeps("1.0.0");
|
|
895
|
+
await installPluginInScope("dev@magus", "project", PROJECT, b.deps);
|
|
896
|
+
expect(b.rowsRepaired).toHaveLength(1);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("B4: a per-scope helper whose repair fails throws, so the TUI reports a failure", async () => {
|
|
900
|
+
const { deps } = loaderDeps("1.0.0", false);
|
|
901
|
+
await expect(
|
|
902
|
+
updatePluginInScope("dev@magus", "project", PROJECT, deps),
|
|
903
|
+
).rejects.toThrow(
|
|
904
|
+
"installed v2.0.0, but Claude Code still loads v1.0.0 here",
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("B4: the per-scope helpers bring the loader forward at user and local scope too", async () => {
|
|
909
|
+
for (const scope of ["user", "local"] as const) {
|
|
910
|
+
const up = loaderDeps("1.0.0");
|
|
911
|
+
await updatePluginInScope("dev@magus", scope, PROJECT, up.deps);
|
|
912
|
+
expect(up.rowsRepaired).toEqual([
|
|
913
|
+
{ id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
|
|
914
|
+
]);
|
|
915
|
+
const fresh = loaderDeps("1.0.0");
|
|
916
|
+
await installPluginInScope("dev@magus", scope, PROJECT, fresh.deps);
|
|
917
|
+
expect(fresh.rowsRepaired).toHaveLength(1);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("B4: a machine-wide winner the repair refuses fails the user-scope update, naming why", async () => {
|
|
922
|
+
const { deps } = loaderDeps("1.0.0", false);
|
|
923
|
+
await expect(
|
|
924
|
+
updatePluginInScope("dev@magus", "user", PROJECT, deps),
|
|
925
|
+
).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
|
|
926
|
+
});
|
|
927
|
+
});
|