magus-cli 7.12.2 → 7.14.0
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 +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- package/src/utils/display-path.ts +33 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
EDITED,
|
|
12
|
+
ID,
|
|
13
|
+
MarketFixture,
|
|
14
|
+
SKILL,
|
|
15
|
+
SKILL_REL,
|
|
16
|
+
expectPublished,
|
|
17
|
+
sourceModule,
|
|
18
|
+
write,
|
|
19
|
+
writeJson,
|
|
20
|
+
} from "./market-fixture.js";
|
|
21
|
+
|
|
22
|
+
let f: MarketFixture | undefined;
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
f?.close();
|
|
25
|
+
f = undefined;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("TEST-27 REQ-21 source inventory exclusions list every reason and never publish secret bytes", () => {
|
|
29
|
+
f = new MarketFixture();
|
|
30
|
+
const repo = f.seed({
|
|
31
|
+
extras: { ".gitignore": "plugins/review-kit/skills/review/dest.tmp\n" },
|
|
32
|
+
});
|
|
33
|
+
f.register(repo);
|
|
34
|
+
const folder = f.folder();
|
|
35
|
+
write(join(folder, "SKILL.md"), EDITED);
|
|
36
|
+
write(join(folder, ".env"), "PRIVATE_TOKEN=never-publish\n");
|
|
37
|
+
write(
|
|
38
|
+
join(folder, "node_modules", "pkg", "secret.txt"),
|
|
39
|
+
"never publish dependency\n",
|
|
40
|
+
);
|
|
41
|
+
write(join(folder, "ignored.txt"), "never publish ignored content\n");
|
|
42
|
+
write(join(folder, "dest.tmp"), "never publish destination ignored\n");
|
|
43
|
+
write(join(folder, "references", "guide.md"), "Publish this guide\n");
|
|
44
|
+
write(join(f.project, ".gitignore"), ".claude/skills/review/ignored.txt\n");
|
|
45
|
+
const outside = join(f.root, "outside.txt");
|
|
46
|
+
write(outside, "outside secret\n");
|
|
47
|
+
symlinkSync(outside, join(folder, "link.txt"));
|
|
48
|
+
const before = f.snapshot();
|
|
49
|
+
|
|
50
|
+
const sets = f.value<{
|
|
51
|
+
published: Array<{ path: string }>;
|
|
52
|
+
notPublished: Array<{ path: string; reason: string }>;
|
|
53
|
+
}>(
|
|
54
|
+
`const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify(await m.readSourceSets(${JSON.stringify(folder)},${JSON.stringify(f.clone(repo))},'plugins/review-kit/skills/review')));`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(sets.published.map((file) => file.path).sort()).toEqual([
|
|
58
|
+
"SKILL.md",
|
|
59
|
+
"references/guide.md",
|
|
60
|
+
]);
|
|
61
|
+
expect(
|
|
62
|
+
sets.notPublished.sort((a, b) => a.path.localeCompare(b.path)),
|
|
63
|
+
).toEqual(
|
|
64
|
+
[
|
|
65
|
+
{ path: ".env", reason: "dotfile" },
|
|
66
|
+
{ path: "dest.tmp", reason: "ignored-by-marketplace" },
|
|
67
|
+
{ path: "ignored.txt", reason: "ignored-here" },
|
|
68
|
+
{ path: "link.txt", reason: "symlink" },
|
|
69
|
+
{ path: "node_modules/pkg/secret.txt", reason: "node_modules" },
|
|
70
|
+
].sort((a, b) => a.path.localeCompare(b.path)),
|
|
71
|
+
);
|
|
72
|
+
expect(f.snapshot()).toEqual(before);
|
|
73
|
+
expect(readFileSync(outside, "utf8")).toBe("outside secret\n");
|
|
74
|
+
}, 30_000);
|
|
75
|
+
|
|
76
|
+
test("TEST-27 REQ-21 an ignored skill root bypasses source ignore rules, not shape or destination exclusions", () => {
|
|
77
|
+
f = new MarketFixture();
|
|
78
|
+
const repo = f.seed({ extras: { ".gitignore": "*.tmp\n" } });
|
|
79
|
+
f.register(repo);
|
|
80
|
+
write(join(f.project, ".gitignore"), ".claude/skills/review/\n*.secret\n");
|
|
81
|
+
write(join(f.folder(), "SKILL.md"), EDITED);
|
|
82
|
+
write(
|
|
83
|
+
join(f.folder(), "notes.secret"),
|
|
84
|
+
"Included because the root is ignored\n",
|
|
85
|
+
);
|
|
86
|
+
write(join(f.folder(), ".env"), "Still excluded\n");
|
|
87
|
+
write(join(f.folder(), "dest.tmp"), "Still excluded by marketplace\n");
|
|
88
|
+
|
|
89
|
+
const sets = f.value<{
|
|
90
|
+
untrackedFolder: boolean;
|
|
91
|
+
published: Array<{ path: string }>;
|
|
92
|
+
notPublished: Array<{ path: string; reason: string }>;
|
|
93
|
+
}>(
|
|
94
|
+
`const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify(await m.readSourceSets(${JSON.stringify(f.folder())},${JSON.stringify(f.clone(repo))},'plugins/review-kit/skills/review')));`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(sets.untrackedFolder).toBe(true);
|
|
98
|
+
expect(sets.published.map((file) => file.path).sort()).toEqual([
|
|
99
|
+
"SKILL.md",
|
|
100
|
+
"notes.secret",
|
|
101
|
+
]);
|
|
102
|
+
expect(sets.notPublished).toContainEqual({ path: ".env", reason: "dotfile" });
|
|
103
|
+
expect(sets.notPublished).toContainEqual({
|
|
104
|
+
path: "dest.tmp",
|
|
105
|
+
reason: "ignored-by-marketplace",
|
|
106
|
+
});
|
|
107
|
+
}, 30_000);
|
|
108
|
+
|
|
109
|
+
test("TEST-27 REQ-30 excluded plugin files compare same, survive publish, and an exact full copy is removable", () => {
|
|
110
|
+
f = new MarketFixture();
|
|
111
|
+
const repo = f.seed({
|
|
112
|
+
extras: {
|
|
113
|
+
"plugins/review-kit/skills/review/.gitkeep": "",
|
|
114
|
+
"plugins/review-kit/skills/review/ignored.txt":
|
|
115
|
+
"Plugin content ignored by the source project\n",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
f.register(repo);
|
|
119
|
+
f.install();
|
|
120
|
+
write(join(f.project, ".gitignore"), ".claude/skills/review/ignored.txt\n");
|
|
121
|
+
f.edit();
|
|
122
|
+
|
|
123
|
+
const status = f.market(["status", "review"]);
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
status.result.skills?.find((row) => row.skill === "review")?.state,
|
|
127
|
+
).toBe("same");
|
|
128
|
+
write(join(f.folder(), "SKILL.md"), EDITED);
|
|
129
|
+
const r = f.market(["publish", "review", "--plugin", ID, "--yes"]);
|
|
130
|
+
expect(r.code).toBe(0);
|
|
131
|
+
expect(
|
|
132
|
+
r.result.files?.map(({ path, change }) => ({
|
|
133
|
+
path: path === SKILL_REL ? "SKILL.md" : path,
|
|
134
|
+
change,
|
|
135
|
+
})),
|
|
136
|
+
).toEqual([{ path: "SKILL.md", change: "modified" }]);
|
|
137
|
+
expect(f.remotePaths(repo)).toContain(
|
|
138
|
+
"plugins/review-kit/skills/review/.gitkeep",
|
|
139
|
+
);
|
|
140
|
+
expect(
|
|
141
|
+
f.remoteFile(repo, "plugins/review-kit/skills/review/ignored.txt"),
|
|
142
|
+
).toContain("Plugin content");
|
|
143
|
+
expect(r.result.localCopy?.action).toBe("removed");
|
|
144
|
+
expect(existsSync(f.folder())).toBe(false);
|
|
145
|
+
f.assertOwned();
|
|
146
|
+
}, 60_000);
|
|
147
|
+
|
|
148
|
+
test.each([
|
|
149
|
+
{ file: ".env", reason: "dotfile" },
|
|
150
|
+
{ file: "dest.tmp", reason: "ignored-by-marketplace" },
|
|
151
|
+
{ file: "ignored.txt", reason: "ignored-here" },
|
|
152
|
+
])(
|
|
153
|
+
"TEST-27 REQ-15 unpublished $file is kept after a successful push",
|
|
154
|
+
async ({ file, reason }) => {
|
|
155
|
+
f = new MarketFixture();
|
|
156
|
+
const repo = f.seed({ extras: { ".gitignore": "*.tmp\n" } });
|
|
157
|
+
f.register(repo);
|
|
158
|
+
f.install();
|
|
159
|
+
f.edit();
|
|
160
|
+
write(join(f.folder(), "SKILL.md"), EDITED);
|
|
161
|
+
write(join(f.folder(), file), "Not published; must survive\n");
|
|
162
|
+
if (reason === "ignored-here")
|
|
163
|
+
write(
|
|
164
|
+
join(f.project, ".gitignore"),
|
|
165
|
+
".claude/skills/review/ignored.txt\n",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const r = f.market(["publish", "review", "--plugin", ID, "--yes"]);
|
|
169
|
+
|
|
170
|
+
expect(r.code).toBe(0);
|
|
171
|
+
expectPublished(f, repo);
|
|
172
|
+
expect(r.result.notPublished).toContainEqual({ path: file, reason });
|
|
173
|
+
expect(r.result.localCopy?.action).toBe("kept");
|
|
174
|
+
expect(readFileSync(join(f.folder(), file), "utf8")).toBe(
|
|
175
|
+
"Not published; must survive\n",
|
|
176
|
+
);
|
|
177
|
+
expect(f.remotePaths(repo)).not.toContain(
|
|
178
|
+
`plugins/review-kit/skills/review/${file}`,
|
|
179
|
+
);
|
|
180
|
+
f.assertOwned();
|
|
181
|
+
},
|
|
182
|
+
60_000,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
test.each([
|
|
186
|
+
"not-installed",
|
|
187
|
+
"keep-local",
|
|
188
|
+
"profile-managed",
|
|
189
|
+
"custom-profile-folder",
|
|
190
|
+
"local-off",
|
|
191
|
+
"global-project-only",
|
|
192
|
+
] as const)(
|
|
193
|
+
"TEST-30 REQ-15 removes only a reachable exact active copy: %s",
|
|
194
|
+
(kind) => {
|
|
195
|
+
f = new MarketFixture();
|
|
196
|
+
const repo = f.seed();
|
|
197
|
+
f.register(repo);
|
|
198
|
+
if (kind !== "not-installed")
|
|
199
|
+
f.install(ID, kind === "global-project-only" ? "project" : "user");
|
|
200
|
+
const global = kind === "global-project-only";
|
|
201
|
+
f.edit(global ? ["--to", "global"] : []);
|
|
202
|
+
const folder = f.folder(global ? "global" : "project");
|
|
203
|
+
write(join(folder, "SKILL.md"), EDITED);
|
|
204
|
+
if (kind === "profile-managed" || kind === "custom-profile-folder") {
|
|
205
|
+
f.profile();
|
|
206
|
+
f.active(kind === "profile-managed" ? { review: "a".repeat(64) } : {});
|
|
207
|
+
}
|
|
208
|
+
if (kind === "local-off")
|
|
209
|
+
writeJson(join(f.project, ".claude", "settings.local.json"), {
|
|
210
|
+
enabledPlugins: { [ID]: false },
|
|
211
|
+
});
|
|
212
|
+
f.rememberOwned();
|
|
213
|
+
|
|
214
|
+
const r = f.market([
|
|
215
|
+
"publish",
|
|
216
|
+
"review",
|
|
217
|
+
"--plugin",
|
|
218
|
+
ID,
|
|
219
|
+
"--yes",
|
|
220
|
+
...(global ? ["--from", "global"] : []),
|
|
221
|
+
...(kind === "keep-local" ? ["--keep-local"] : []),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
expect(r.code).toBe(0);
|
|
225
|
+
const removed = kind === "custom-profile-folder";
|
|
226
|
+
expect(r.result.localCopy?.action).toBe(removed ? "removed" : "kept");
|
|
227
|
+
expect(existsSync(folder)).toBe(!removed);
|
|
228
|
+
expectPublished(f, repo);
|
|
229
|
+
if (!removed) expect(typeof r.result.localCopy?.reason).toBe("string");
|
|
230
|
+
f.assertOwned();
|
|
231
|
+
},
|
|
232
|
+
60_000,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
test.each([
|
|
236
|
+
{ user: true, project: true, local: false, expected: "local" },
|
|
237
|
+
{ user: true, project: false, local: true, expected: null },
|
|
238
|
+
{ user: true, project: false, local: undefined, expected: "project" },
|
|
239
|
+
{ user: false, project: undefined, local: undefined, expected: "user" },
|
|
240
|
+
{ user: undefined, project: undefined, local: undefined, expected: null },
|
|
241
|
+
])(
|
|
242
|
+
"TEST-30 REQ-15 activation precedence is LOCAL over PROJECT over USER: %j",
|
|
243
|
+
(values) => {
|
|
244
|
+
f = new MarketFixture();
|
|
245
|
+
for (const [scope, file] of [
|
|
246
|
+
["user", join(f.config, "settings.json")],
|
|
247
|
+
["project", join(f.project, ".claude", "settings.json")],
|
|
248
|
+
["local", join(f.project, ".claude", "settings.local.json")],
|
|
249
|
+
] as const)
|
|
250
|
+
if (values[scope] !== undefined)
|
|
251
|
+
writeJson(file, { enabledPlugins: { [ID]: values[scope] } });
|
|
252
|
+
|
|
253
|
+
const off = f.value(
|
|
254
|
+
`const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify(await m.turnedOffHere(${JSON.stringify(ID)},${JSON.stringify(f.project)})));`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(off).toBe(values.expected);
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
test.each(["new-file", "changed-file", "turned-off"] as const)(
|
|
262
|
+
"TEST-49 REQ-15 source or activation changed at CLI completion remains kept: %s",
|
|
263
|
+
async (kind) => {
|
|
264
|
+
f = new MarketFixture();
|
|
265
|
+
const repo = await f.prepared();
|
|
266
|
+
const file = join(
|
|
267
|
+
f.folder(),
|
|
268
|
+
kind === "new-file" ? "late.txt" : "SKILL.md",
|
|
269
|
+
);
|
|
270
|
+
f.setModes(
|
|
271
|
+
kind === "turned-off"
|
|
272
|
+
? { lateOff: "local" }
|
|
273
|
+
: { lateFile: { path: file, content: "Late content must survive\n" } },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const r = f.market(["publish", "review", "--plugin", ID, "--yes"]);
|
|
277
|
+
|
|
278
|
+
expect(r.code).toBe(0);
|
|
279
|
+
expectPublished(f, repo);
|
|
280
|
+
expect(r.result.localCopy?.action).toBe("kept");
|
|
281
|
+
expect(existsSync(f.folder())).toBe(true);
|
|
282
|
+
if (kind !== "turned-off")
|
|
283
|
+
expect(readFileSync(file, "utf8")).toBe("Late content must survive\n");
|
|
284
|
+
expect(typeof r.result.localCopy?.reason).toBe("string");
|
|
285
|
+
f.assertOwned();
|
|
286
|
+
},
|
|
287
|
+
60_000,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
test.each(["plugin", "skill", "manifest"] as const)(
|
|
291
|
+
"TEST-44 REQ-21 refuses a symlink at the %s write boundary",
|
|
292
|
+
(boundary) => {
|
|
293
|
+
f = new MarketFixture();
|
|
294
|
+
const repo = f.seed();
|
|
295
|
+
const outside = join(f.root, "outside");
|
|
296
|
+
mkdirSync(outside);
|
|
297
|
+
write(join(outside, "sentinel.txt"), "Do not alter\n");
|
|
298
|
+
let args: string[];
|
|
299
|
+
if (boundary === "manifest") {
|
|
300
|
+
const manifestText = readFileSync(
|
|
301
|
+
join(repo.author, ".claude-plugin", "marketplace.json"),
|
|
302
|
+
"utf8",
|
|
303
|
+
);
|
|
304
|
+
rmSync(join(repo.author, ".claude-plugin"), { recursive: true });
|
|
305
|
+
write(join(outside, "marketplace.json"), manifestText);
|
|
306
|
+
symlinkSync(outside, join(repo.author, ".claude-plugin"));
|
|
307
|
+
args = ["plugin", "create", "unsafe@acme", "--yes"];
|
|
308
|
+
} else {
|
|
309
|
+
const rel =
|
|
310
|
+
boundary === "plugin"
|
|
311
|
+
? "plugins/review-kit"
|
|
312
|
+
: "plugins/review-kit/skills/review";
|
|
313
|
+
rmSync(join(repo.author, rel), { recursive: true });
|
|
314
|
+
if (boundary === "plugin")
|
|
315
|
+
writeJson(join(outside, ".claude-plugin", "plugin.json"), {
|
|
316
|
+
name: "review-kit",
|
|
317
|
+
version: "0.3.1",
|
|
318
|
+
});
|
|
319
|
+
else write(join(outside, "SKILL.md"), SKILL);
|
|
320
|
+
symlinkSync(outside, join(repo.author, rel));
|
|
321
|
+
args =
|
|
322
|
+
boundary === "plugin"
|
|
323
|
+
? ["plugin", "delete", ID, "--yes"]
|
|
324
|
+
: ["skill", "remove", "review", "--plugin", ID, "--yes"];
|
|
325
|
+
}
|
|
326
|
+
f.push(repo);
|
|
327
|
+
f.register(repo);
|
|
328
|
+
const head = f.head(repo);
|
|
329
|
+
const sentinel = readFileSync(join(outside, "sentinel.txt"), "utf8");
|
|
330
|
+
|
|
331
|
+
const r = f.market(args);
|
|
332
|
+
|
|
333
|
+
// Refuse at ownership lookup (usage) or at the link write guard.
|
|
334
|
+
if (boundary === "skill") expect([1, 2]).toContain(r.code);
|
|
335
|
+
else expect(r.code).toBe(1);
|
|
336
|
+
expect(f.head(repo)).toBe(head);
|
|
337
|
+
expect(readFileSync(join(outside, "sentinel.txt"), "utf8")).toBe(sentinel);
|
|
338
|
+
f.assertOwned();
|
|
339
|
+
},
|
|
340
|
+
60_000,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
test("TEST-44 REQ-21 skill edit refuses a linked destination parent", () => {
|
|
344
|
+
f = new MarketFixture();
|
|
345
|
+
const repo = f.seed();
|
|
346
|
+
f.register(repo);
|
|
347
|
+
const outside = join(f.root, "outside");
|
|
348
|
+
mkdirSync(outside);
|
|
349
|
+
mkdirSync(join(f.project, ".claude"), { recursive: true });
|
|
350
|
+
symlinkSync(outside, join(f.project, ".claude", "skills"));
|
|
351
|
+
|
|
352
|
+
const r = f.market(["skill", "edit", "review", "--plugin", ID, "--yes"]);
|
|
353
|
+
|
|
354
|
+
expect(r.code).toBe(1);
|
|
355
|
+
expect(existsSync(join(outside, "review"))).toBe(false);
|
|
356
|
+
f.assertOwned();
|
|
357
|
+
}, 30_000);
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ID,
|
|
6
|
+
MANIFEST,
|
|
7
|
+
MarketFixture,
|
|
8
|
+
PLUGIN_MANIFEST,
|
|
9
|
+
type Repo,
|
|
10
|
+
readJson,
|
|
11
|
+
sourceModule,
|
|
12
|
+
writeJson,
|
|
13
|
+
} from "./market-fixture.js";
|
|
14
|
+
|
|
15
|
+
const machines: MarketFixture[] = [];
|
|
16
|
+
function machine(): MarketFixture {
|
|
17
|
+
const f = new MarketFixture();
|
|
18
|
+
machines.push(f);
|
|
19
|
+
return f;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const f of machines.splice(0).reverse()) f.close();
|
|
23
|
+
});
|
|
24
|
+
function shareRemote(from: Repo, to: MarketFixture): void {
|
|
25
|
+
const link = join(to.root, "remotes", `${from.slug}.git`);
|
|
26
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
27
|
+
symlinkSync(from.bare, link);
|
|
28
|
+
}
|
|
29
|
+
function registration(f: MarketFixture, marketplaces: Record<string, unknown>) {
|
|
30
|
+
return f.value<{
|
|
31
|
+
added: unknown[];
|
|
32
|
+
trusted: string[];
|
|
33
|
+
conflicts: Array<{ name: string; message: string; fix?: string }>;
|
|
34
|
+
failed: unknown[];
|
|
35
|
+
}>(
|
|
36
|
+
`const m=await import(${JSON.stringify(sourceModule("services/marketplace-sync.js"))}); console.log(JSON.stringify(await m.registerClosureMarketplaces(${JSON.stringify(marketplaces)},${JSON.stringify(f.project)})));`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
function conflicts(f: MarketFixture, marketplaces: Record<string, unknown>) {
|
|
40
|
+
return f.value<Array<{ name: string; message: string; fix?: string }>>(
|
|
41
|
+
`const m=await import(${JSON.stringify(sourceModule("services/marketplace-sync.js"))}); console.log(JSON.stringify(await m.findMarketplaceConflicts(${JSON.stringify(marketplaces)},${JSON.stringify(f.project)})));`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
function addPlugin(f: MarketFixture, repo: Repo, name: string) {
|
|
45
|
+
const manifest = readJson(join(repo.author, MANIFEST));
|
|
46
|
+
manifest.plugins.push({
|
|
47
|
+
name,
|
|
48
|
+
source: `./plugins/${name}`,
|
|
49
|
+
version: "0.1.0",
|
|
50
|
+
description: `${name} tools`,
|
|
51
|
+
});
|
|
52
|
+
writeJson(join(repo.author, MANIFEST), manifest);
|
|
53
|
+
writeJson(
|
|
54
|
+
join(repo.author, "plugins", name, ".claude-plugin", "plugin.json"),
|
|
55
|
+
{ name, version: "0.1.0", description: `${name} tools` },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("TEST-36 REQ-33 mismatched company source is a read-only conflict with no destructive remove fix", () => {
|
|
60
|
+
const f = machine();
|
|
61
|
+
const repo = f.seed({ slug: "different/market" });
|
|
62
|
+
f.register(repo);
|
|
63
|
+
const refs = { acme: { source: "github", repo: "expected/market" } };
|
|
64
|
+
const before = f.snapshot();
|
|
65
|
+
|
|
66
|
+
const readOnly = conflicts(f, refs);
|
|
67
|
+
const applied = registration(f, refs);
|
|
68
|
+
|
|
69
|
+
expect(readOnly.map((row) => row.name)).toEqual(["acme"]);
|
|
70
|
+
expect(applied.conflicts.map((row) => row.name)).toEqual(["acme"]);
|
|
71
|
+
expect(applied.added).toEqual([]);
|
|
72
|
+
expect(applied.trusted).toEqual([]);
|
|
73
|
+
for (const row of [...readOnly, ...applied.conflicts]) {
|
|
74
|
+
expect(row.message).toMatch(/nothing was changed/i);
|
|
75
|
+
expect(row.fix ?? "").not.toContain("market remove");
|
|
76
|
+
expect(row.fix ?? "").toMatch(/check/i);
|
|
77
|
+
}
|
|
78
|
+
expect(f.snapshot()).toEqual(before);
|
|
79
|
+
expect(f.journal()).toEqual([]);
|
|
80
|
+
f.assertOwned();
|
|
81
|
+
}, 30_000);
|
|
82
|
+
|
|
83
|
+
test.each(["fork", "path", "directory"] as const)(
|
|
84
|
+
"TEST-37 REQ-33 default magus registration from a %s is not a company conflict",
|
|
85
|
+
(source) => {
|
|
86
|
+
const f = machine();
|
|
87
|
+
const repo = f.seed({
|
|
88
|
+
name: "magus",
|
|
89
|
+
slug: "developer/magus-fork",
|
|
90
|
+
marker: false,
|
|
91
|
+
});
|
|
92
|
+
f.register(repo, { trust: false });
|
|
93
|
+
if (source === "path")
|
|
94
|
+
f.registration(repo, { source: "git", url: repo.author });
|
|
95
|
+
if (source === "directory")
|
|
96
|
+
f.registration(repo, { source: "directory", path: repo.author });
|
|
97
|
+
const refs = { magus: { source: "github", repo: "MadAppGang/magus" } };
|
|
98
|
+
const before = f.snapshot();
|
|
99
|
+
|
|
100
|
+
expect(conflicts(f, refs)).toEqual([]);
|
|
101
|
+
const r = registration(f, refs);
|
|
102
|
+
expect(r.conflicts).toEqual([]);
|
|
103
|
+
expect(r.added).toEqual([]);
|
|
104
|
+
expect(f.snapshot()).toEqual(before);
|
|
105
|
+
f.assertOwned();
|
|
106
|
+
},
|
|
107
|
+
30_000,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
test.each(["case", "relative-directory"] as const)(
|
|
111
|
+
"TEST-37 REQ-33 equivalent %s source trusts the company without changing its registration",
|
|
112
|
+
(spelling) => {
|
|
113
|
+
const f = machine();
|
|
114
|
+
const repo = f.seed();
|
|
115
|
+
f.register(repo, { trust: false });
|
|
116
|
+
let refs: Record<string, unknown>;
|
|
117
|
+
if (spelling === "case") {
|
|
118
|
+
f.registration(repo, { source: "github", repo: "Acme/Market" });
|
|
119
|
+
refs = { acme: { source: "github", repo: "acme/market" } };
|
|
120
|
+
} else {
|
|
121
|
+
f.registration(repo, { source: "directory", path: repo.author });
|
|
122
|
+
refs = {
|
|
123
|
+
acme: { source: "directory", path: relative(f.project, repo.author) },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(conflicts(f, refs)).toEqual([]);
|
|
128
|
+
const r = registration(f, refs);
|
|
129
|
+
expect(r.conflicts).toEqual([]);
|
|
130
|
+
expect(r.trusted).toEqual(["acme"]);
|
|
131
|
+
expect(registration(f, refs).trusted).toEqual([]);
|
|
132
|
+
f.assertOwned();
|
|
133
|
+
},
|
|
134
|
+
30_000,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
test.each([
|
|
138
|
+
{ args: ["update", "team", "--dry-run"], readonly: true, stop: false },
|
|
139
|
+
{ args: ["update", "team", "--check"], readonly: true, stop: false },
|
|
140
|
+
{ args: ["update", "team"], readonly: false, stop: false },
|
|
141
|
+
{ args: ["install", "team", "--yes"], readonly: false, stop: true },
|
|
142
|
+
])(
|
|
143
|
+
"TEST-36 REQ-26 wrong-source work stays out in $args",
|
|
144
|
+
({ args, readonly, stop }) => {
|
|
145
|
+
const f = machine();
|
|
146
|
+
const repo = f.seed({ slug: "different/market" });
|
|
147
|
+
f.register(repo);
|
|
148
|
+
f.install();
|
|
149
|
+
const manifest = readJson(join(repo.author, MANIFEST));
|
|
150
|
+
manifest.plugins[0].version = "0.3.2";
|
|
151
|
+
writeJson(join(repo.author, MANIFEST), manifest);
|
|
152
|
+
writeJson(join(repo.author, PLUGIN_MANIFEST), {
|
|
153
|
+
name: "review-kit",
|
|
154
|
+
version: "0.3.2",
|
|
155
|
+
});
|
|
156
|
+
f.push(repo);
|
|
157
|
+
f.git(f.clone(repo), "pull", "--ff-only");
|
|
158
|
+
f.profile(
|
|
159
|
+
{ [ID]: "latest" },
|
|
160
|
+
{ acme: { source: "github", repo: "expected/market" } },
|
|
161
|
+
);
|
|
162
|
+
if (!stop) f.active();
|
|
163
|
+
const profile = readFileSync(
|
|
164
|
+
join(f.project, ".claude", "profiles.json"),
|
|
165
|
+
"utf8",
|
|
166
|
+
);
|
|
167
|
+
const registry = readFileSync(
|
|
168
|
+
join(f.config, "plugins", "installed_plugins.json"),
|
|
169
|
+
"utf8",
|
|
170
|
+
);
|
|
171
|
+
const userSettings = readFileSync(join(f.config, "settings.json"), "utf8");
|
|
172
|
+
const before = f.snapshot();
|
|
173
|
+
|
|
174
|
+
const r = f.cli(args);
|
|
175
|
+
|
|
176
|
+
if (stop) expect(r.code).toBe(1);
|
|
177
|
+
expect(r.out + r.err).toContain("acme");
|
|
178
|
+
expect(r.out + r.err).toMatch(
|
|
179
|
+
/left out|conflict|different|not the|does not match/i,
|
|
180
|
+
);
|
|
181
|
+
expect(r.out + r.err).not.toContain("magus market remove");
|
|
182
|
+
expect(
|
|
183
|
+
readFileSync(join(f.config, "plugins", "installed_plugins.json"), "utf8"),
|
|
184
|
+
).toBe(registry);
|
|
185
|
+
expect(readFileSync(join(f.config, "settings.json"), "utf8")).toBe(
|
|
186
|
+
userSettings,
|
|
187
|
+
);
|
|
188
|
+
expect(
|
|
189
|
+
readFileSync(join(f.project, ".claude", "profiles.json"), "utf8"),
|
|
190
|
+
).toBe(profile);
|
|
191
|
+
expect(f.version()).toBe("0.3.1");
|
|
192
|
+
if (stop)
|
|
193
|
+
expect(
|
|
194
|
+
existsSync(join(f.project, ".claude", "profiles", "active.json")),
|
|
195
|
+
).toBe(false);
|
|
196
|
+
if (readonly) expect(f.snapshot()).toEqual(before);
|
|
197
|
+
},
|
|
198
|
+
60_000,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
test.each(["install", "update"] as const)(
|
|
202
|
+
"TEST-40 REQ-6 REQ-7 Save Profile on the author onboards a fresh teammate via %s",
|
|
203
|
+
(verb) => {
|
|
204
|
+
const author = machine();
|
|
205
|
+
const repo = author.seed({ dependencies: ["base-kit"] });
|
|
206
|
+
addPlugin(author, repo, "base-kit");
|
|
207
|
+
author.push(repo);
|
|
208
|
+
author.register(repo);
|
|
209
|
+
author.install(ID, "project");
|
|
210
|
+
const saved = author.value<Record<string, any>>(
|
|
211
|
+
`const p=await import(${JSON.stringify(sourceModule("services/profiles.js"))}); const fs=await import('node:fs'); await p.saveProfile('Team',{${JSON.stringify(ID)}:true},'project',${JSON.stringify(author.project)}); console.log(JSON.stringify(JSON.parse(fs.readFileSync(${JSON.stringify(join(author.project, ".claude", "profiles.json"))},'utf8'))));`,
|
|
212
|
+
);
|
|
213
|
+
expect(saved.profiles.team.marketplaces.acme).toEqual({
|
|
214
|
+
source: "github",
|
|
215
|
+
repo: repo.slug,
|
|
216
|
+
});
|
|
217
|
+
expect(saved.profiles.team.marketplaces.magus).toBeUndefined();
|
|
218
|
+
const mate = machine();
|
|
219
|
+
shareRemote(repo, mate);
|
|
220
|
+
writeJson(join(mate.project, ".claude", "profiles.json"), saved);
|
|
221
|
+
|
|
222
|
+
const r = mate.cli(
|
|
223
|
+
verb === "install" ? [verb, "team", "--yes"] : [verb, "team"],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(r.code).toBe(0);
|
|
227
|
+
expect(mate.version(ID, "project")).toBe("0.3.1");
|
|
228
|
+
expect(mate.version("base-kit@acme", "project")).toBe("0.1.0");
|
|
229
|
+
expect(mate.version(ID, "user")).toBeNull();
|
|
230
|
+
expect(
|
|
231
|
+
readJson(join(mate.config, "magus-market", "trusted.json")).marketplaces
|
|
232
|
+
.acme,
|
|
233
|
+
).toEqual({ source: repo.slug, via: "profile" });
|
|
234
|
+
const notices = (r.out + r.err).match(/company marketplace|trust/gi) ?? [];
|
|
235
|
+
expect(notices.length).toBeGreaterThan(0);
|
|
236
|
+
},
|
|
237
|
+
60_000,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
test.each(["github", "git"] as const)(
|
|
241
|
+
"TEST-41 REQ-7 company %s source refreshes inside the hour despite autoUpdate false and adds new dependency",
|
|
242
|
+
(source) => {
|
|
243
|
+
const author = machine();
|
|
244
|
+
const repo = author.seed({ dependencies: ["base-kit"] });
|
|
245
|
+
addPlugin(author, repo, "base-kit");
|
|
246
|
+
author.push(repo);
|
|
247
|
+
const mate = machine();
|
|
248
|
+
shareRemote(repo, mate);
|
|
249
|
+
mate.setModes({ autoUpdate: false });
|
|
250
|
+
mate.profile();
|
|
251
|
+
const first = mate.cli(["update", "team"]);
|
|
252
|
+
expect(first.code).toBe(0);
|
|
253
|
+
expect(mate.version(ID, "project")).toBe("0.3.1");
|
|
254
|
+
if (source === "git")
|
|
255
|
+
mate.registration(repo, { source: "git", url: repo.url });
|
|
256
|
+
const knownFile = join(mate.config, "plugins", "known_marketplaces.json");
|
|
257
|
+
const known = readJson(knownFile);
|
|
258
|
+
known.acme.autoUpdate = false;
|
|
259
|
+
writeJson(knownFile, known);
|
|
260
|
+
addPlugin(author, repo, "extra-kit");
|
|
261
|
+
const manifest = readJson(join(repo.author, MANIFEST));
|
|
262
|
+
manifest.plugins.find(
|
|
263
|
+
(entry: { name: string }) => entry.name === "review-kit",
|
|
264
|
+
).version = "0.3.2";
|
|
265
|
+
writeJson(join(repo.author, MANIFEST), manifest);
|
|
266
|
+
writeJson(join(repo.author, PLUGIN_MANIFEST), {
|
|
267
|
+
name: "review-kit",
|
|
268
|
+
version: "0.3.2",
|
|
269
|
+
dependencies: ["base-kit", "extra-kit"],
|
|
270
|
+
});
|
|
271
|
+
author.push(repo);
|
|
272
|
+
|
|
273
|
+
const second = mate.cli(["update", "team"]);
|
|
274
|
+
|
|
275
|
+
expect(second.code).toBe(0);
|
|
276
|
+
expect(mate.version(ID, "project")).toBe("0.3.2");
|
|
277
|
+
expect(mate.version("extra-kit@acme", "project")).toBe("0.1.0");
|
|
278
|
+
expect(mate.version("base-kit@acme", "project")).toBe("0.1.0");
|
|
279
|
+
},
|
|
280
|
+
60_000,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
test("TEST-42 REQ-26 an unreadable company marketplace does not prevent unrelated teammate plugins", () => {
|
|
284
|
+
const owner = machine();
|
|
285
|
+
const bad = owner.seed();
|
|
286
|
+
const good = owner.seed({ name: "safe", slug: "safe/market" });
|
|
287
|
+
const mate = machine();
|
|
288
|
+
shareRemote(bad, mate);
|
|
289
|
+
shareRemote(good, mate);
|
|
290
|
+
mate.setModes({ denyReadSlug: bad.slug });
|
|
291
|
+
mate.profile(
|
|
292
|
+
{ [ID]: "latest", "review-kit@safe": "latest" },
|
|
293
|
+
{
|
|
294
|
+
acme: { source: "github", repo: bad.slug },
|
|
295
|
+
safe: { source: "github", repo: good.slug },
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const r = mate.cli(["update", "team"]);
|
|
300
|
+
|
|
301
|
+
expect(r.out + r.err).toContain("ask its owner");
|
|
302
|
+
expect(mate.version("review-kit@safe", "project")).toBe("0.3.1");
|
|
303
|
+
expect(mate.version(ID, "project")).toBeNull();
|
|
304
|
+
}, 60_000);
|