magus-cli 7.13.0 → 7.14.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 +7 -4
- package/package.json +4 -4
- 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-router.test.ts +4 -4
- package/src/__tests__/scope-uninstall-wrong-scope.test.ts +341 -0
- package/src/cli/market-view.ts +59 -18
- package/src/cli/market.ts +128 -44
- package/src/cli/router.ts +42 -10
- package/src/data/marketplaces.ts +1 -0
- package/src/main.tsx +1 -1
- package/src/market-tui.tsx +1 -1
- package/src/services/claude-cli.ts +13 -0
- package/src/services/local-marketplace.ts +8 -0
- package/src/services/market-change.ts +6 -6
- package/src/services/market-local.ts +93 -0
- package/src/services/market-ops-plugin.ts +366 -47
- package/src/services/market-ops-publish.ts +363 -15
- package/src/services/market-ops-repo.ts +94 -13
- package/src/services/market-ops-skill.ts +93 -11
- package/src/services/market-ops.ts +72 -8
- package/src/services/market-publish.ts +10 -1
- package/src/services/market-types.ts +23 -0
- package/src/services/update-engine.ts +97 -1
- package/src/tui-runtime.tsx +2 -2
- package/src/types/index.ts +2 -0
- package/src/ui/adapters/marketAdapter.ts +123 -4
- package/src/ui/adapters/pluginsAdapter.ts +25 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/market/MarketplaceScreen.tsx +55 -24
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
- package/src/ui/market/MarketplacesScreen.tsx +120 -29
- package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
- package/src/ui/market/marketKeymap.ts +29 -2
- package/src/ui/market/useMarketState.ts +146 -5
- package/src/utils/display-path.ts +33 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { MarketResult } from "../services/market-types.js";
|
|
5
|
+
import {
|
|
6
|
+
EDITED,
|
|
7
|
+
ID,
|
|
8
|
+
MANIFEST,
|
|
9
|
+
MarketFixture,
|
|
10
|
+
PLUGIN,
|
|
11
|
+
PLUGIN_MANIFEST,
|
|
12
|
+
REMOTE_EDIT,
|
|
13
|
+
type Repo,
|
|
14
|
+
SKILL,
|
|
15
|
+
SKILL_REL,
|
|
16
|
+
write,
|
|
17
|
+
} from "./market-fixture.js";
|
|
18
|
+
|
|
19
|
+
const LINT =
|
|
20
|
+
"---\nname: lint\ndescription: Checks code style.\n---\nOriginal lint rules.\n";
|
|
21
|
+
const LINT_EDITED = `${LINT}\nCheck imports too.\n`;
|
|
22
|
+
const STEADY = "---\nname: steady\n---\nStable instructions.\n";
|
|
23
|
+
const LINT_REL = "plugins/review-kit/skills/lint/SKILL.md";
|
|
24
|
+
const STEADY_REL = "plugins/review-kit/skills/steady/SKILL.md";
|
|
25
|
+
|
|
26
|
+
let f: MarketFixture | undefined;
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
f?.close();
|
|
29
|
+
f = undefined;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function seedSkills(world: MarketFixture, install = true): Repo {
|
|
33
|
+
const repo = world.seed({
|
|
34
|
+
extras: { [LINT_REL]: LINT, [STEADY_REL]: STEADY },
|
|
35
|
+
});
|
|
36
|
+
world.register(repo);
|
|
37
|
+
if (install) world.install();
|
|
38
|
+
return repo;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function editSkill(
|
|
42
|
+
world: MarketFixture,
|
|
43
|
+
skill: string,
|
|
44
|
+
bytes?: string,
|
|
45
|
+
to: "project" | "global" = "project",
|
|
46
|
+
): void {
|
|
47
|
+
world.ok(["skill", "edit", skill, "--plugin", ID, "--to", to, "--yes"]);
|
|
48
|
+
if (bytes !== undefined)
|
|
49
|
+
write(join(world.folder(to, skill), "SKILL.md"), bytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertForceGuidance(result: MarketResult, names: string[]): void {
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
expect(
|
|
55
|
+
result.plan?.some(
|
|
56
|
+
(line) =>
|
|
57
|
+
line.includes(`magus market publish ${name}`) &&
|
|
58
|
+
line.includes("--force"),
|
|
59
|
+
),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertOneCommit(
|
|
65
|
+
world: MarketFixture,
|
|
66
|
+
repo: Repo,
|
|
67
|
+
old: string,
|
|
68
|
+
expectedPaths: string[],
|
|
69
|
+
): void {
|
|
70
|
+
expect(
|
|
71
|
+
world.git(
|
|
72
|
+
world.root,
|
|
73
|
+
"--git-dir",
|
|
74
|
+
repo.bare,
|
|
75
|
+
"rev-list",
|
|
76
|
+
"--count",
|
|
77
|
+
`${old}..HEAD`,
|
|
78
|
+
),
|
|
79
|
+
).toBe("1");
|
|
80
|
+
expect(
|
|
81
|
+
world
|
|
82
|
+
.git(
|
|
83
|
+
world.root,
|
|
84
|
+
"--git-dir",
|
|
85
|
+
repo.bare,
|
|
86
|
+
"diff",
|
|
87
|
+
"--name-only",
|
|
88
|
+
old,
|
|
89
|
+
"HEAD",
|
|
90
|
+
)
|
|
91
|
+
.split("\n")
|
|
92
|
+
.sort(),
|
|
93
|
+
).toEqual([...expectedPaths].sort());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("R3.2 R4.5 TEST-21 approved plugin publish sends both edits in one patch commit and settles each copy", () => {
|
|
97
|
+
f = new MarketFixture();
|
|
98
|
+
const repo = seedSkills(f);
|
|
99
|
+
editSkill(f, "review", EDITED);
|
|
100
|
+
editSkill(f, "lint", LINT_EDITED);
|
|
101
|
+
editSkill(f, "steady");
|
|
102
|
+
const old = f.head(repo);
|
|
103
|
+
const before = f.snapshot();
|
|
104
|
+
|
|
105
|
+
const plan = f.market(["publish", "--plugin", ID]);
|
|
106
|
+
|
|
107
|
+
expect(plan.code).toBe(3);
|
|
108
|
+
expect(plan.result.outcome).toBe("needs-consent");
|
|
109
|
+
expect(plan.result.version).toEqual({ from: "0.3.1", to: "0.3.2" });
|
|
110
|
+
expect(plan.result.plan?.join("\n")).toContain("review");
|
|
111
|
+
expect(plan.result.plan?.join("\n")).toContain("lint");
|
|
112
|
+
expect(typeof plan.result.planId).toBe("string");
|
|
113
|
+
expect(f.head(repo)).toBe(old);
|
|
114
|
+
expect(f.snapshot()).toEqual(before);
|
|
115
|
+
|
|
116
|
+
const applied = f.market([
|
|
117
|
+
"publish",
|
|
118
|
+
"--plugin",
|
|
119
|
+
ID,
|
|
120
|
+
`--yes=${plan.result.planId}`,
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
expect({ exit: applied.code, outcome: applied.result.outcome }).toEqual({
|
|
124
|
+
exit: 0,
|
|
125
|
+
outcome: "ok",
|
|
126
|
+
});
|
|
127
|
+
assertOneCommit(f, repo, old, [
|
|
128
|
+
MANIFEST,
|
|
129
|
+
PLUGIN_MANIFEST,
|
|
130
|
+
SKILL_REL,
|
|
131
|
+
LINT_REL,
|
|
132
|
+
]);
|
|
133
|
+
expect(applied.result.commit).toBe(f.head(repo));
|
|
134
|
+
expect(f.remoteBytes(repo, SKILL_REL)).toBe(EDITED);
|
|
135
|
+
expect(f.remoteBytes(repo, LINT_REL)).toBe(LINT_EDITED);
|
|
136
|
+
expect(f.remoteBytes(repo, STEADY_REL)).toBe(STEADY);
|
|
137
|
+
expect(
|
|
138
|
+
f
|
|
139
|
+
.remoteJson(repo, MANIFEST)
|
|
140
|
+
.plugins.find((plugin: { name: string }) => plugin.name === PLUGIN)
|
|
141
|
+
?.version,
|
|
142
|
+
).toBe("0.3.2");
|
|
143
|
+
expect(f.remoteJson(repo, PLUGIN_MANIFEST).version).toBe("0.3.2");
|
|
144
|
+
expect(f.version()).toBe("0.3.2");
|
|
145
|
+
expect(
|
|
146
|
+
applied.result.installs?.map(({ scope, from, to, loaded, status }) => ({
|
|
147
|
+
scope,
|
|
148
|
+
from,
|
|
149
|
+
to,
|
|
150
|
+
loaded,
|
|
151
|
+
status,
|
|
152
|
+
})),
|
|
153
|
+
).toEqual([
|
|
154
|
+
{
|
|
155
|
+
scope: "user",
|
|
156
|
+
from: "0.3.1",
|
|
157
|
+
to: "0.3.2",
|
|
158
|
+
loaded: "0.3.2",
|
|
159
|
+
status: "updated",
|
|
160
|
+
},
|
|
161
|
+
]);
|
|
162
|
+
expect(applied.result.localCopy).toBeUndefined();
|
|
163
|
+
expect(applied.result.localCopies).toHaveLength(2);
|
|
164
|
+
expect(applied.result.localCopies).toContainEqual({
|
|
165
|
+
scope: "project",
|
|
166
|
+
name: "review",
|
|
167
|
+
action: "removed",
|
|
168
|
+
});
|
|
169
|
+
expect(applied.result.localCopies).toContainEqual({
|
|
170
|
+
scope: "project",
|
|
171
|
+
name: "lint",
|
|
172
|
+
action: "removed",
|
|
173
|
+
});
|
|
174
|
+
expect(existsSync(f.folder("project", "review"))).toBe(false);
|
|
175
|
+
expect(existsSync(f.folder("project", "lint"))).toBe(false);
|
|
176
|
+
expect(
|
|
177
|
+
readFileSync(join(f.folder("project", "steady"), "SKILL.md"), "utf8"),
|
|
178
|
+
).toBe(STEADY);
|
|
179
|
+
const steps = applied.result.steps.map((step) => step.id);
|
|
180
|
+
expect(steps.slice(0, 2).sort()).toEqual(["replace:lint", "replace:review"]);
|
|
181
|
+
expect(steps.slice(2)).toEqual([
|
|
182
|
+
"bump",
|
|
183
|
+
"validate",
|
|
184
|
+
"commit",
|
|
185
|
+
"deliver",
|
|
186
|
+
"install",
|
|
187
|
+
"local-copy",
|
|
188
|
+
]);
|
|
189
|
+
f.assertOwned();
|
|
190
|
+
}, 90_000);
|
|
191
|
+
|
|
192
|
+
test.each([
|
|
193
|
+
"keep-local",
|
|
194
|
+
"unpublished",
|
|
195
|
+
"not-installed",
|
|
196
|
+
"mixed-scopes",
|
|
197
|
+
"install-failed",
|
|
198
|
+
] as const)(
|
|
199
|
+
"R3.2 TEST-22 plugin publish settles all copies independently: %s",
|
|
200
|
+
(mode) => {
|
|
201
|
+
f = new MarketFixture(true);
|
|
202
|
+
const repo = seedSkills(f, mode !== "not-installed");
|
|
203
|
+
editSkill(f, "review", EDITED);
|
|
204
|
+
editSkill(
|
|
205
|
+
f,
|
|
206
|
+
"lint",
|
|
207
|
+
LINT_EDITED,
|
|
208
|
+
mode === "mixed-scopes" ? "global" : "project",
|
|
209
|
+
);
|
|
210
|
+
if (mode === "unpublished")
|
|
211
|
+
write(join(f.folder(), ".env"), "PRIVATE=keep-here\n");
|
|
212
|
+
if (mode === "install-failed")
|
|
213
|
+
f.setModes({ failInstall: "Install did not finish" });
|
|
214
|
+
const old = f.head(repo);
|
|
215
|
+
|
|
216
|
+
const result = f.market([
|
|
217
|
+
"publish",
|
|
218
|
+
"--plugin",
|
|
219
|
+
ID,
|
|
220
|
+
"--yes",
|
|
221
|
+
...(mode === "keep-local" ? ["--keep-local"] : []),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
expect(result.code).toBe(mode === "install-failed" ? 1 : 0);
|
|
225
|
+
if (mode === "install-failed") {
|
|
226
|
+
expect(result.result.outcome).toBe("partial");
|
|
227
|
+
expect(result.result.error?.code).toBe("install-failed");
|
|
228
|
+
expect(result.result.installs).toHaveLength(1);
|
|
229
|
+
expect(
|
|
230
|
+
result.result.installs?.every((row) => row.status === "failed"),
|
|
231
|
+
).toBe(true);
|
|
232
|
+
}
|
|
233
|
+
assertOneCommit(f, repo, old, [
|
|
234
|
+
MANIFEST,
|
|
235
|
+
PLUGIN_MANIFEST,
|
|
236
|
+
SKILL_REL,
|
|
237
|
+
LINT_REL,
|
|
238
|
+
]);
|
|
239
|
+
expect(result.result.localCopies).toHaveLength(2);
|
|
240
|
+
for (const name of ["review", "lint"]) {
|
|
241
|
+
const kept =
|
|
242
|
+
mode === "keep-local" ||
|
|
243
|
+
mode === "not-installed" ||
|
|
244
|
+
mode === "install-failed" ||
|
|
245
|
+
(mode === "unpublished" && name === "review");
|
|
246
|
+
const scope =
|
|
247
|
+
mode === "mixed-scopes" && name === "lint" ? "user" : "project";
|
|
248
|
+
const copy = result.result.localCopies?.find(
|
|
249
|
+
(copy) => copy.name === name,
|
|
250
|
+
);
|
|
251
|
+
expect(copy?.scope).toBe(scope);
|
|
252
|
+
expect(copy?.action).toBe(kept ? "kept" : "removed");
|
|
253
|
+
expect(
|
|
254
|
+
existsSync(f.folder(scope === "user" ? "global" : "project", name)),
|
|
255
|
+
).toBe(kept);
|
|
256
|
+
if (kept) expect(copy?.reason?.length).toBeGreaterThan(0);
|
|
257
|
+
}
|
|
258
|
+
if (mode === "unpublished") {
|
|
259
|
+
expect(readFileSync(join(f.folder(), ".env"), "utf8")).toBe(
|
|
260
|
+
"PRIVATE=keep-here\n",
|
|
261
|
+
);
|
|
262
|
+
expect(f.remotePaths(repo)).not.toContain(
|
|
263
|
+
"plugins/review-kit/skills/review/.env",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (mode === "not-installed") expect(f.version()).toBeNull();
|
|
267
|
+
else
|
|
268
|
+
expect(f.version()).toBe(mode === "install-failed" ? "0.3.1" : "0.3.2");
|
|
269
|
+
if (mode === "keep-local" || mode === "not-installed") {
|
|
270
|
+
write(join(f.folder(), "SKILL.md"), `${EDITED}\nAnother tracked edit.\n`);
|
|
271
|
+
const tracked = f.market(["publish", "--plugin", ID]);
|
|
272
|
+
expect(tracked.code).toBe(3);
|
|
273
|
+
expect(tracked.result.outcome).toBe("needs-consent");
|
|
274
|
+
expect(tracked.result.error).toBeUndefined();
|
|
275
|
+
expect(f.head(repo)).toBe(result.result.commit!);
|
|
276
|
+
}
|
|
277
|
+
f.assertOwned();
|
|
278
|
+
},
|
|
279
|
+
90_000,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
test.each(["protected", "fork"] as const)(
|
|
283
|
+
"R3.2 TEST-23 %s delivery puts both skill edits in one PR commit and keeps copies",
|
|
284
|
+
(mode) => {
|
|
285
|
+
f = new MarketFixture();
|
|
286
|
+
const repo = seedSkills(f);
|
|
287
|
+
editSkill(f, "review", EDITED);
|
|
288
|
+
editSkill(f, "lint", LINT_EDITED);
|
|
289
|
+
if (mode === "protected") f.protect(repo);
|
|
290
|
+
else f.setModes({ push: false });
|
|
291
|
+
const old = f.head(repo);
|
|
292
|
+
|
|
293
|
+
const result = f.market(["publish", "--plugin", ID, "--yes"]);
|
|
294
|
+
|
|
295
|
+
expect({ exit: result.code, outcome: result.result.outcome }).toEqual({
|
|
296
|
+
exit: 0,
|
|
297
|
+
outcome: "pull-request",
|
|
298
|
+
});
|
|
299
|
+
expect(result.result.pullRequest).toMatch(/\/pull\/17$/);
|
|
300
|
+
expect(typeof result.result.commit).toBe("string");
|
|
301
|
+
const destination =
|
|
302
|
+
mode === "protected"
|
|
303
|
+
? repo.bare
|
|
304
|
+
: join(f.root, "remotes", "teammate", "market-renamed.git");
|
|
305
|
+
const commit = result.result.commit!;
|
|
306
|
+
expect(
|
|
307
|
+
f.git(
|
|
308
|
+
f.root,
|
|
309
|
+
"--git-dir",
|
|
310
|
+
destination,
|
|
311
|
+
"rev-list",
|
|
312
|
+
"--count",
|
|
313
|
+
`${old}..${commit}`,
|
|
314
|
+
),
|
|
315
|
+
).toBe("1");
|
|
316
|
+
expect(
|
|
317
|
+
f.git(f.root, "--git-dir", destination, "show", `${commit}:${SKILL_REL}`),
|
|
318
|
+
).toBe(EDITED.trimEnd());
|
|
319
|
+
expect(
|
|
320
|
+
f.git(f.root, "--git-dir", destination, "show", `${commit}:${LINT_REL}`),
|
|
321
|
+
).toBe(LINT_EDITED.trimEnd());
|
|
322
|
+
expect(f.head(repo)).toBe(old);
|
|
323
|
+
expect(f.version()).toBe("0.3.1");
|
|
324
|
+
expect(
|
|
325
|
+
result.result.installs?.some((install) => install.status === "updated") ??
|
|
326
|
+
false,
|
|
327
|
+
).toBe(false);
|
|
328
|
+
expect(result.result.localCopies).toHaveLength(2);
|
|
329
|
+
for (const name of ["review", "lint"]) {
|
|
330
|
+
const copy = result.result.localCopies?.find(
|
|
331
|
+
(copy) => copy.name === name,
|
|
332
|
+
);
|
|
333
|
+
expect(copy?.action).toBe("kept");
|
|
334
|
+
expect(copy?.reason?.length).toBeGreaterThan(0);
|
|
335
|
+
expect(existsSync(f.folder("project", name))).toBe(true);
|
|
336
|
+
}
|
|
337
|
+
f.assertOwned();
|
|
338
|
+
},
|
|
339
|
+
90_000,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
test("R3.3 TEST-24 mixed plugin publish excludes both unknown-origin and conflicting skills with individual force guidance", () => {
|
|
343
|
+
f = new MarketFixture();
|
|
344
|
+
const repo = seedSkills(f);
|
|
345
|
+
editSkill(f, "review", EDITED);
|
|
346
|
+
write(join(f.folder("project", "lint"), "SKILL.md"), LINT_EDITED);
|
|
347
|
+
editSkill(f, "steady", `${STEADY}\nLocal steady edit.\n`);
|
|
348
|
+
const remoteSteady = `${STEADY}\nRemote steady edit.\n`;
|
|
349
|
+
write(join(repo.author, STEADY_REL), remoteSteady);
|
|
350
|
+
f.push(repo);
|
|
351
|
+
f.ok(["fetch", "acme"]);
|
|
352
|
+
const old = f.head(repo);
|
|
353
|
+
const before = f.snapshot();
|
|
354
|
+
|
|
355
|
+
const plan = f.market(["publish", "--plugin", ID]);
|
|
356
|
+
|
|
357
|
+
expect(plan.code).toBe(3);
|
|
358
|
+
assertForceGuidance(plan.result, ["lint", "steady"]);
|
|
359
|
+
expect(f.snapshot()).toEqual(before);
|
|
360
|
+
|
|
361
|
+
const result = f.market([
|
|
362
|
+
"publish",
|
|
363
|
+
"--plugin",
|
|
364
|
+
ID,
|
|
365
|
+
`--yes=${plan.result.planId}`,
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
expect(result.code).toBe(0);
|
|
369
|
+
assertOneCommit(f, repo, old, [MANIFEST, PLUGIN_MANIFEST, SKILL_REL]);
|
|
370
|
+
expect(f.remoteBytes(repo, SKILL_REL)).toBe(EDITED);
|
|
371
|
+
expect(f.remoteBytes(repo, LINT_REL)).toBe(LINT);
|
|
372
|
+
expect(f.remoteBytes(repo, STEADY_REL)).toBe(remoteSteady);
|
|
373
|
+
expect(result.result.localCopies?.map((copy) => copy.name)).toEqual([
|
|
374
|
+
"review",
|
|
375
|
+
]);
|
|
376
|
+
expect(
|
|
377
|
+
readFileSync(join(f.folder("project", "lint"), "SKILL.md"), "utf8"),
|
|
378
|
+
).toBe(LINT_EDITED);
|
|
379
|
+
expect(
|
|
380
|
+
readFileSync(join(f.folder("project", "steady"), "SKILL.md"), "utf8"),
|
|
381
|
+
).toBe(`${STEADY}\nLocal steady edit.\n`);
|
|
382
|
+
f.assertOwned();
|
|
383
|
+
}, 90_000);
|
|
384
|
+
|
|
385
|
+
test.each(["no copies", "same copies", "plugin newer"] as const)(
|
|
386
|
+
"R3.3 TEST-25 plugin publish with %s is unchanged and read-only",
|
|
387
|
+
(mode) => {
|
|
388
|
+
f = new MarketFixture();
|
|
389
|
+
const repo = seedSkills(f);
|
|
390
|
+
if (mode !== "no copies") editSkill(f, "review");
|
|
391
|
+
if (mode === "plugin newer") {
|
|
392
|
+
write(join(repo.author, SKILL_REL), REMOTE_EDIT);
|
|
393
|
+
f.push(repo);
|
|
394
|
+
f.ok(["fetch", "acme"]);
|
|
395
|
+
}
|
|
396
|
+
const before = f.snapshot();
|
|
397
|
+
const head = f.head(repo);
|
|
398
|
+
|
|
399
|
+
const result = f.market(["publish", "--plugin", ID]);
|
|
400
|
+
|
|
401
|
+
expect({ exit: result.code, outcome: result.result.outcome }).toEqual({
|
|
402
|
+
exit: 0,
|
|
403
|
+
outcome: "unchanged",
|
|
404
|
+
});
|
|
405
|
+
expect(result.result.steps).toEqual([]);
|
|
406
|
+
expect(f.head(repo)).toBe(head);
|
|
407
|
+
expect(f.snapshot()).toEqual(before);
|
|
408
|
+
},
|
|
409
|
+
60_000,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
test.each([
|
|
413
|
+
{ state: "no-base", from: "project" },
|
|
414
|
+
{ state: "no-base", from: "global" },
|
|
415
|
+
{ state: "conflict", from: "project" },
|
|
416
|
+
{ state: "conflict", from: "global" },
|
|
417
|
+
] as const)(
|
|
418
|
+
"R3.3 TEST-26 only $state copies in $from fail with the correct exit and force command, without writes",
|
|
419
|
+
({ state, from }) => {
|
|
420
|
+
f = new MarketFixture(true);
|
|
421
|
+
const repo = seedSkills(f);
|
|
422
|
+
if (state === "no-base") {
|
|
423
|
+
write(join(f.folder(from), "SKILL.md"), EDITED);
|
|
424
|
+
} else {
|
|
425
|
+
editSkill(f, "review", EDITED, from);
|
|
426
|
+
write(join(repo.author, SKILL_REL), REMOTE_EDIT);
|
|
427
|
+
f.push(repo);
|
|
428
|
+
f.ok(["fetch", "acme"]);
|
|
429
|
+
}
|
|
430
|
+
const before = f.snapshot();
|
|
431
|
+
const head = f.head(repo);
|
|
432
|
+
|
|
433
|
+
const result = f.market(["publish", "--plugin", ID]);
|
|
434
|
+
|
|
435
|
+
expect({
|
|
436
|
+
exit: result.code,
|
|
437
|
+
outcome: result.result.outcome,
|
|
438
|
+
error: result.result.error?.code,
|
|
439
|
+
}).toEqual({
|
|
440
|
+
exit: 1,
|
|
441
|
+
outcome: "failed",
|
|
442
|
+
error: state,
|
|
443
|
+
});
|
|
444
|
+
assertForceGuidance(result.result, ["review"]);
|
|
445
|
+
if (from === "global") {
|
|
446
|
+
expect(
|
|
447
|
+
result.result.plan?.some(
|
|
448
|
+
(line) =>
|
|
449
|
+
line.includes("magus market publish review") &&
|
|
450
|
+
line.includes("--force") &&
|
|
451
|
+
/--from(?:=|\s+)global/.test(line),
|
|
452
|
+
),
|
|
453
|
+
).toBe(true);
|
|
454
|
+
}
|
|
455
|
+
expect(f.head(repo)).toBe(head);
|
|
456
|
+
expect(f.snapshot()).toEqual(before);
|
|
457
|
+
},
|
|
458
|
+
60_000,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
test("R3.3 TEST-27 the same skill edited in PROJECT and USER refuses ambiguous plugin publish", () => {
|
|
462
|
+
f = new MarketFixture(true);
|
|
463
|
+
const repo = seedSkills(f);
|
|
464
|
+
editSkill(f, "review", EDITED);
|
|
465
|
+
editSkill(f, "review", `${EDITED}\nDifferent USER edit.\n`, "global");
|
|
466
|
+
const before = f.snapshot();
|
|
467
|
+
const head = f.head(repo);
|
|
468
|
+
|
|
469
|
+
const result = f.market(["publish", "--plugin", ID]);
|
|
470
|
+
|
|
471
|
+
expect(result.code).toBe(2);
|
|
472
|
+
expect(result.result.error?.code).toBe("ambiguous");
|
|
473
|
+
expect(f.head(repo)).toBe(head);
|
|
474
|
+
expect(f.snapshot()).toEqual(before);
|
|
475
|
+
}, 60_000);
|
|
476
|
+
|
|
477
|
+
test.each([
|
|
478
|
+
{ args: ["publish", "--plugin", ID, "--force"] },
|
|
479
|
+
{ args: ["publish", "--plugin", ID, "--from", "project"] },
|
|
480
|
+
{ args: ["publish", "--plugin"] },
|
|
481
|
+
])(
|
|
482
|
+
"R3.3 TEST-28 plugin-wide publish rejects skill-only or missing arguments: $args",
|
|
483
|
+
({ args }) => {
|
|
484
|
+
f = new MarketFixture();
|
|
485
|
+
const before = f.snapshot();
|
|
486
|
+
|
|
487
|
+
const result = f.market(args);
|
|
488
|
+
|
|
489
|
+
expect(result.code).toBe(2);
|
|
490
|
+
expect(f.snapshot()).toEqual(before);
|
|
491
|
+
},
|
|
492
|
+
30_000,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
test("R3.2 R4.5 TEST-29 plugin dry-run and unconsented plan show both skills without any writes", () => {
|
|
496
|
+
f = new MarketFixture();
|
|
497
|
+
const repo = seedSkills(f);
|
|
498
|
+
editSkill(f, "review", EDITED);
|
|
499
|
+
editSkill(f, "lint", LINT_EDITED);
|
|
500
|
+
const before = f.snapshot();
|
|
501
|
+
const old = f.head(repo);
|
|
502
|
+
|
|
503
|
+
const dry = f.market(["publish", "--plugin", ID, "--dry-run"]);
|
|
504
|
+
const plan = f.market(["publish", "--plugin", ID]);
|
|
505
|
+
|
|
506
|
+
expect(dry.code).toBe(0);
|
|
507
|
+
expect(plan.code).toBe(3);
|
|
508
|
+
expect(plan.result.outcome).toBe("needs-consent");
|
|
509
|
+
expect(typeof dry.result.planId).toBe("string");
|
|
510
|
+
expect(plan.result.planId).toBe(dry.result.planId);
|
|
511
|
+
expect(dry.result.plan?.join("\n")).toContain("review");
|
|
512
|
+
expect(dry.result.plan?.join("\n")).toContain("lint");
|
|
513
|
+
expect(f.head(repo)).toBe(old);
|
|
514
|
+
expect(f.snapshot()).toEqual(before);
|
|
515
|
+
}, 60_000);
|
|
516
|
+
|
|
517
|
+
test.each(["source", "keep-local", "another skill"] as const)(
|
|
518
|
+
"R4.5 TEST-29 changing %s revokes a plugin-wide publish plan before push",
|
|
519
|
+
(changed) => {
|
|
520
|
+
f = new MarketFixture();
|
|
521
|
+
const repo = seedSkills(f);
|
|
522
|
+
editSkill(f, "review", EDITED);
|
|
523
|
+
editSkill(f, "lint", LINT_EDITED);
|
|
524
|
+
const plan = f.market(["publish", "--plugin", ID]);
|
|
525
|
+
expect(plan.code).toBe(3);
|
|
526
|
+
if (changed === "source")
|
|
527
|
+
write(
|
|
528
|
+
join(f.folder("project", "lint"), "SKILL.md"),
|
|
529
|
+
`${LINT_EDITED}\nLater edit.\n`,
|
|
530
|
+
);
|
|
531
|
+
if (changed === "another skill")
|
|
532
|
+
editSkill(f, "steady", `${STEADY}\nNew edit.\n`);
|
|
533
|
+
const old = f.head(repo);
|
|
534
|
+
const review = readFileSync(join(f.folder(), "SKILL.md"), "utf8");
|
|
535
|
+
const lint = readFileSync(
|
|
536
|
+
join(f.folder("project", "lint"), "SKILL.md"),
|
|
537
|
+
"utf8",
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const result = f.market([
|
|
541
|
+
"publish",
|
|
542
|
+
"--plugin",
|
|
543
|
+
ID,
|
|
544
|
+
`--yes=${plan.result.planId}`,
|
|
545
|
+
...(changed === "keep-local" ? ["--keep-local"] : []),
|
|
546
|
+
]);
|
|
547
|
+
|
|
548
|
+
expect({
|
|
549
|
+
exit: result.code,
|
|
550
|
+
outcome: result.result.outcome,
|
|
551
|
+
error: result.result.error?.code,
|
|
552
|
+
}).toEqual({
|
|
553
|
+
exit: 3,
|
|
554
|
+
outcome: "needs-consent",
|
|
555
|
+
error: "plan-changed",
|
|
556
|
+
});
|
|
557
|
+
expect(result.result.planId).not.toBe(plan.result.planId);
|
|
558
|
+
expect(result.result.plan?.length).toBeGreaterThan(0);
|
|
559
|
+
expect(f.head(repo)).toBe(old);
|
|
560
|
+
expect(f.version()).toBe("0.3.1");
|
|
561
|
+
expect(readFileSync(join(f.folder(), "SKILL.md"), "utf8")).toBe(review);
|
|
562
|
+
expect(
|
|
563
|
+
readFileSync(join(f.folder("project", "lint"), "SKILL.md"), "utf8"),
|
|
564
|
+
).toBe(lint);
|
|
565
|
+
f.assertOwned();
|
|
566
|
+
},
|
|
567
|
+
90_000,
|
|
568
|
+
);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { RouteOutcome } from "../cli/router.js";
|
|
4
|
+
import type { MarketOverviewEntry } from "../services/market-ops.js";
|
|
5
|
+
import type { MarketResult } from "../services/market-types.js";
|
|
6
|
+
import { MarketFixture, sourceModule, write } from "./market-fixture.js";
|
|
7
|
+
|
|
8
|
+
let f: MarketFixture | undefined;
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
f?.close();
|
|
11
|
+
f = undefined;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("R2.1 R2.4 TEST-16 edit selects the market app and headless main names the command-line help", () => {
|
|
15
|
+
f = new MarketFixture();
|
|
16
|
+
const before = f.snapshot();
|
|
17
|
+
|
|
18
|
+
const route = f.value<RouteOutcome>(
|
|
19
|
+
`const {route}=await import(${JSON.stringify(sourceModule("cli/router.js"))});console.log(JSON.stringify(await route(['edit'],'1.2.3')));`,
|
|
20
|
+
);
|
|
21
|
+
const result = f.cli(["edit"]);
|
|
22
|
+
|
|
23
|
+
expect(route).toEqual({ handled: false, launchTui: true, app: "market" });
|
|
24
|
+
expect(result.code).toBe(2);
|
|
25
|
+
expect(result.out + result.err).toContain("magus market --help");
|
|
26
|
+
expect(result.out + result.err).toContain("magus edit");
|
|
27
|
+
expect(result.out + result.err).toMatch(/needs a terminal/i);
|
|
28
|
+
expect(f.snapshot()).toEqual(before);
|
|
29
|
+
}, 30_000);
|
|
30
|
+
|
|
31
|
+
test.each(["--help", "-h"])(
|
|
32
|
+
"R2.2 TEST-17 edit %s prints edit-specific help, not global help",
|
|
33
|
+
(flag) => {
|
|
34
|
+
f = new MarketFixture();
|
|
35
|
+
const before = f.snapshot();
|
|
36
|
+
|
|
37
|
+
const result = f.cli(["edit", flag]);
|
|
38
|
+
|
|
39
|
+
expect(result.code).toBe(0);
|
|
40
|
+
expect(result.out).toContain("Usage: magus edit");
|
|
41
|
+
expect(result.out).toContain("company marketplace");
|
|
42
|
+
expect(result.out).toContain("magus market --help");
|
|
43
|
+
expect(result.out).toContain("whole plugin as one new version");
|
|
44
|
+
expect(f.snapshot()).toEqual(before);
|
|
45
|
+
},
|
|
46
|
+
30_000,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
test.each(["list", "publish", "anything", "--unknown", "--json"])(
|
|
50
|
+
"R2.2 TEST-18 edit %s refuses arguments with market-verb guidance",
|
|
51
|
+
(argument) => {
|
|
52
|
+
f = new MarketFixture();
|
|
53
|
+
const before = f.snapshot();
|
|
54
|
+
|
|
55
|
+
const route = f.value<RouteOutcome>(
|
|
56
|
+
`const {route}=await import(${JSON.stringify(sourceModule("cli/router.js"))});console.log(JSON.stringify(await route(${JSON.stringify(["edit", argument])},'1.2.3')));`,
|
|
57
|
+
);
|
|
58
|
+
const result = f.cli(["edit", argument]);
|
|
59
|
+
|
|
60
|
+
expect(route).toEqual({ handled: true, launchTui: false, exitCode: 2 });
|
|
61
|
+
expect(result.code).toBe(2);
|
|
62
|
+
expect(result.err).toContain("magus market <verb>");
|
|
63
|
+
expect(result.err).toContain("magus market --help");
|
|
64
|
+
expect(f.snapshot()).toEqual(before);
|
|
65
|
+
},
|
|
66
|
+
30_000,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
test("R2.3 TEST-19 bare market handles help without launching; market list still dispatches", () => {
|
|
70
|
+
f = new MarketFixture();
|
|
71
|
+
const before = f.snapshot();
|
|
72
|
+
|
|
73
|
+
const routes = f.value<RouteOutcome[]>(
|
|
74
|
+
`const {route}=await import(${JSON.stringify(sourceModule("cli/router.js"))});console.log(JSON.stringify([await route(['market'],'1.2.3'),await route(['market','list'],'1.2.3')]));`,
|
|
75
|
+
);
|
|
76
|
+
const result = f.cli(["market"]);
|
|
77
|
+
|
|
78
|
+
expect(routes).toEqual([
|
|
79
|
+
{ handled: true, launchTui: false, exitCode: 0 },
|
|
80
|
+
{ handled: true, launchTui: false, exitCode: 0 },
|
|
81
|
+
]);
|
|
82
|
+
expect(result.code).toBe(0);
|
|
83
|
+
expect(result.out).toContain("Usage: magus market <command> [args]");
|
|
84
|
+
expect(result.out).toContain("Open the screen with `magus edit`.");
|
|
85
|
+
expect(result.out).toContain("--company");
|
|
86
|
+
expect(result.out).toContain("--create-plugin");
|
|
87
|
+
expect(result.out).toContain("plugin add");
|
|
88
|
+
expect(result.out).toContain("publish --plugin");
|
|
89
|
+
expect(f.snapshot()).toEqual(before);
|
|
90
|
+
}, 30_000);
|
|
91
|
+
|
|
92
|
+
test.each([false, true])(
|
|
93
|
+
"R2.4 R4.5 TEST-20 opening-data services are read-only; working clone=%s",
|
|
94
|
+
(working) => {
|
|
95
|
+
f = new MarketFixture(true);
|
|
96
|
+
const repo = f.seed();
|
|
97
|
+
f.register(repo);
|
|
98
|
+
if (working) f.ok(["fetch", "acme"]);
|
|
99
|
+
const skill = f.folder("project", "solo");
|
|
100
|
+
write(
|
|
101
|
+
join(skill, "SKILL.md"),
|
|
102
|
+
"---\nname: solo\n---\nLocal instructions\n",
|
|
103
|
+
);
|
|
104
|
+
const before = f.snapshot();
|
|
105
|
+
const head = f.head(repo);
|
|
106
|
+
|
|
107
|
+
const data = f.value<{
|
|
108
|
+
entries: MarketOverviewEntry[];
|
|
109
|
+
list: MarketResult;
|
|
110
|
+
}>(
|
|
111
|
+
`const m=await import(${JSON.stringify(sourceModule("services/market-ops.js"))});console.log(JSON.stringify({entries:await m.marketOverview(),list:await m.marketList(${JSON.stringify(f.project)})}));`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(data.entries.map((entry) => entry.name)).toEqual(["acme"]);
|
|
115
|
+
expect(data.entries[0]?.freshness).toBe("fresh");
|
|
116
|
+
expect(data.list.local).toContainEqual({
|
|
117
|
+
kind: "skill",
|
|
118
|
+
name: "solo",
|
|
119
|
+
scope: "project",
|
|
120
|
+
path: skill,
|
|
121
|
+
});
|
|
122
|
+
expect(f.snapshot()).toEqual(before);
|
|
123
|
+
expect(f.head(repo)).toBe(head);
|
|
124
|
+
f.assertOwned();
|
|
125
|
+
},
|
|
126
|
+
60_000,
|
|
127
|
+
);
|