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,1167 @@
|
|
|
1
|
+
import { expect } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
chmodSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readlinkSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { devNull, tmpdir } from "node:os";
|
|
19
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import type { MarketResult } from "../services/market-types.js";
|
|
22
|
+
|
|
23
|
+
export const SKILL =
|
|
24
|
+
"---\nname: review\ndescription: Reviews changes.\n---\nOriginal review instructions.\n";
|
|
25
|
+
export const EDITED = `${SKILL}\nReview error handling too.\n`;
|
|
26
|
+
export const REMOTE_EDIT = `${SKILL}\nReview concurrency too.\n`;
|
|
27
|
+
export const PLUGIN = "review-kit";
|
|
28
|
+
export const ID = `${PLUGIN}@acme`;
|
|
29
|
+
export const SKILL_REL = "plugins/review-kit/skills/review/SKILL.md";
|
|
30
|
+
export const MANIFEST = ".claude-plugin/marketplace.json";
|
|
31
|
+
export const PLUGIN_MANIFEST = "plugins/review-kit/.claude-plugin/plugin.json";
|
|
32
|
+
|
|
33
|
+
export interface ProcessResult {
|
|
34
|
+
code: number;
|
|
35
|
+
out: string;
|
|
36
|
+
err: string;
|
|
37
|
+
}
|
|
38
|
+
export interface MarketRun extends ProcessResult {
|
|
39
|
+
result: MarketResult;
|
|
40
|
+
}
|
|
41
|
+
export interface Repo {
|
|
42
|
+
name: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
url: string;
|
|
45
|
+
bare: string;
|
|
46
|
+
author: string;
|
|
47
|
+
}
|
|
48
|
+
export interface SeedOptions {
|
|
49
|
+
name?: string;
|
|
50
|
+
slug?: string;
|
|
51
|
+
marker?: boolean;
|
|
52
|
+
empty?: boolean;
|
|
53
|
+
noManifest?: boolean;
|
|
54
|
+
entryVersion?: string | null;
|
|
55
|
+
pluginVersion?: string | null;
|
|
56
|
+
noPluginJson?: boolean;
|
|
57
|
+
dependencies?: string[];
|
|
58
|
+
extras?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
export interface JournalEvent {
|
|
61
|
+
tool: string;
|
|
62
|
+
argv: string[];
|
|
63
|
+
cwd: string;
|
|
64
|
+
writes: Array<{ path: string; bytes: string | null }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The provider stand-in is self-contained so its transpiled function can be
|
|
69
|
+
* installed as an executable in each test's PATH. It never imports magus.
|
|
70
|
+
*/
|
|
71
|
+
async function providerMain() {
|
|
72
|
+
const fs = await import("node:fs");
|
|
73
|
+
const p = await import("node:path");
|
|
74
|
+
const cp = await import("node:child_process");
|
|
75
|
+
type Json = Record<string, any>;
|
|
76
|
+
const root = process.env.MARKET_TEST_ROOT!;
|
|
77
|
+
const config = process.env.CLAUDE_CONFIG_DIR!;
|
|
78
|
+
const control = p.join(root, "control");
|
|
79
|
+
const controls: Json = JSON.parse(
|
|
80
|
+
fs.readFileSync(p.join(control, "modes.json"), "utf8"),
|
|
81
|
+
);
|
|
82
|
+
const argv = process.argv.slice(2);
|
|
83
|
+
const tool = p.basename(process.argv[1]!);
|
|
84
|
+
const writes: Array<{ path: string; bytes: string | null }> = [];
|
|
85
|
+
const event = () =>
|
|
86
|
+
fs.appendFileSync(
|
|
87
|
+
p.join(control, "journal.jsonl"),
|
|
88
|
+
`${JSON.stringify({ tool, argv, cwd: process.cwd(), writes })}\n`,
|
|
89
|
+
);
|
|
90
|
+
process.on("exit", event);
|
|
91
|
+
const fail = (message: string, code = 1): never => {
|
|
92
|
+
process.stderr.write(`${message}\n`);
|
|
93
|
+
process.exit(code);
|
|
94
|
+
};
|
|
95
|
+
const output = (value: unknown) =>
|
|
96
|
+
process.stdout.write(
|
|
97
|
+
typeof value === "string" ? `${value}\n` : `${JSON.stringify(value)}\n`,
|
|
98
|
+
);
|
|
99
|
+
const read = (file: string, fallback: Json = {}): Json =>
|
|
100
|
+
fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : fallback;
|
|
101
|
+
const write = (file: string, value: unknown) => {
|
|
102
|
+
fs.mkdirSync(p.dirname(file), { recursive: true });
|
|
103
|
+
const bytes = `${JSON.stringify(value, null, 2)}\n`;
|
|
104
|
+
fs.writeFileSync(file, bytes);
|
|
105
|
+
writes.push({ path: file, bytes: Buffer.from(bytes).toString("base64") });
|
|
106
|
+
};
|
|
107
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
108
|
+
cp
|
|
109
|
+
.execFileSync(process.env.MARKET_REAL_GIT!, args, {
|
|
110
|
+
cwd,
|
|
111
|
+
env: process.env,
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
114
|
+
timeout: 15_000,
|
|
115
|
+
})
|
|
116
|
+
.trim();
|
|
117
|
+
const flag = (name: string) => {
|
|
118
|
+
const eq = argv.find((a) => a.startsWith(`${name}=`));
|
|
119
|
+
const index = argv.indexOf(name);
|
|
120
|
+
return eq
|
|
121
|
+
? eq.slice(name.length + 1)
|
|
122
|
+
: index < 0
|
|
123
|
+
? undefined
|
|
124
|
+
: argv[index + 1];
|
|
125
|
+
};
|
|
126
|
+
const sourceSlug = (source: string) =>
|
|
127
|
+
source
|
|
128
|
+
.replace(/^https:\/\/github\.com\//, "")
|
|
129
|
+
.replace(/^git@github\.com:/, "")
|
|
130
|
+
.replace(/\.git\/?$/, "")
|
|
131
|
+
.replace(/\/$/, "");
|
|
132
|
+
const bare = (slug: string) => p.join(root, "remotes", `${slug}.git`);
|
|
133
|
+
const knownFile = p.join(config, "plugins", "known_marketplaces.json");
|
|
134
|
+
const installedFile = p.join(config, "plugins", "installed_plugins.json");
|
|
135
|
+
const known = () => read(knownFile);
|
|
136
|
+
const installed = () => read(installedFile, { version: 2, plugins: {} });
|
|
137
|
+
const settingsFile = (scope: string, cwd = process.cwd()) =>
|
|
138
|
+
scope === "user"
|
|
139
|
+
? p.join(config, "settings.json")
|
|
140
|
+
: p.join(
|
|
141
|
+
cwd,
|
|
142
|
+
".claude",
|
|
143
|
+
scope === "local" ? "settings.local.json" : "settings.json",
|
|
144
|
+
);
|
|
145
|
+
const pluginInfo = (id: string) => {
|
|
146
|
+
const at = id.lastIndexOf("@");
|
|
147
|
+
const name = id.slice(0, at);
|
|
148
|
+
const registration = known()[id.slice(at + 1)];
|
|
149
|
+
if (!registration) fail("Plugin not found in marketplace");
|
|
150
|
+
const location = registration.installLocation;
|
|
151
|
+
const manifest = read(
|
|
152
|
+
p.join(location, ".claude-plugin", "marketplace.json"),
|
|
153
|
+
);
|
|
154
|
+
const entry = (manifest.plugins ?? []).find(
|
|
155
|
+
(item: Json) => item.name === name,
|
|
156
|
+
);
|
|
157
|
+
if (!entry || typeof entry.source !== "string")
|
|
158
|
+
fail("Plugin not found in marketplace");
|
|
159
|
+
const plugin = entry.source.startsWith(".")
|
|
160
|
+
? p.resolve(location, entry.source)
|
|
161
|
+
: p.resolve(location, manifest.metadata?.pluginRoot ?? "", entry.source);
|
|
162
|
+
const json = read(p.join(plugin, ".claude-plugin", "plugin.json"));
|
|
163
|
+
return { version: json.version ?? entry.version ?? "0.0.0", plugin };
|
|
164
|
+
};
|
|
165
|
+
const recordCache = (dir: string) => {
|
|
166
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
167
|
+
const file = p.join(dir, ent.name);
|
|
168
|
+
if (ent.isDirectory()) recordCache(file);
|
|
169
|
+
else if (!ent.isSymbolicLink())
|
|
170
|
+
writes.push({
|
|
171
|
+
path: file,
|
|
172
|
+
bytes: fs.readFileSync(file).toString("base64"),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const lateChange = () => {
|
|
177
|
+
if (controls.lateFile) {
|
|
178
|
+
fs.mkdirSync(p.dirname(controls.lateFile.path), { recursive: true });
|
|
179
|
+
fs.writeFileSync(controls.lateFile.path, controls.lateFile.content);
|
|
180
|
+
}
|
|
181
|
+
if (controls.lateOff) {
|
|
182
|
+
const file = settingsFile(controls.lateOff);
|
|
183
|
+
const state = read(file);
|
|
184
|
+
state.enabledPlugins ??= {};
|
|
185
|
+
state.enabledPlugins["review-kit@acme"] = false;
|
|
186
|
+
write(file, state);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (tool === "claude") {
|
|
191
|
+
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
192
|
+
output("2.1.323 (Claude Code)");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (argv[0] !== "plugin")
|
|
196
|
+
fail(`Unsupported test Claude invocation: ${JSON.stringify(argv)}`);
|
|
197
|
+
if (argv[1] === "validate") {
|
|
198
|
+
if (argv.includes("--strict"))
|
|
199
|
+
fail("Strict validation is not the market contract");
|
|
200
|
+
if (
|
|
201
|
+
controls.advanceOnValidate &&
|
|
202
|
+
!fs.existsSync(p.join(control, "advanced"))
|
|
203
|
+
) {
|
|
204
|
+
fs.writeFileSync(p.join(control, "advanced"), "once");
|
|
205
|
+
const a = controls.advanceOnValidate;
|
|
206
|
+
fs.writeFileSync(
|
|
207
|
+
p.join(a.author, "remote-race.txt"),
|
|
208
|
+
"remote advance\n",
|
|
209
|
+
);
|
|
210
|
+
git(a.author, "add", "--", "remote-race.txt");
|
|
211
|
+
git(a.author, "commit", "-m", "concurrent remote change");
|
|
212
|
+
git(a.author, "push", "origin", "HEAD:main");
|
|
213
|
+
}
|
|
214
|
+
if (controls.validateFail) fail("Invalid plugin manifest");
|
|
215
|
+
output("Validation passed (warnings are allowed)");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (argv[1] === "marketplace") {
|
|
219
|
+
if (argv[2] === "list") {
|
|
220
|
+
output(
|
|
221
|
+
Object.entries(known()).map(([name, row]: [string, any]) => ({
|
|
222
|
+
name,
|
|
223
|
+
...row,
|
|
224
|
+
})),
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (argv[2] === "add") {
|
|
229
|
+
const source = argv[3]!;
|
|
230
|
+
const slug = sourceSlug(source);
|
|
231
|
+
if (controls.addFail || controls.denyReadSlug === slug)
|
|
232
|
+
fail(
|
|
233
|
+
`Your GitHub account cannot read ${slug} — ask its owner for access`,
|
|
234
|
+
);
|
|
235
|
+
const location = p.join(
|
|
236
|
+
config,
|
|
237
|
+
"plugins",
|
|
238
|
+
"marketplaces",
|
|
239
|
+
slug.split("/").pop()!,
|
|
240
|
+
);
|
|
241
|
+
fs.mkdirSync(p.dirname(location), { recursive: true });
|
|
242
|
+
git(
|
|
243
|
+
root,
|
|
244
|
+
"clone",
|
|
245
|
+
source.includes("://") || source.startsWith("git@")
|
|
246
|
+
? source
|
|
247
|
+
: `https://github.com/${source}.git`,
|
|
248
|
+
location,
|
|
249
|
+
);
|
|
250
|
+
const manifest = read(
|
|
251
|
+
p.join(location, ".claude-plugin", "marketplace.json"),
|
|
252
|
+
);
|
|
253
|
+
if (!manifest.name) fail("Marketplace has no name");
|
|
254
|
+
const finalLocation = p.join(
|
|
255
|
+
config,
|
|
256
|
+
"plugins",
|
|
257
|
+
"marketplaces",
|
|
258
|
+
manifest.name,
|
|
259
|
+
);
|
|
260
|
+
if (location !== finalLocation) fs.renameSync(location, finalLocation);
|
|
261
|
+
const entries = known();
|
|
262
|
+
entries[manifest.name] = {
|
|
263
|
+
source:
|
|
264
|
+
source.includes("://") || source.startsWith("git@")
|
|
265
|
+
? { source: "git", url: source }
|
|
266
|
+
: { source: "github", repo: source },
|
|
267
|
+
installLocation: finalLocation,
|
|
268
|
+
lastUpdated: new Date().toISOString(),
|
|
269
|
+
autoUpdate: controls.autoUpdate ?? true,
|
|
270
|
+
};
|
|
271
|
+
write(knownFile, entries);
|
|
272
|
+
output(`Added marketplace ${manifest.name}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (argv[2] === "remove") {
|
|
276
|
+
const name = argv[3]!;
|
|
277
|
+
const entries = known();
|
|
278
|
+
const location = entries[name]?.installLocation;
|
|
279
|
+
delete entries[name];
|
|
280
|
+
write(knownFile, entries);
|
|
281
|
+
const state = installed();
|
|
282
|
+
for (const id of Object.keys(state.plugins)) {
|
|
283
|
+
if (!id.endsWith(`@${name}`)) continue;
|
|
284
|
+
for (const row of state.plugins[id]) {
|
|
285
|
+
const file = settingsFile(
|
|
286
|
+
row.scope,
|
|
287
|
+
row.projectPath ?? process.cwd(),
|
|
288
|
+
);
|
|
289
|
+
const settings = read(file);
|
|
290
|
+
delete settings.enabledPlugins?.[id];
|
|
291
|
+
write(file, settings);
|
|
292
|
+
}
|
|
293
|
+
delete state.plugins[id];
|
|
294
|
+
}
|
|
295
|
+
write(installedFile, state);
|
|
296
|
+
if (location) fs.rmSync(location, { recursive: true, force: true });
|
|
297
|
+
output(`Removed marketplace ${name}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
fail(
|
|
301
|
+
`Forbidden or unsupported marketplace command: ${JSON.stringify(argv)}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (argv[1] === "list") {
|
|
305
|
+
output(
|
|
306
|
+
Object.entries(installed().plugins).flatMap(
|
|
307
|
+
([id, rows]: [string, any]) =>
|
|
308
|
+
rows.map((row: Json) => ({
|
|
309
|
+
id,
|
|
310
|
+
name: id.split("@")[0],
|
|
311
|
+
...row,
|
|
312
|
+
enabled: true,
|
|
313
|
+
})),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (argv[1] !== "install" && argv[1] !== "update")
|
|
319
|
+
fail(`Forbidden or unsupported plugin command: ${JSON.stringify(argv)}`);
|
|
320
|
+
const id = argv[2]!;
|
|
321
|
+
const scope =
|
|
322
|
+
argv.includes("--scope") || argv.some((a) => a.startsWith("--scope="))
|
|
323
|
+
? flag("--scope")!
|
|
324
|
+
: "user";
|
|
325
|
+
if (controls.failInstall) fail(controls.failInstall);
|
|
326
|
+
const { version, plugin } = pluginInfo(id);
|
|
327
|
+
const dest = p.join(
|
|
328
|
+
config,
|
|
329
|
+
"plugins",
|
|
330
|
+
"cache",
|
|
331
|
+
id.split("@")[1]!,
|
|
332
|
+
id.split("@")[0]!,
|
|
333
|
+
version,
|
|
334
|
+
);
|
|
335
|
+
fs.mkdirSync(p.dirname(dest), { recursive: true });
|
|
336
|
+
fs.cpSync(plugin, dest, { recursive: true });
|
|
337
|
+
recordCache(dest);
|
|
338
|
+
const state = installed();
|
|
339
|
+
state.plugins[id] ??= [];
|
|
340
|
+
let row = state.plugins[id].find(
|
|
341
|
+
(r: Json) =>
|
|
342
|
+
r.scope === scope &&
|
|
343
|
+
(scope === "user" || r.projectPath === process.cwd()),
|
|
344
|
+
);
|
|
345
|
+
if (controls.misdirect && scope === "project" && argv[1] === "update") {
|
|
346
|
+
row = state.plugins[id].find(
|
|
347
|
+
(r: Json) => r.scope === "project" && r.projectPath !== process.cwd(),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (!row) {
|
|
351
|
+
row = {
|
|
352
|
+
scope,
|
|
353
|
+
...(scope === "user" ? {} : { projectPath: process.cwd() }),
|
|
354
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
355
|
+
};
|
|
356
|
+
state.plugins[id].push(row);
|
|
357
|
+
}
|
|
358
|
+
row.version = version;
|
|
359
|
+
row.installPath = dest;
|
|
360
|
+
row.lastUpdated = "2026-01-01T00:00:00.000Z";
|
|
361
|
+
write(installedFile, state);
|
|
362
|
+
const file = settingsFile(scope);
|
|
363
|
+
const settings = read(file);
|
|
364
|
+
settings.enabledPlugins ??= {};
|
|
365
|
+
settings.enabledPlugins[id] = true;
|
|
366
|
+
write(file, settings);
|
|
367
|
+
lateChange();
|
|
368
|
+
output(`Installed ${id} v${version}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (tool !== "gh") fail(`Unsupported test provider ${tool}`);
|
|
373
|
+
if (controls.ghNetwork) fail("Could not resolve host: github.com");
|
|
374
|
+
if (controls.auth === false)
|
|
375
|
+
fail("Not logged in to any GitHub hosts. Run gh auth login.", 4);
|
|
376
|
+
if (argv[0] === "--version") {
|
|
377
|
+
output("gh version 2.80.0");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (argv[0] === "auth") {
|
|
381
|
+
output("Logged in to github.com as teammate");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (argv[0] === "repo" && argv[1] === "create") {
|
|
385
|
+
const slug = argv[2]!;
|
|
386
|
+
if (fs.existsSync(bare(slug))) fail("Name already exists");
|
|
387
|
+
fs.mkdirSync(p.dirname(bare(slug)), { recursive: true });
|
|
388
|
+
git(root, "init", "--bare", "-b", "main", bare(slug));
|
|
389
|
+
output(`https://github.com/${slug}`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (argv[0] === "repo" && argv[1] === "view") {
|
|
393
|
+
const slug = argv[2]!;
|
|
394
|
+
if (!fs.existsSync(bare(slug)) || controls.invisible)
|
|
395
|
+
fail("GraphQL: Could not resolve to a Repository", 1);
|
|
396
|
+
const data = {
|
|
397
|
+
name: slug.split("/")[1],
|
|
398
|
+
nameWithOwner: slug,
|
|
399
|
+
defaultBranchRef: { name: "main" },
|
|
400
|
+
viewerPermission: controls.push === false ? "READ" : "WRITE",
|
|
401
|
+
isPrivate: true,
|
|
402
|
+
};
|
|
403
|
+
const jq = flag("--jq");
|
|
404
|
+
output(jq?.includes("viewerPermission") ? data.viewerPermission : data);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (argv[0] === "pr" && argv[1] === "create") {
|
|
408
|
+
if (!argv.includes("--body-file") || !fs.existsSync(flag("--body-file")!))
|
|
409
|
+
fail("PR body must use an existing --body-file");
|
|
410
|
+
if (controls.prFail) fail("Could not open pull request");
|
|
411
|
+
output(`https://github.com/${flag("-R") ?? flag("--repo")}/pull/17`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (argv[0] === "api") {
|
|
415
|
+
const endpoint = argv.find(
|
|
416
|
+
(a) => a === "user" || a === "user/orgs" || a.startsWith("repos/"),
|
|
417
|
+
)!;
|
|
418
|
+
const jq = flag("--jq");
|
|
419
|
+
if (endpoint === "user") {
|
|
420
|
+
output(jq ? "teammate" : { login: "teammate" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (endpoint === "user/orgs") {
|
|
424
|
+
output(jq ? "acme" : [{ login: "acme" }]);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!endpoint) fail(`Unsupported gh api: ${JSON.stringify(argv)}`);
|
|
428
|
+
const parts = endpoint.split("/");
|
|
429
|
+
const slug = parts.slice(1, 3).join("/");
|
|
430
|
+
if (controls.invisible || !fs.existsSync(bare(slug)))
|
|
431
|
+
fail("HTTP 404: Not Found", 1);
|
|
432
|
+
if (parts[3] === "contents") {
|
|
433
|
+
try {
|
|
434
|
+
output(
|
|
435
|
+
git(
|
|
436
|
+
root,
|
|
437
|
+
"--git-dir",
|
|
438
|
+
bare(slug),
|
|
439
|
+
"show",
|
|
440
|
+
`HEAD:${parts.slice(4).join("/").split("?")[0]}`,
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
} catch {
|
|
444
|
+
fail("HTTP 404: Not Found", 1);
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (parts[3] === "forks") {
|
|
449
|
+
const fork = controls.forkName ?? "teammate/market-renamed";
|
|
450
|
+
fs.mkdirSync(p.dirname(bare(fork)), { recursive: true });
|
|
451
|
+
if (!fs.existsSync(bare(fork)))
|
|
452
|
+
git(root, "clone", "--bare", bare(slug), bare(fork));
|
|
453
|
+
fs.writeFileSync(
|
|
454
|
+
p.join(control, "fork-parent.json"),
|
|
455
|
+
JSON.stringify({ fork, parent: controls.forkParent ?? slug }),
|
|
456
|
+
);
|
|
457
|
+
output(jq ? fork : { full_name: fork });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const parent = read(p.join(control, "fork-parent.json"));
|
|
461
|
+
const data = {
|
|
462
|
+
full_name: slug,
|
|
463
|
+
default_branch: "main",
|
|
464
|
+
allow_forking: controls.forks !== false,
|
|
465
|
+
permissions: { push: controls.push !== false },
|
|
466
|
+
owner: { login: slug.split("/")[0] },
|
|
467
|
+
parent: parent.fork === slug ? { full_name: parent.parent } : null,
|
|
468
|
+
};
|
|
469
|
+
if (jq) {
|
|
470
|
+
if (jq.includes("permissions.push")) output(data.permissions.push);
|
|
471
|
+
else if (jq.includes("allow_forking")) output(data.allow_forking);
|
|
472
|
+
else if (jq.includes("parent")) output(data.parent?.full_name ?? "");
|
|
473
|
+
else if (jq.includes("default_branch")) output(data.default_branch);
|
|
474
|
+
else if (jq.includes("owner.login")) output(data.owner.login);
|
|
475
|
+
else if (jq.includes("full_name")) output(data.full_name);
|
|
476
|
+
else fail(`Unsupported gh jq: ${jq}`);
|
|
477
|
+
} else output(data);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
fail(`Unsupported gh invocation: ${JSON.stringify(argv)}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function filesUnder(dir: string): string[] {
|
|
484
|
+
if (!existsSync(dir)) return [];
|
|
485
|
+
const stat = lstatSync(dir);
|
|
486
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return [dir];
|
|
487
|
+
return readdirSync(dir)
|
|
488
|
+
.sort()
|
|
489
|
+
.flatMap((name) => filesUnder(join(dir, name)));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function write(file: string, bytes: string): void {
|
|
493
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
494
|
+
writeFileSync(file, bytes);
|
|
495
|
+
}
|
|
496
|
+
export function writeJson(file: string, value: unknown): void {
|
|
497
|
+
write(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
498
|
+
}
|
|
499
|
+
export function readJson<T = Record<string, any>>(file: string): T {
|
|
500
|
+
return JSON.parse(readFileSync(file, "utf8")) as T;
|
|
501
|
+
}
|
|
502
|
+
export function sourceModule(rel: string): string {
|
|
503
|
+
return new URL(`../${rel.replace(/^\.\//, "")}`, import.meta.url).href;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export class MarketFixture {
|
|
507
|
+
readonly root = realpathSync(
|
|
508
|
+
mkdtempSync(join(tmpdir(), "magus-market-test-")),
|
|
509
|
+
);
|
|
510
|
+
readonly home = join(this.root, "home");
|
|
511
|
+
readonly config: string;
|
|
512
|
+
readonly project = join(this.root, "project");
|
|
513
|
+
readonly temp = join(this.root, "tmp");
|
|
514
|
+
readonly bin = join(this.root, "bin");
|
|
515
|
+
readonly control = join(this.root, "control");
|
|
516
|
+
readonly gitBinary =
|
|
517
|
+
Bun.which("git") ??
|
|
518
|
+
(() => {
|
|
519
|
+
throw new Error("Real git is required");
|
|
520
|
+
})();
|
|
521
|
+
readonly env: NodeJS.ProcessEnv;
|
|
522
|
+
private modes: Record<string, unknown> = {};
|
|
523
|
+
private baseline = new Map<string, string>();
|
|
524
|
+
|
|
525
|
+
constructor(configOutsideHome = false) {
|
|
526
|
+
this.config = configOutsideHome
|
|
527
|
+
? join(this.root, "chosen-config")
|
|
528
|
+
: join(this.home, ".claude");
|
|
529
|
+
for (const dir of [
|
|
530
|
+
this.home,
|
|
531
|
+
this.config,
|
|
532
|
+
this.project,
|
|
533
|
+
this.temp,
|
|
534
|
+
this.bin,
|
|
535
|
+
this.control,
|
|
536
|
+
join(this.root, "remotes"),
|
|
537
|
+
join(this.root, "xdg-cache"),
|
|
538
|
+
join(this.root, "xdg-config"),
|
|
539
|
+
])
|
|
540
|
+
mkdirSync(dir, { recursive: true });
|
|
541
|
+
const gitConfig = join(this.control, "gitconfig");
|
|
542
|
+
this.env = {
|
|
543
|
+
HOME: this.home,
|
|
544
|
+
CLAUDE_CONFIG_DIR: this.config,
|
|
545
|
+
XDG_CACHE_HOME: join(this.root, "xdg-cache"),
|
|
546
|
+
XDG_CONFIG_HOME: join(this.root, "xdg-config"),
|
|
547
|
+
TMPDIR: this.temp,
|
|
548
|
+
TMP: this.temp,
|
|
549
|
+
TEMP: this.temp,
|
|
550
|
+
PATH: this.bin,
|
|
551
|
+
NO_COLOR: "1",
|
|
552
|
+
TERM: "xterm-256color",
|
|
553
|
+
CI: "1",
|
|
554
|
+
GIT_CONFIG_GLOBAL: gitConfig,
|
|
555
|
+
GIT_CONFIG_SYSTEM: devNull,
|
|
556
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
557
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
558
|
+
GCM_INTERACTIVE: "never",
|
|
559
|
+
GIT_ALLOW_PROTOCOL: "file",
|
|
560
|
+
GH_PROMPT_DISABLED: "1",
|
|
561
|
+
MARKET_TEST_ROOT: this.root,
|
|
562
|
+
MARKET_REAL_GIT: this.gitBinary,
|
|
563
|
+
};
|
|
564
|
+
symlinkSync(this.gitBinary, join(this.bin, "git"));
|
|
565
|
+
symlinkSync(process.execPath, join(this.bin, "bun"));
|
|
566
|
+
for (const name of ["sh", "which", "env", "uname"]) {
|
|
567
|
+
const binary = Bun.which(name);
|
|
568
|
+
if (binary) symlinkSync(binary, join(this.bin, name));
|
|
569
|
+
}
|
|
570
|
+
write(
|
|
571
|
+
gitConfig,
|
|
572
|
+
`${[
|
|
573
|
+
"[user]",
|
|
574
|
+
"\tname = Market Test",
|
|
575
|
+
"\temail = market-test@example.invalid",
|
|
576
|
+
"[init]",
|
|
577
|
+
"\tdefaultBranch = main",
|
|
578
|
+
"[commit]",
|
|
579
|
+
"\tgpgsign = false",
|
|
580
|
+
'[protocol "file"]',
|
|
581
|
+
"\tallow = always",
|
|
582
|
+
`[url ${JSON.stringify(pathToFileURL(`${join(this.root, "remotes")}/`).href)}]`,
|
|
583
|
+
"\tinsteadOf = https://github.com/",
|
|
584
|
+
`[url ${JSON.stringify(pathToFileURL(`${join(this.root, "remotes")}/`).href)}]`,
|
|
585
|
+
"\tinsteadOf = git@github.com:",
|
|
586
|
+
].join("\n")}\n`,
|
|
587
|
+
);
|
|
588
|
+
const provider = join(this.control, "provider.ts");
|
|
589
|
+
write(
|
|
590
|
+
provider,
|
|
591
|
+
`(${providerMain.toString()})().catch(e => { process.stderr.write(String(e.stack || e) + '\\n'); process.exit(1); });\n`,
|
|
592
|
+
);
|
|
593
|
+
const standIn = `#!${process.execPath} --no-env-file\nimport ${JSON.stringify(pathToFileURL(provider).href)};\n`;
|
|
594
|
+
for (const name of ["claude", "gh"]) {
|
|
595
|
+
write(join(this.bin, name), standIn);
|
|
596
|
+
chmodSync(join(this.bin, name), 0o755);
|
|
597
|
+
}
|
|
598
|
+
writeJson(join(this.control, "modes.json"), {});
|
|
599
|
+
write(join(this.control, "journal.jsonl"), "");
|
|
600
|
+
write(
|
|
601
|
+
join(this.control, "deny-network.ts"),
|
|
602
|
+
"globalThis.fetch = async () => { throw new Error('External network is forbidden in market tests'); };\n" +
|
|
603
|
+
"import net from 'node:net'; net.Socket.prototype.connect = function () { throw new Error('External sockets are forbidden in market tests'); } as any;\n",
|
|
604
|
+
);
|
|
605
|
+
this.git(this.project, "init", "-b", "main");
|
|
606
|
+
write(join(this.project, "README.md"), "Scratch project\n");
|
|
607
|
+
this.git(this.project, "add", "--", "README.md");
|
|
608
|
+
this.git(this.project, "commit", "-m", "initial project");
|
|
609
|
+
this.rememberOwned();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
setModes(patch: Record<string, unknown>): void {
|
|
613
|
+
this.modes = { ...this.modes, ...patch };
|
|
614
|
+
writeJson(join(this.control, "modes.json"), this.modes);
|
|
615
|
+
}
|
|
616
|
+
missingGh(): void {
|
|
617
|
+
rmSync(join(this.bin, "gh"));
|
|
618
|
+
}
|
|
619
|
+
run(args: string[], cwd = this.project, timeout = 30_000): ProcessResult {
|
|
620
|
+
const r = spawnSync(args[0]!, args.slice(1), {
|
|
621
|
+
cwd,
|
|
622
|
+
env: this.env,
|
|
623
|
+
input: "",
|
|
624
|
+
encoding: "utf8",
|
|
625
|
+
timeout,
|
|
626
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
627
|
+
});
|
|
628
|
+
if (r.error || r.signal)
|
|
629
|
+
throw new Error(
|
|
630
|
+
`Child did not finish: ${JSON.stringify({
|
|
631
|
+
args,
|
|
632
|
+
error: String(r.error),
|
|
633
|
+
signal: r.signal,
|
|
634
|
+
out: r.stdout,
|
|
635
|
+
err: r.stderr,
|
|
636
|
+
})}`,
|
|
637
|
+
);
|
|
638
|
+
return { code: r.status!, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
639
|
+
}
|
|
640
|
+
git(cwd: string, ...args: string[]): string {
|
|
641
|
+
const r = this.run([this.gitBinary, ...args], cwd);
|
|
642
|
+
if (r.code !== 0)
|
|
643
|
+
throw new Error(`Test git failed ${args.join(" ")}\n${r.err}`);
|
|
644
|
+
return r.out.trim();
|
|
645
|
+
}
|
|
646
|
+
evaluate(body: string, cwd = this.project): ProcessResult {
|
|
647
|
+
return this.run(
|
|
648
|
+
[
|
|
649
|
+
process.execPath,
|
|
650
|
+
"--no-env-file",
|
|
651
|
+
"--preload",
|
|
652
|
+
join(this.control, "deny-network.ts"),
|
|
653
|
+
"--eval",
|
|
654
|
+
body,
|
|
655
|
+
],
|
|
656
|
+
cwd,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
value<T>(body: string, cwd = this.project): T {
|
|
660
|
+
const r = this.evaluate(body, cwd);
|
|
661
|
+
if (r.code !== 0)
|
|
662
|
+
throw new Error(`Public API child failed\n${r.out}\n${r.err}`);
|
|
663
|
+
const last = r.out.trim().split("\n").at(-1);
|
|
664
|
+
if (!last) throw new Error(`Public API child printed no JSON\n${r.err}`);
|
|
665
|
+
return JSON.parse(last) as T;
|
|
666
|
+
}
|
|
667
|
+
cli(args: string[], cwd = this.project): ProcessResult {
|
|
668
|
+
return this.run(
|
|
669
|
+
[
|
|
670
|
+
process.execPath,
|
|
671
|
+
"--no-env-file",
|
|
672
|
+
"--preload",
|
|
673
|
+
join(this.control, "deny-network.ts"),
|
|
674
|
+
join(import.meta.dir, "..", "main.tsx"),
|
|
675
|
+
...args,
|
|
676
|
+
],
|
|
677
|
+
cwd,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
market(args: string[], cwd = this.project): MarketRun {
|
|
681
|
+
const r = this.cli(["market", ...args, "--json"], cwd);
|
|
682
|
+
const lines = r.out.trimEnd().split(/\r?\n/);
|
|
683
|
+
const last = lines.at(-1);
|
|
684
|
+
if (!last || !last.startsWith("{"))
|
|
685
|
+
throw new Error(`No final-line MarketResult\n${r.out}\n${r.err}`);
|
|
686
|
+
const result = JSON.parse(last) as MarketResult;
|
|
687
|
+
expect(result.command.startsWith("market ")).toBe(true);
|
|
688
|
+
expect([
|
|
689
|
+
"ok",
|
|
690
|
+
"unchanged",
|
|
691
|
+
"plugin-newer",
|
|
692
|
+
"pull-request",
|
|
693
|
+
"needs-consent",
|
|
694
|
+
"stale",
|
|
695
|
+
"partial",
|
|
696
|
+
"failed",
|
|
697
|
+
]).toContain(result.outcome);
|
|
698
|
+
expect(typeof result.message).toBe("string");
|
|
699
|
+
if (result.planId !== undefined)
|
|
700
|
+
expect(typeof result.planId).toBe("string");
|
|
701
|
+
if (result.stale !== undefined) expect(typeof result.stale).toBe("boolean");
|
|
702
|
+
if (result.target !== undefined)
|
|
703
|
+
expect(["push-or-pr", "fork-pr"]).toContain(result.target);
|
|
704
|
+
if (result.error !== undefined) {
|
|
705
|
+
expect(typeof result.error.message).toBe("string");
|
|
706
|
+
expect([
|
|
707
|
+
"gh-missing",
|
|
708
|
+
"gh-auth",
|
|
709
|
+
"no-access",
|
|
710
|
+
"no-network",
|
|
711
|
+
"conflict",
|
|
712
|
+
"no-base",
|
|
713
|
+
"name-taken",
|
|
714
|
+
"not-found",
|
|
715
|
+
"ambiguous",
|
|
716
|
+
"invalid-name",
|
|
717
|
+
"external-plugin",
|
|
718
|
+
"claude-missing",
|
|
719
|
+
"validate-failed",
|
|
720
|
+
"locked",
|
|
721
|
+
"not-company",
|
|
722
|
+
"fork-not-allowed",
|
|
723
|
+
"install-failed",
|
|
724
|
+
"plan-changed",
|
|
725
|
+
"stale",
|
|
726
|
+
"timeout",
|
|
727
|
+
"signing-timeout",
|
|
728
|
+
"other",
|
|
729
|
+
]).toContain(result.error.code);
|
|
730
|
+
}
|
|
731
|
+
for (const file of result.files ?? []) {
|
|
732
|
+
expect(typeof file.path).toBe("string");
|
|
733
|
+
expect(["added", "modified", "deleted"]).toContain(file.change);
|
|
734
|
+
}
|
|
735
|
+
for (const file of result.notPublished ?? []) {
|
|
736
|
+
expect(typeof file.path).toBe("string");
|
|
737
|
+
expect([
|
|
738
|
+
"dotfile",
|
|
739
|
+
"node_modules",
|
|
740
|
+
"symlink",
|
|
741
|
+
"ignored-here",
|
|
742
|
+
"ignored-by-marketplace",
|
|
743
|
+
]).toContain(file.reason);
|
|
744
|
+
}
|
|
745
|
+
for (const row of result.installs ?? []) {
|
|
746
|
+
expect(["user", "project", "local"]).toContain(row.scope);
|
|
747
|
+
expect(["updated", "unchanged", "failed"]).toContain(row.status);
|
|
748
|
+
expect(row.from === null || typeof row.from === "string").toBe(true);
|
|
749
|
+
expect(row.to === null || typeof row.to === "string").toBe(true);
|
|
750
|
+
if (row.loaded !== undefined)
|
|
751
|
+
expect(row.loaded === null || typeof row.loaded === "string").toBe(
|
|
752
|
+
true,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
if (result.localCopy !== undefined) {
|
|
756
|
+
expect(["project", "user"]).toContain(result.localCopy.scope);
|
|
757
|
+
expect(["removed", "kept", "created"]).toContain(result.localCopy.action);
|
|
758
|
+
expect(typeof result.localCopy.name).toBe("string");
|
|
759
|
+
}
|
|
760
|
+
for (const row of result.marketplaces ?? []) {
|
|
761
|
+
expect(row.scope).toBe("user");
|
|
762
|
+
expect(typeof row.name).toBe("string");
|
|
763
|
+
expect(typeof row.repo).toBe("string");
|
|
764
|
+
expect(Array.isArray(row.plugins)).toBe(true);
|
|
765
|
+
}
|
|
766
|
+
for (const row of result.skills ?? []) {
|
|
767
|
+
expect(typeof row.skill).toBe("string");
|
|
768
|
+
expect(["project", "user"]).toContain(row.from);
|
|
769
|
+
expect(row.owner === null || typeof row.owner === "string").toBe(true);
|
|
770
|
+
expect([
|
|
771
|
+
"edited",
|
|
772
|
+
"same",
|
|
773
|
+
"plugin-newer",
|
|
774
|
+
"conflict",
|
|
775
|
+
"no-base",
|
|
776
|
+
"not-checked",
|
|
777
|
+
"ambiguous",
|
|
778
|
+
"not-owned",
|
|
779
|
+
]).toContain(row.state);
|
|
780
|
+
}
|
|
781
|
+
expect(Array.isArray(result.steps)).toBe(true);
|
|
782
|
+
for (const step of result.steps) {
|
|
783
|
+
expect(typeof step.id).toBe("string");
|
|
784
|
+
expect(["done", "skipped", "failed"]).toContain(step.status);
|
|
785
|
+
}
|
|
786
|
+
return { ...r, result };
|
|
787
|
+
}
|
|
788
|
+
ok(args: string[], cwd = this.project): MarketRun {
|
|
789
|
+
const r = this.market(args, cwd);
|
|
790
|
+
if (r.code !== 0)
|
|
791
|
+
throw new Error(
|
|
792
|
+
`Setup operation failed ${args.join(" ")}\n${r.out}\n${r.err}`,
|
|
793
|
+
);
|
|
794
|
+
return r;
|
|
795
|
+
}
|
|
796
|
+
tool(
|
|
797
|
+
tool: "claude" | "gh",
|
|
798
|
+
args: string[],
|
|
799
|
+
cwd = this.project,
|
|
800
|
+
): ProcessResult {
|
|
801
|
+
return this.run([join(this.bin, tool), ...args], cwd);
|
|
802
|
+
}
|
|
803
|
+
seed(options: SeedOptions = {}): Repo {
|
|
804
|
+
const name = options.name ?? "acme";
|
|
805
|
+
const slug = options.slug ?? "acme/market";
|
|
806
|
+
const bare = join(this.root, "remotes", `${slug}.git`);
|
|
807
|
+
const author = join(this.root, `author-${name}`);
|
|
808
|
+
mkdirSync(dirname(bare), { recursive: true });
|
|
809
|
+
this.git(this.root, "init", "--bare", "-b", "main", bare);
|
|
810
|
+
this.git(this.root, "clone", bare, author);
|
|
811
|
+
const repo = {
|
|
812
|
+
name,
|
|
813
|
+
slug,
|
|
814
|
+
bare,
|
|
815
|
+
author,
|
|
816
|
+
url: `https://github.com/${slug}.git`,
|
|
817
|
+
};
|
|
818
|
+
if (options.empty) return repo;
|
|
819
|
+
write(join(author, "README.md"), "Existing repository documentation\n");
|
|
820
|
+
if (!options.noManifest) {
|
|
821
|
+
const version =
|
|
822
|
+
options.entryVersion === undefined ? "0.3.1" : options.entryVersion;
|
|
823
|
+
writeJson(join(author, MANIFEST), {
|
|
824
|
+
name,
|
|
825
|
+
owner: { name: slug.split("/")[0] },
|
|
826
|
+
metadata: {
|
|
827
|
+
description: "Company tools",
|
|
828
|
+
...(options.marker === false
|
|
829
|
+
? {}
|
|
830
|
+
: { magus: { schema: 1, company: true } }),
|
|
831
|
+
},
|
|
832
|
+
plugins: [
|
|
833
|
+
{
|
|
834
|
+
name: PLUGIN,
|
|
835
|
+
source: `./plugins/${PLUGIN}`,
|
|
836
|
+
description: "Reviews changes.",
|
|
837
|
+
...(version === null ? {} : { version }),
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
});
|
|
841
|
+
if (!options.noPluginJson) {
|
|
842
|
+
const v =
|
|
843
|
+
options.pluginVersion === undefined ? "0.3.1" : options.pluginVersion;
|
|
844
|
+
writeJson(join(author, PLUGIN_MANIFEST), {
|
|
845
|
+
name: PLUGIN,
|
|
846
|
+
description: "Reviews changes.",
|
|
847
|
+
...(v === null ? {} : { version: v }),
|
|
848
|
+
...(options.dependencies
|
|
849
|
+
? { dependencies: options.dependencies }
|
|
850
|
+
: {}),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
write(join(author, SKILL_REL), SKILL);
|
|
854
|
+
}
|
|
855
|
+
for (const [file, bytes] of Object.entries(options.extras ?? {}))
|
|
856
|
+
write(join(author, file), bytes);
|
|
857
|
+
this.push(repo, "seed marketplace");
|
|
858
|
+
return repo;
|
|
859
|
+
}
|
|
860
|
+
push(repo: Repo, message = "teammate change"): string {
|
|
861
|
+
this.git(repo.author, "add", "-A");
|
|
862
|
+
this.git(repo.author, "commit", "-m", message);
|
|
863
|
+
this.git(repo.author, "push", "origin", "HEAD:main");
|
|
864
|
+
return this.head(repo);
|
|
865
|
+
}
|
|
866
|
+
head(repo: Repo, ref = "HEAD"): string {
|
|
867
|
+
return this.git(this.root, "--git-dir", repo.bare, "rev-parse", ref);
|
|
868
|
+
}
|
|
869
|
+
remoteFile(repo: Repo, file: string, ref = "HEAD"): string {
|
|
870
|
+
return this.git(
|
|
871
|
+
this.root,
|
|
872
|
+
"--git-dir",
|
|
873
|
+
repo.bare,
|
|
874
|
+
"show",
|
|
875
|
+
`${ref}:${file}`,
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
remoteBytes(repo: Repo, file: string, ref = "HEAD"): string {
|
|
879
|
+
const r = this.run(
|
|
880
|
+
[
|
|
881
|
+
this.gitBinary,
|
|
882
|
+
"--git-dir",
|
|
883
|
+
repo.bare,
|
|
884
|
+
"cat-file",
|
|
885
|
+
"blob",
|
|
886
|
+
`${ref}:${file}`,
|
|
887
|
+
],
|
|
888
|
+
this.root,
|
|
889
|
+
);
|
|
890
|
+
if (r.code !== 0) throw new Error(`Cannot read remote blob\n${r.err}`);
|
|
891
|
+
return r.out;
|
|
892
|
+
}
|
|
893
|
+
remoteJson<T = Record<string, any>>(
|
|
894
|
+
repo: Repo,
|
|
895
|
+
file: string,
|
|
896
|
+
ref = "HEAD",
|
|
897
|
+
): T {
|
|
898
|
+
return JSON.parse(this.remoteFile(repo, file, ref)) as T;
|
|
899
|
+
}
|
|
900
|
+
remotePaths(repo: Repo, ref = "HEAD"): string[] {
|
|
901
|
+
return this.git(
|
|
902
|
+
this.root,
|
|
903
|
+
"--git-dir",
|
|
904
|
+
repo.bare,
|
|
905
|
+
"ls-tree",
|
|
906
|
+
"-r",
|
|
907
|
+
"--name-only",
|
|
908
|
+
ref,
|
|
909
|
+
)
|
|
910
|
+
.split("\n")
|
|
911
|
+
.filter(Boolean);
|
|
912
|
+
}
|
|
913
|
+
clone(repo: Repo): string {
|
|
914
|
+
return join(this.config, "plugins", "marketplaces", repo.name);
|
|
915
|
+
}
|
|
916
|
+
working(repo: Repo): string {
|
|
917
|
+
return join(this.config, "magus-market", repo.name);
|
|
918
|
+
}
|
|
919
|
+
register(
|
|
920
|
+
repo: Repo,
|
|
921
|
+
opts: {
|
|
922
|
+
trust?: boolean;
|
|
923
|
+
sourceType?: "github" | "git";
|
|
924
|
+
source?: string;
|
|
925
|
+
} = {},
|
|
926
|
+
): void {
|
|
927
|
+
const r = this.tool("claude", [
|
|
928
|
+
"plugin",
|
|
929
|
+
"marketplace",
|
|
930
|
+
"add",
|
|
931
|
+
opts.source ?? repo.slug,
|
|
932
|
+
"--scope",
|
|
933
|
+
"user",
|
|
934
|
+
]);
|
|
935
|
+
if (r.code !== 0) throw new Error(`Stand-in registration failed\n${r.err}`);
|
|
936
|
+
if (opts.sourceType === "github")
|
|
937
|
+
this.registration(repo, {
|
|
938
|
+
source: "github",
|
|
939
|
+
repo: opts.source ?? repo.slug,
|
|
940
|
+
});
|
|
941
|
+
if (opts.trust !== false) {
|
|
942
|
+
const file = join(this.config, "magus-market", "trusted.json");
|
|
943
|
+
const list = existsSync(file)
|
|
944
|
+
? readJson(file)
|
|
945
|
+
: { schema: 1, marketplaces: {} };
|
|
946
|
+
list.marketplaces[repo.name] = { source: repo.slug, via: "connect" };
|
|
947
|
+
writeJson(file, list);
|
|
948
|
+
}
|
|
949
|
+
this.rememberOwned();
|
|
950
|
+
}
|
|
951
|
+
registration(repo: Repo, source: Record<string, unknown>): void {
|
|
952
|
+
const file = join(this.config, "plugins", "known_marketplaces.json");
|
|
953
|
+
const entries = existsSync(file) ? readJson(file) : {};
|
|
954
|
+
entries[repo.name] = {
|
|
955
|
+
...entries[repo.name],
|
|
956
|
+
source,
|
|
957
|
+
installLocation: this.clone(repo),
|
|
958
|
+
lastUpdated: new Date().toISOString(),
|
|
959
|
+
};
|
|
960
|
+
writeJson(file, entries);
|
|
961
|
+
this.rememberOwned();
|
|
962
|
+
}
|
|
963
|
+
install(
|
|
964
|
+
id = ID,
|
|
965
|
+
scope: "user" | "project" | "local" = "user",
|
|
966
|
+
cwd = this.project,
|
|
967
|
+
): void {
|
|
968
|
+
const r = this.tool(
|
|
969
|
+
"claude",
|
|
970
|
+
["plugin", "install", id, "--scope", scope],
|
|
971
|
+
cwd,
|
|
972
|
+
);
|
|
973
|
+
if (r.code !== 0) throw new Error(`Stand-in install failed\n${r.err}`);
|
|
974
|
+
this.rememberOwned();
|
|
975
|
+
}
|
|
976
|
+
version(id = ID, scope = "user", cwd = this.project): string | null {
|
|
977
|
+
const file = join(this.config, "plugins", "installed_plugins.json");
|
|
978
|
+
if (!existsSync(file)) return null;
|
|
979
|
+
const state = readJson(file);
|
|
980
|
+
return (
|
|
981
|
+
state.plugins[id]?.find(
|
|
982
|
+
(r: Record<string, string>) =>
|
|
983
|
+
r.scope === scope && (scope === "user" || r.projectPath === cwd),
|
|
984
|
+
)?.version ?? null
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
folder(from: "project" | "global" = "project", name = "review"): string {
|
|
988
|
+
return join(
|
|
989
|
+
from === "global" ? this.config : join(this.project, ".claude"),
|
|
990
|
+
"skills",
|
|
991
|
+
name,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
edit(args: string[] = []): void {
|
|
995
|
+
this.ok(["skill", "edit", "review", "--plugin", ID, "--yes", ...args]);
|
|
996
|
+
}
|
|
997
|
+
async prepared(): Promise<Repo> {
|
|
998
|
+
const repo = this.seed();
|
|
999
|
+
this.register(repo);
|
|
1000
|
+
this.install();
|
|
1001
|
+
this.edit();
|
|
1002
|
+
write(join(this.folder(), "SKILL.md"), EDITED);
|
|
1003
|
+
return repo;
|
|
1004
|
+
}
|
|
1005
|
+
profile(
|
|
1006
|
+
plugins: Record<string, string | boolean> = { [ID]: "latest" },
|
|
1007
|
+
marketplaces: Record<string, unknown> = {
|
|
1008
|
+
acme: { source: "github", repo: "acme/market" },
|
|
1009
|
+
},
|
|
1010
|
+
cwd = this.project,
|
|
1011
|
+
): void {
|
|
1012
|
+
writeJson(join(cwd, ".claude", "profiles.json"), {
|
|
1013
|
+
version: 2,
|
|
1014
|
+
profiles: { team: { name: "Team", plugins, marketplaces } },
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
active(skills: Record<string, string> = {}): void {
|
|
1018
|
+
writeJson(join(this.project, ".claude", "profiles", "active.json"), {
|
|
1019
|
+
version: 1,
|
|
1020
|
+
profile: "team",
|
|
1021
|
+
files: {},
|
|
1022
|
+
skills,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
snapshot(): Record<string, string> {
|
|
1026
|
+
const roots = [
|
|
1027
|
+
this.home,
|
|
1028
|
+
this.config,
|
|
1029
|
+
this.project,
|
|
1030
|
+
this.temp,
|
|
1031
|
+
this.env.XDG_CACHE_HOME!,
|
|
1032
|
+
this.env.XDG_CONFIG_HOME!,
|
|
1033
|
+
];
|
|
1034
|
+
const result: Record<string, string> = {};
|
|
1035
|
+
const bunCache = join(this.env.XDG_CACHE_HOME!, "bun");
|
|
1036
|
+
const visit = (file: string) => {
|
|
1037
|
+
// Bun owns its runtime cache; keep checking all feature state around it.
|
|
1038
|
+
if (file === bunCache) return;
|
|
1039
|
+
const stat = lstatSync(file);
|
|
1040
|
+
const key = relative(this.root, file);
|
|
1041
|
+
if (stat.isSymbolicLink()) result[key] = `link:${readlinkSync(file)}`;
|
|
1042
|
+
else if (stat.isDirectory()) {
|
|
1043
|
+
result[key] = "directory";
|
|
1044
|
+
for (const name of readdirSync(file).sort()) visit(join(file, name));
|
|
1045
|
+
} else
|
|
1046
|
+
result[key] =
|
|
1047
|
+
`${createHash("sha256").update(readFileSync(file)).digest("hex")}:${stat.mode & 0o777}`;
|
|
1048
|
+
};
|
|
1049
|
+
for (const root of new Set(roots)) visit(root);
|
|
1050
|
+
return result;
|
|
1051
|
+
}
|
|
1052
|
+
journal(): JournalEvent[] {
|
|
1053
|
+
return readFileSync(join(this.control, "journal.jsonl"), "utf8")
|
|
1054
|
+
.trim()
|
|
1055
|
+
.split("\n")
|
|
1056
|
+
.filter(Boolean)
|
|
1057
|
+
.map((line) => JSON.parse(line));
|
|
1058
|
+
}
|
|
1059
|
+
private ownedFiles(): string[] {
|
|
1060
|
+
const configFiles = [
|
|
1061
|
+
join(this.config, "plugins", "known_marketplaces.json"),
|
|
1062
|
+
join(this.config, "plugins", "installed_plugins.json"),
|
|
1063
|
+
].filter(existsSync);
|
|
1064
|
+
const settings = [
|
|
1065
|
+
...new Set([this.home, this.config, this.project]),
|
|
1066
|
+
].flatMap((root) =>
|
|
1067
|
+
filesUnder(root).filter(
|
|
1068
|
+
(file) =>
|
|
1069
|
+
["settings.json", "settings.local.json"].includes(basename(file)) &&
|
|
1070
|
+
!file.includes(join("plugins", "marketplaces")) &&
|
|
1071
|
+
!file.includes(join("magus-market", "")),
|
|
1072
|
+
),
|
|
1073
|
+
);
|
|
1074
|
+
return [
|
|
1075
|
+
...new Set([
|
|
1076
|
+
...configFiles,
|
|
1077
|
+
...settings,
|
|
1078
|
+
...filesUnder(join(this.config, "plugins", "cache")),
|
|
1079
|
+
]),
|
|
1080
|
+
].sort();
|
|
1081
|
+
}
|
|
1082
|
+
rememberOwned(): void {
|
|
1083
|
+
this.baseline = new Map(
|
|
1084
|
+
this.ownedFiles().map((file) => [
|
|
1085
|
+
file,
|
|
1086
|
+
readFileSync(file).toString("base64"),
|
|
1087
|
+
]),
|
|
1088
|
+
);
|
|
1089
|
+
// A setup write is the new baseline, so subsequent product writes cannot
|
|
1090
|
+
// be legitimised by an earlier provider event.
|
|
1091
|
+
write(join(this.control, "journal.jsonl"), "");
|
|
1092
|
+
}
|
|
1093
|
+
/** TEST-50 REQ-4 REQ-19: compare actual bytes, never private writer calls. */
|
|
1094
|
+
assertOwned(): void {
|
|
1095
|
+
const expected = new Map(this.baseline);
|
|
1096
|
+
for (const event of this.journal()) {
|
|
1097
|
+
if (event.tool !== "claude") continue;
|
|
1098
|
+
expect(event.argv.slice(0, 3)).not.toEqual([
|
|
1099
|
+
"plugin",
|
|
1100
|
+
"marketplace",
|
|
1101
|
+
"update",
|
|
1102
|
+
]);
|
|
1103
|
+
expect(event.argv.slice(0, 2)).not.toEqual(["plugin", "uninstall"]);
|
|
1104
|
+
for (const item of event.writes) {
|
|
1105
|
+
if (item.bytes === null) expected.delete(item.path);
|
|
1106
|
+
else expected.set(item.path, item.bytes);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const paths = new Set([...this.ownedFiles(), ...expected.keys()]);
|
|
1110
|
+
for (const file of paths) {
|
|
1111
|
+
const actual = existsSync(file)
|
|
1112
|
+
? readFileSync(file).toString("base64")
|
|
1113
|
+
: undefined;
|
|
1114
|
+
expect({ path: relative(this.root, file), bytes: actual }).toEqual({
|
|
1115
|
+
path: relative(this.root, file),
|
|
1116
|
+
bytes: expected.get(file),
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
protect(repo: Repo, denied = false): void {
|
|
1121
|
+
const hook = join(repo.bare, "hooks", "update");
|
|
1122
|
+
write(
|
|
1123
|
+
hook,
|
|
1124
|
+
`#!${process.execPath} --no-env-file\nif (${denied ? "true" : "process.argv[2] === 'refs/heads/main'"}) {process.stderr.write(${JSON.stringify(
|
|
1125
|
+
denied
|
|
1126
|
+
? "Permission to acme/market denied to teammate.\\n"
|
|
1127
|
+
: "GH006: Protected branch update failed for refs/heads/main.\\n",
|
|
1128
|
+
)}); process.exit(1); }\n`,
|
|
1129
|
+
);
|
|
1130
|
+
chmodSync(hook, 0o755);
|
|
1131
|
+
}
|
|
1132
|
+
close(): void {
|
|
1133
|
+
rmSync(this.root, { recursive: true, force: true });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
export function expectFailure(
|
|
1138
|
+
run: MarketRun,
|
|
1139
|
+
code: number,
|
|
1140
|
+
error: string,
|
|
1141
|
+
): void {
|
|
1142
|
+
expect({
|
|
1143
|
+
exit: run.code,
|
|
1144
|
+
outcome: run.result.outcome,
|
|
1145
|
+
error: run.result.error?.code,
|
|
1146
|
+
}).toEqual({
|
|
1147
|
+
exit: code,
|
|
1148
|
+
outcome:
|
|
1149
|
+
code === 3 ? (error === "stale" ? "stale" : "needs-consent") : "failed",
|
|
1150
|
+
error,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export function expectPublished(
|
|
1155
|
+
f: MarketFixture,
|
|
1156
|
+
repo: Repo,
|
|
1157
|
+
version = "0.3.2",
|
|
1158
|
+
bytes = EDITED,
|
|
1159
|
+
): void {
|
|
1160
|
+
expect(
|
|
1161
|
+
f
|
|
1162
|
+
.remoteJson(repo, MANIFEST)
|
|
1163
|
+
.plugins.find((p: Record<string, string>) => p.name === PLUGIN)?.version,
|
|
1164
|
+
).toBe(version);
|
|
1165
|
+
expect(f.remoteJson(repo, PLUGIN_MANIFEST).version).toBe(version);
|
|
1166
|
+
expect(f.remoteBytes(repo, SKILL_REL)).toBe(bytes);
|
|
1167
|
+
}
|