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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,162 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ EDITED,
6
+ ID,
7
+ MarketFixture,
8
+ SKILL,
9
+ SKILL_REL,
10
+ sourceModule,
11
+ write,
12
+ } from "./market-fixture.js";
13
+
14
+ let f: MarketFixture | undefined;
15
+ afterEach(() => {
16
+ f?.close();
17
+ f = undefined;
18
+ });
19
+
20
+ test("TEST-32 REQ-31 a selected fetch array refreshes only that working clone", () => {
21
+ f = new MarketFixture();
22
+ const a = f.seed();
23
+ const b = f.seed({ name: "beta", slug: "beta/market" });
24
+ f.register(a);
25
+ f.register(b);
26
+ f.ok(["fetch"]);
27
+ const oldB = f.git(f.working(b), "rev-parse", "HEAD");
28
+ const oldClaudeA = f.git(f.clone(a), "rev-parse", "HEAD");
29
+ write(join(a.author, "README.md"), "New acme commit\n");
30
+ write(join(b.author, "README.md"), "New beta commit\n");
31
+ const newA = f.push(a);
32
+ f.push(b);
33
+ const before = f.snapshot();
34
+ const outsideA = (snapshot: Record<string, string>) =>
35
+ Object.fromEntries(
36
+ Object.entries(snapshot).filter(
37
+ ([file]) => !file.includes("magus-market/acme/"),
38
+ ),
39
+ );
40
+
41
+ const r = f.value<{ outcome: string }>(
42
+ `const m=await import(${JSON.stringify(sourceModule("services/market-ops.js"))}); console.log(JSON.stringify(await m.marketFetch(['acme'])));`,
43
+ );
44
+
45
+ expect(r.outcome).toBe("ok");
46
+ expect(f.git(f.working(a), "rev-parse", "HEAD")).toBe(newA);
47
+ expect(f.git(f.working(b), "rev-parse", "HEAD")).toBe(oldB);
48
+ expect(f.git(f.clone(a), "rev-parse", "HEAD")).toBe(oldClaudeA);
49
+ expect(outsideA(f.snapshot())).toEqual(outsideA(before));
50
+ f.assertOwned();
51
+ }, 60_000);
52
+
53
+ for (const working of [false, true]) {
54
+ test.each([
55
+ { args: ["list"], code: 0 },
56
+ { args: ["status"], code: 0 },
57
+ { args: ["status", "review"], code: 0 },
58
+ { args: ["publish", "review", "--plugin", ID, "--dry-run"], code: 0 },
59
+ { args: ["publish", "review", "--plugin", ID], code: 3 },
60
+ ])(
61
+ `TEST-31 REQ-20 read-only $args keeps every state byte; working clone=${working}`,
62
+ async ({ args, code }) => {
63
+ f = new MarketFixture();
64
+ const repo = await f.prepared();
65
+ if (!working) rmSync(f.working(repo), { recursive: true, force: true });
66
+ f.rememberOwned();
67
+ const before = f.snapshot();
68
+ const head = f.head(repo);
69
+
70
+ const r = f.market(args);
71
+
72
+ expect(r.code).toBe(code);
73
+ expect(f.snapshot()).toEqual(before);
74
+ expect(f.head(repo)).toBe(head);
75
+ expect(f.journal().flatMap((event) => event.writes)).toEqual([]);
76
+ f.assertOwned();
77
+ },
78
+ 60_000,
79
+ );
80
+ }
81
+
82
+ test("TEST-32 REQ-20 stale state decides nothing, offers fetch, and dry-run has no consent id", () => {
83
+ f = new MarketFixture();
84
+ const repo = f.seed();
85
+ f.register(repo);
86
+ write(join(f.folder(), "SKILL.md"), SKILL);
87
+ write(join(repo.author, SKILL_REL), EDITED);
88
+ f.push(repo);
89
+ const before = f.snapshot();
90
+
91
+ const refused = f.market(["publish", "review", "--plugin", ID]);
92
+
93
+ expect(refused.code).toBe(3);
94
+ expect(refused.result.outcome).toBe("stale");
95
+ expect(refused.result.error?.code).toBe("stale");
96
+ expect(refused.result.planId).toBeUndefined();
97
+ expect(JSON.stringify(refused.result)).toContain("magus market fetch");
98
+ expect(f.snapshot()).toEqual(before);
99
+
100
+ const provisional = f.market([
101
+ "publish",
102
+ "review",
103
+ "--plugin",
104
+ ID,
105
+ "--dry-run",
106
+ ]);
107
+ expect(provisional.code).toBe(0);
108
+ expect(provisional.result.stale).toBe(true);
109
+ expect(provisional.result.planId).toBeUndefined();
110
+ expect(f.snapshot()).toEqual(before);
111
+ const status = f.market(["status", "review"]);
112
+ expect(
113
+ status.result.skills?.find((row) => row.skill === "review")?.state,
114
+ ).toBe("not-checked");
115
+ expect(f.snapshot()).toEqual(before);
116
+ f.assertOwned();
117
+ }, 60_000);
118
+
119
+ test("TEST-32 REQ-31 fetch writes only magus's clone then fresh consent can apply", async () => {
120
+ f = new MarketFixture();
121
+ const repo = await f.prepared();
122
+ rmSync(f.working(repo), { recursive: true, force: true });
123
+ write(join(repo.author, "README.md"), "A newer remote commit\n");
124
+ const head = f.push(repo);
125
+ const clone = f.git(f.clone(repo), "rev-parse", "HEAD");
126
+ const fetched = f.market(["fetch", "acme"]);
127
+
128
+ expect(fetched.code).toBe(0);
129
+ expect(f.git(f.working(repo), "rev-parse", "HEAD")).toBe(head);
130
+ expect(f.git(f.clone(repo), "rev-parse", "HEAD")).toBe(clone);
131
+ expect(existsSync(join(f.folder(), "SKILL.md"))).toBe(true);
132
+ f.assertOwned();
133
+ const plan = f.market(["publish", "review", "--plugin", ID]);
134
+ expect(plan.code).toBe(3);
135
+ expect(plan.result.outcome).toBe("needs-consent");
136
+ expect(typeof plan.result.planId).toBe("string");
137
+ const applied = f.market([
138
+ "publish",
139
+ "review",
140
+ "--plugin",
141
+ ID,
142
+ `--yes=${plan.result.planId}`,
143
+ ]);
144
+ expect(applied.code).toBe(0);
145
+ expect(f.version()).toBe("0.3.2");
146
+ f.assertOwned();
147
+ }, 60_000);
148
+
149
+ test("TEST-31 REQ-20 unconsented connect marker consent leaves local state and remote unchanged", () => {
150
+ f = new MarketFixture();
151
+ const repo = f.seed({ marker: false });
152
+ const before = f.snapshot();
153
+ const head = f.head(repo);
154
+
155
+ const r = f.market(["connect", repo.url]);
156
+
157
+ expect(r.code).toBe(3);
158
+ expect(typeof r.result.planId).toBe("string");
159
+ expect(r.result.plan?.join("\n")).toMatch(/company|mark/i);
160
+ expect(f.snapshot()).toEqual(before);
161
+ expect(f.head(repo)).toBe(head);
162
+ }, 30_000);
@@ -0,0 +1,263 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ MANIFEST,
6
+ MarketFixture,
7
+ PLUGIN,
8
+ readJson,
9
+ sourceModule,
10
+ writeJson,
11
+ } from "./market-fixture.js";
12
+
13
+ let f: MarketFixture | undefined;
14
+ afterEach(() => {
15
+ f?.close();
16
+ f = undefined;
17
+ });
18
+
19
+ test("TEST-19 REQ-2 registration failure after create reports the existing repository and connect remedy", () => {
20
+ f = new MarketFixture();
21
+ f.setModes({ addFail: true });
22
+
23
+ const r = f.market([
24
+ "create",
25
+ "acme/new-market",
26
+ "--private",
27
+ "--name",
28
+ "acme",
29
+ "--yes",
30
+ ]);
31
+
32
+ expect(r.code).toBe(1);
33
+ expect(existsSync(join(f.root, "remotes", "acme", "new-market.git"))).toBe(
34
+ true,
35
+ );
36
+ expect(r.result.message.toLowerCase()).not.toContain("nothing changed");
37
+ expect(JSON.stringify(r.result)).toContain("connect");
38
+ expect(JSON.stringify(r.result)).toContain("acme/new-market");
39
+ f.assertOwned();
40
+ }, 60_000);
41
+
42
+ test.each(["--private", "--public"])(
43
+ "TEST-19 REQ-2 creates %s marketplace then reads back USER registration",
44
+ (visibility) => {
45
+ f = new MarketFixture();
46
+
47
+ const r = f.market([
48
+ "create",
49
+ "acme/new-market",
50
+ visibility,
51
+ "--name",
52
+ "acme",
53
+ "--description",
54
+ "Company tools",
55
+ "--yes",
56
+ ]);
57
+
58
+ expect(r.code).toBe(0);
59
+ expect(r.result.outcome).toBe("ok");
60
+ const bare = join(f.root, "remotes", "acme", "new-market.git");
61
+ const manifest = JSON.parse(
62
+ f.git(f.root, "--git-dir", bare, "show", `HEAD:${MANIFEST}`),
63
+ );
64
+ expect(manifest).toEqual({
65
+ name: "acme",
66
+ owner: { name: "acme" },
67
+ metadata: {
68
+ description: "Company tools",
69
+ magus: { schema: 1, company: true },
70
+ },
71
+ plugins: [],
72
+ });
73
+ expect(
74
+ readJson(join(f.config, "plugins", "known_marketplaces.json")).acme,
75
+ ).toBeDefined();
76
+ expect(
77
+ readJson(join(f.config, "magus-market", "trusted.json")).marketplaces.acme
78
+ .source,
79
+ ).toBe("acme/new-market");
80
+ expect(
81
+ f.market(["list"]).result.marketplaces?.map((row) => ({
82
+ name: row.name,
83
+ scope: row.scope,
84
+ })),
85
+ ).toEqual([{ name: "acme", scope: "user" }]);
86
+ f.assertOwned();
87
+ },
88
+ 60_000,
89
+ );
90
+
91
+ test("TEST-19 REQ-2 an existing repository gets a connect remedy instead of another create", () => {
92
+ f = new MarketFixture();
93
+ const repo = f.seed();
94
+ const head = f.head(repo);
95
+
96
+ const r = f.market(["create", repo.slug, "--private", "--yes"]);
97
+
98
+ expect(r.code).toBe(1);
99
+ expect(r.result.error?.code).toBe("name-taken");
100
+ expect(JSON.stringify(r.result)).toContain("connect");
101
+ expect(f.head(repo)).toBe(head);
102
+ f.assertOwned();
103
+ }, 30_000);
104
+
105
+ test.each([
106
+ {
107
+ marker: true,
108
+ noManifest: false,
109
+ empty: false,
110
+ commits: 0,
111
+ label: "marked",
112
+ },
113
+ {
114
+ marker: false,
115
+ noManifest: false,
116
+ empty: false,
117
+ commits: 1,
118
+ label: "unmarked",
119
+ },
120
+ { marker: true, noManifest: true, empty: true, commits: 1, label: "empty" },
121
+ {
122
+ marker: true,
123
+ noManifest: true,
124
+ empty: false,
125
+ commits: 1,
126
+ label: "populated without manifest",
127
+ },
128
+ ])(
129
+ "TEST-20 REQ-3 connects $label while retaining pre-existing history",
130
+ (options) => {
131
+ f = new MarketFixture();
132
+ const repo = f.seed(options);
133
+ const old = options.empty ? null : f.head(repo);
134
+ const originalReadme = options.empty
135
+ ? null
136
+ : f.remoteFile(repo, "README.md");
137
+ const args = ["connect", repo.url, "--yes"];
138
+ if (options.noManifest) args.push("--name", "acme");
139
+
140
+ const r = f.market(args);
141
+
142
+ expect(r.code).toBe(0);
143
+ expect(r.result.outcome).toBe("ok");
144
+ expect(f.remoteJson(repo, MANIFEST).metadata.magus).toEqual({
145
+ schema: 1,
146
+ company: true,
147
+ });
148
+ if (old) {
149
+ expect(
150
+ f.git(
151
+ f.root,
152
+ "--git-dir",
153
+ repo.bare,
154
+ "rev-list",
155
+ "--count",
156
+ `${old}..HEAD`,
157
+ ),
158
+ ).toBe(String(options.commits));
159
+ expect(f.remoteFile(repo, "README.md")).toBe(originalReadme!);
160
+ }
161
+ if (old && options.noManifest) {
162
+ expect(
163
+ f.git(
164
+ f.root,
165
+ "--git-dir",
166
+ repo.bare,
167
+ "diff-tree",
168
+ "--no-commit-id",
169
+ "--name-only",
170
+ "-r",
171
+ "HEAD",
172
+ ),
173
+ ).toBe(MANIFEST);
174
+ }
175
+ expect(
176
+ readJson(join(f.config, "plugins", "known_marketplaces.json")).acme,
177
+ ).toBeDefined();
178
+ f.assertOwned();
179
+ },
180
+ 60_000,
181
+ );
182
+
183
+ test("TEST-20 REQ-23 --name cannot rename an existing marketplace", () => {
184
+ f = new MarketFixture();
185
+ const repo = f.seed();
186
+ const before = f.snapshot();
187
+ const head = f.head(repo);
188
+
189
+ const r = f.market(["connect", repo.url, "--name", "different", "--yes"]);
190
+
191
+ expect(r.code).toBe(2);
192
+ expect(f.head(repo)).toBe(head);
193
+ expect(f.snapshot()).toEqual(before);
194
+ }, 30_000);
195
+
196
+ test("TEST-20 REQ-20 GitHub connect without gh cleans up its temporary read clone", () => {
197
+ f = new MarketFixture();
198
+ const repo = f.seed();
199
+ f.missingGh();
200
+ const before = f.snapshot();
201
+
202
+ const r = f.market(["connect", repo.url]);
203
+
204
+ expect(r.code).toBe(3);
205
+ expect(r.result.outcome).toBe("needs-consent");
206
+ expect(typeof r.result.planId).toBe("string");
207
+ expect(f.snapshot()).toEqual(before);
208
+ }, 30_000);
209
+
210
+ test("TEST-20 REQ-3 reconnect refreshes an already registered but not yet marked clone", () => {
211
+ f = new MarketFixture();
212
+ const repo = f.seed({ marker: false });
213
+ f.register(repo, { trust: false });
214
+ const manifest = readJson(join(repo.author, MANIFEST));
215
+ manifest.metadata.magus = { schema: 1, company: true };
216
+ writeJson(join(repo.author, MANIFEST), manifest);
217
+ f.push(repo);
218
+
219
+ const r = f.market(["connect", repo.url, "--yes"]);
220
+
221
+ expect(r.code).toBe(0);
222
+ expect(
223
+ f.market(["list"]).result.marketplaces?.map((row) => row.name),
224
+ ).toEqual(["acme"]);
225
+ f.assertOwned();
226
+ }, 60_000);
227
+
228
+ test("TEST-43 REQ-16 remove discloses uninstall/profile re-add and never deletes the remote", async () => {
229
+ f = new MarketFixture();
230
+ const repo = await f.prepared();
231
+ f.profile();
232
+ const head = f.head(repo);
233
+ const folder = f.folder();
234
+ const before = f.snapshot();
235
+
236
+ const plan = f.market(["remove", "acme"]);
237
+
238
+ expect(plan.code).toBe(3);
239
+ const wording = [plan.result.message, ...(plan.result.plan ?? [])].join("\n");
240
+ expect(wording).toMatch(/uninstall/i);
241
+ expect(wording).toContain(PLUGIN);
242
+ expect(wording).toContain("magus update");
243
+ expect(wording).toMatch(/adds? it back|again/i);
244
+ expect(f.snapshot()).toEqual(before);
245
+
246
+ const applied = f.market(["remove", "acme", `--yes=${plan.result.planId}`]);
247
+
248
+ expect(applied.code).toBe(0);
249
+ expect(f.head(repo)).toBe(head);
250
+ expect(existsSync(repo.bare)).toBe(true);
251
+ expect(readFileSync(join(folder, "SKILL.md"), "utf8")).toContain(
252
+ "Review error handling",
253
+ );
254
+ expect(
255
+ readJson(join(f.config, "magus-market", "trusted.json")).marketplaces.acme,
256
+ ).toBeUndefined();
257
+ expect(existsSync(f.working(repo))).toBe(false);
258
+ const base = f.value(
259
+ `const b=await import(${JSON.stringify(sourceModule("services/market-bases.js"))}); console.log(JSON.stringify(b.readBase(${JSON.stringify(folder)})));`,
260
+ );
261
+ expect(base).toBeNull();
262
+ f.assertOwned();
263
+ }, 60_000);
@@ -0,0 +1,198 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ computePlanId,
6
+ errorResult,
7
+ exitCodeFor,
8
+ } from "../services/market-ops.js";
9
+ import { MarketError, type MarketResult } from "../services/market-types.js";
10
+ import { MarketFixture, sourceModule } from "./market-fixture.js";
11
+
12
+ let f: MarketFixture | undefined;
13
+ afterEach(() => {
14
+ f?.close();
15
+ f = undefined;
16
+ });
17
+ function result(
18
+ outcome: MarketResult["outcome"],
19
+ error?: MarketResult["error"],
20
+ ): MarketResult {
21
+ return {
22
+ command: "market publish",
23
+ outcome,
24
+ message: "State of this change",
25
+ steps: [],
26
+ ...(error ? { error } : {}),
27
+ };
28
+ }
29
+ test.each([
30
+ ["ok", 0],
31
+ ["unchanged", 0],
32
+ ["plugin-newer", 0],
33
+ ["pull-request", 0],
34
+ ["failed", 1],
35
+ ["partial", 1],
36
+ ["needs-consent", 3],
37
+ ["stale", 3],
38
+ ] as const)("TEST-15 REQ-27 outcome %s exits %i", (outcome, code) =>
39
+ expect(exitCodeFor(result(outcome))).toBe(code),
40
+ );
41
+ test.each([
42
+ ["gh-auth", 4],
43
+ ["no-network", 1],
44
+ ["no-access", 1],
45
+ ["fork-not-allowed", 1],
46
+ ["timeout", 1],
47
+ ["signing-timeout", 1],
48
+ ["install-failed", 1],
49
+ ["plan-changed", 3],
50
+ ] as const)("TEST-15 REQ-27 error %s exits %i", (code, exit) => {
51
+ const r = errorResult(
52
+ "market publish",
53
+ new MarketError(code, "Provider failure"),
54
+ );
55
+ expect(r.error?.code).toBe(code);
56
+ expect(exitCodeFor(r)).toBe(exit);
57
+ expect(exitCodeFor(r, true)).toBe(2);
58
+ });
59
+ test("TEST-15 REQ-27 an unexpected exception keeps its words under other", () => {
60
+ const r = errorResult(
61
+ "market publish",
62
+ new Error("unexpected provider words"),
63
+ );
64
+ expect(r.outcome).toBe("failed");
65
+ expect(r.error).toEqual({
66
+ code: "other",
67
+ message: expect.stringContaining("unexpected provider words"),
68
+ });
69
+ });
70
+
71
+ test("TEST-48 REQ-32 a live lock excludes another writer and release permits a new one", () => {
72
+ f = new MarketFixture();
73
+ const answer = f.value<{
74
+ refused: string | null;
75
+ acquiredAfterRelease: boolean;
76
+ }>(
77
+ `const c=await import(${JSON.stringify(sourceModule("services/market-clone.js"))});const release=await c.acquireMarketLock('acme'); let refused=null; try { const unexpected=await c.acquireMarketLock('acme'); unexpected(); } catch(e) { refused=e.code; } finally { release(); }const again=await c.acquireMarketLock('acme'); again(); console.log(JSON.stringify({refused,acquiredAfterRelease:true}));`,
78
+ );
79
+ expect(answer).toEqual({ refused: "locked", acquiredAfterRelease: true });
80
+ });
81
+
82
+ test("TEST-48 REQ-24 a public plan revalidation changes consent before any external step", () => {
83
+ f = new MarketFixture();
84
+ const destination = join(f.project, "must-not-exist.txt");
85
+ const r = f.value<MarketResult>(
86
+ `const fs=await import('node:fs'); const o=await import(${JSON.stringify(sourceModule("services/market-ops.js"))});const fresh={command:'market test',lines:['Fresh action'],planId:'fresh',summary:{},steps:[{id:'write',label:'write',outside:true,run:async()=>{fs.writeFileSync(${JSON.stringify(destination)},'wrong');}}],sync:null,lock:null,doneMessage:'Done'};fresh.replan=async()=>({kind:'plan',plan:fresh}); const old={...fresh,planId:'old'}; console.log(JSON.stringify(await o.applyMarketPlan(old,{approved:'old',cwd:${JSON.stringify(f.project)}})));`,
87
+ );
88
+ expect(r.outcome).toBe("needs-consent");
89
+ expect(r.error?.code).toBe("plan-changed");
90
+ expect(r.planId).toBe("fresh");
91
+ expect(r.plan).toEqual(["Fresh action"]);
92
+ expect(existsSync(destination)).toBe(false);
93
+ });
94
+
95
+ const facts = {
96
+ operation: "publish",
97
+ options: { from: "project", force: false, keepLocal: false },
98
+ commit: "a".repeat(40),
99
+ hash: "content-a",
100
+ notPublished: [],
101
+ target: "push-or-pr",
102
+ version: { from: "0.3.1", to: "0.3.2" },
103
+ installs: [{ scope: "user", from: "0.3.1" }],
104
+ localCopy: { action: "removed" },
105
+ };
106
+ test("TEST-16 REQ-24 identical material facts have a stable consent id", () => {
107
+ expect(computePlanId(facts)).toBe(
108
+ computePlanId(JSON.parse(JSON.stringify(facts))),
109
+ );
110
+ });
111
+ test.each([
112
+ { operation: "skill add" },
113
+ { options: { from: "global", force: false, keepLocal: false } },
114
+ { options: { from: "project", force: true, keepLocal: false } },
115
+ { options: { from: "project", force: false, keepLocal: true } },
116
+ { commit: "b".repeat(40) },
117
+ { hash: "content-b" },
118
+ { notPublished: [{ path: ".env", reason: "dotfile" }] },
119
+ { target: "fork-pr" },
120
+ { version: { from: "0.3.2", to: "0.3.3" } },
121
+ {
122
+ installs: [
123
+ { scope: "user", from: "0.3.1" },
124
+ { scope: "project", from: "0.3.1" },
125
+ ],
126
+ },
127
+ { localCopy: { action: "kept", reason: "turned off here" } },
128
+ ])("TEST-16 REQ-24 changed material fact revokes consent: %j", (patch) => {
129
+ expect(computePlanId({ ...facts, ...patch })).not.toBe(computePlanId(facts));
130
+ });
131
+
132
+ test("TEST-48 REQ-32 apply stops at a failed public plan step and reports completed writes", () => {
133
+ f = new MarketFixture();
134
+ const first = join(f.project, "first.txt");
135
+ const last = join(f.project, "must-not-exist.txt");
136
+ const script = `const fs = await import('node:fs'); const o = await import(${JSON.stringify(sourceModule("services/market-ops.js"))});const t = await import(${JSON.stringify(sourceModule("services/market-types.js"))});const p = {command:'market test', lines:['first write','fail','last write'],planId:'approved',summary:{},sync:null,lock:null,doneMessage:'Done', steps:[{id:'first',label:'first',outside:true,run:async()=>{fs.writeFileSync(${JSON.stringify(first)},'first');}},{id:'fail',label:'fail',outside:false,run:async()=>{throw new t.MarketError('other','Provider stopped');}},{id:'last',label:'last',outside:true,run:async()=>{fs.writeFileSync(${JSON.stringify(last)},'last');}}]}; p.replan=async()=>({kind:'plan',plan:p}); console.log(JSON.stringify(await o.applyMarketPlan(p,{approved:'approved',cwd:${JSON.stringify(f.project)}})));`;
137
+
138
+ const r = f.value<MarketResult>(script);
139
+
140
+ expect(r.outcome).toBe("failed");
141
+ expect(
142
+ r.steps.filter((step) => step.status === "done").map((step) => step.id),
143
+ ).toEqual(["first"]);
144
+ expect(r.steps.find((step) => step.id === "fail")?.status).toBe("failed");
145
+ expect(r.message.toLowerCase()).not.toContain("nothing changed");
146
+ expect(existsSync(first)).toBe(true);
147
+ expect(existsSync(last)).toBe(false);
148
+ });
149
+
150
+ test.each(
151
+ [
152
+ ["market", "nonsense"],
153
+ ["market", "publish"],
154
+ ["market", "plugin", "unknown"],
155
+ ["market", "create", "acme/new"],
156
+ ["market", "create", "acme/new", "--private", "--public"],
157
+ ["market", "publish", "review", "--from", "elsewhere"],
158
+ ["market", "publish", "review", "--unknown"],
159
+ ].map((argv) => ({ argv })),
160
+ )(
161
+ "TEST-47 REQ-27 rejects invalid argv %j before any writes",
162
+ ({ argv }) => {
163
+ f = new MarketFixture();
164
+ const before = f.snapshot();
165
+
166
+ const r = f.cli([...argv, "--json"]);
167
+
168
+ expect(r.code).toBe(2);
169
+ expect(f.snapshot()).toEqual(before);
170
+ f.assertOwned();
171
+ },
172
+ 30_000,
173
+ );
174
+
175
+ test("TEST-47 REQ-18 human output uses a header, scope and reload guidance", async () => {
176
+ f = new MarketFixture();
177
+ await f.prepared();
178
+
179
+ const r = f.cli([
180
+ "market",
181
+ "publish",
182
+ "review",
183
+ "--plugin",
184
+ "review-kit@acme",
185
+ "--yes",
186
+ ]);
187
+
188
+ expect(r.code).toBe(0);
189
+ expect(r.out).toContain("◆");
190
+ expect(r.out).toContain("USER");
191
+ expect(r.out).toContain("0.3.2");
192
+ expect(r.out).toContain("/reload-plugins");
193
+ expect(r.out).toMatch(/new sessions/i);
194
+ expect(r.out).not.toMatch(
195
+ /installed_plugins\.json|known_marketplaces\.json|enabledPlugins|legacy/i,
196
+ );
197
+ f.assertOwned();
198
+ }, 60_000);
@@ -0,0 +1,65 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { MarketFixture, sourceModule } from "./market-fixture.js";
3
+
4
+ let f: MarketFixture | undefined;
5
+ afterEach(() => {
6
+ f?.close();
7
+ f = undefined;
8
+ });
9
+
10
+ test("TEST-17 REQ-1 routing edit selects a different app than bare magus", () => {
11
+ f = new MarketFixture();
12
+ const before = f.snapshot();
13
+ const routes = f.value<
14
+ Array<{ launchTui: boolean; app?: string; handled: boolean }>
15
+ >(
16
+ `const {route}=await import(${JSON.stringify(sourceModule("cli/router.js"))}); console.log(JSON.stringify([await route(['edit'],'1.2.3'),await route([],'1.2.3')]));`,
17
+ );
18
+ expect(routes[0]).toEqual({ handled: false, launchTui: true, app: "market" });
19
+ expect(routes[1]?.handled).toBe(false);
20
+ expect(routes[1]?.launchTui).toBe(true);
21
+ expect(routes[1]?.app ?? "main").toBe("main");
22
+ expect(f.snapshot()).toEqual(before);
23
+ });
24
+ test("TEST-17 REQ-1 headless bare edit is a usage error without renderer writes", () => {
25
+ f = new MarketFixture();
26
+ const before = f.snapshot();
27
+
28
+ const r = f.cli(["edit"]);
29
+
30
+ expect(r.code).toBe(2);
31
+ expect(r.out + r.err).toMatch(/terminal|TTY|interactive/i);
32
+ expect(f.snapshot()).toEqual(before);
33
+ });
34
+
35
+ test.each(
36
+ [
37
+ ["market", "--help"],
38
+ ["--help"],
39
+ ["update", "--help"],
40
+ ["install", "--help"],
41
+ ["upgrade", "--help"],
42
+ ["upgrade", "-v"],
43
+ ].map((args) => ({ args })),
44
+ )(
45
+ "TEST-18 REQ-27 help/version bypass profile gates and preserve state: %j",
46
+ ({ args }) => {
47
+ f = new MarketFixture();
48
+ const before = f.snapshot();
49
+
50
+ const r = f.cli(args);
51
+
52
+ expect(r.code).toBe(0);
53
+ expect(r.out + r.err).toMatch(/magus/i);
54
+ if (args[0] === "market") {
55
+ expect(r.out).toContain("publish");
56
+ expect(r.out).toContain("connect");
57
+ } else if (args.includes("--help")) {
58
+ expect(r.out).toContain("upgrade");
59
+ expect(r.out).toContain("install");
60
+ }
61
+ expect(f.snapshot()).toEqual(before);
62
+ expect(f.journal().flatMap((event) => event.writes)).toEqual([]);
63
+ },
64
+ 30_000,
65
+ );