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,572 @@
1
+ /**
2
+ * market-clone.ts — magus's own working clone of a company marketplace, and
3
+ * the read-only view of a marketplace that every plan starts from.
4
+ *
5
+ * The working clone lives at `<claude config dir>/magus-market/<name>/`. It is
6
+ * not Claude Code's clone (a local commit would make that one dirty, and
7
+ * Claude Code deletes it on refresh) and it is disposable: every apply and
8
+ * every `market fetch` starts with fetch + `reset --hard` + `clean -ffdx`, and
9
+ * every apply ends with `reset --hard` + `clean -ffdx`.
10
+ *
11
+ * Plans never touch it. {@link readRemoteState} asks the remote for its head
12
+ * with `ls-remote` and reads whichever local copy already sits at that commit,
13
+ * through git's object store only.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
20
+ import {
21
+ type GitFailure,
22
+ type GitResult,
23
+ type TreeEntry,
24
+ catFile,
25
+ cleanAll,
26
+ cloneFull,
27
+ fetch as gitFetch,
28
+ lsRemoteHead,
29
+ lsTree,
30
+ pushRef,
31
+ remoteUrl,
32
+ resetToRemote,
33
+ revParse,
34
+ } from "./git-write.js";
35
+ import {
36
+ forkParent,
37
+ ghAvailable,
38
+ prCreate,
39
+ repoFork,
40
+ repoForkAllowed,
41
+ viewerLogin,
42
+ } from "./github-cli.js";
43
+ import { type TrustedRegistration, isGithubSource } from "./market-registry.js";
44
+ import { type DeliveryTarget, MarketError } from "./market-types.js";
45
+
46
+ // ─── Paths ───────────────────────────────────────────────────────────────────
47
+
48
+ /** `<claude config dir>/magus-market`: magus's own files for `magus market`. */
49
+ export function marketRoot(): string {
50
+ return path.join(requireClaudeConfigDir(), "magus-market");
51
+ }
52
+
53
+ /** The working clone of marketplace `name`. */
54
+ export function workingClonePath(name: string): string {
55
+ return path.join(marketRoot(), name);
56
+ }
57
+
58
+ /** The URL git clones and pushes for a normalised source. */
59
+ export function cloneUrl(source: string): string {
60
+ return isGithubSource(source) ? `https://github.com/${source}.git` : source;
61
+ }
62
+
63
+ // ─── Git failures as market errors ───────────────────────────────────────────
64
+
65
+ const SIGN_IN_FIX = "run `gh auth login`, then `gh auth setup-git`";
66
+
67
+ /** A git failure in the terms of a market verb (the table in the CLI docs). */
68
+ export function gitError(
69
+ r: { failure: GitFailure; message: string },
70
+ what: string,
71
+ ): MarketError {
72
+ switch (r.failure) {
73
+ case "no-network":
74
+ return new MarketError("no-network", `Could not reach ${what}`);
75
+ case "auth":
76
+ return new MarketError(
77
+ "gh-auth",
78
+ `Not signed in to GitHub for ${what}`,
79
+ SIGN_IN_FIX,
80
+ );
81
+ case "denied":
82
+ return new MarketError(
83
+ "no-access",
84
+ `Your account cannot write to ${what}`,
85
+ "ask the owner for write access, or install gh and sign in so magus can open a pull request",
86
+ );
87
+ case "rejected-non-ff":
88
+ return new MarketError(
89
+ "plan-changed",
90
+ `${what} changed on GitHub while this ran — nothing was pushed`,
91
+ "run the command again to see the new plan",
92
+ );
93
+ case "rejected-protected":
94
+ return new MarketError(
95
+ "other",
96
+ `${what} only takes changes through pull requests`,
97
+ );
98
+ case "timeout":
99
+ return new MarketError("timeout", `git did not finish in time (${what})`);
100
+ case "signing-timeout":
101
+ return new MarketError(
102
+ "signing-timeout",
103
+ "commit signing did not finish — approve it and run again",
104
+ );
105
+ default:
106
+ return new MarketError("other", r.message);
107
+ }
108
+ }
109
+
110
+ function must<T>(r: GitResult<T>, what: string): T {
111
+ if (!r.ok) throw gitError(r, what);
112
+ return r.value;
113
+ }
114
+
115
+ // ─── Lock ────────────────────────────────────────────────────────────────────
116
+
117
+ const LOCK_ATTEMPTS = 20;
118
+ const LOCK_RETRY_MS = 50;
119
+ const EMPTY_LOCK_STALE_MS = 10_000;
120
+
121
+ function alive(pid: number): boolean {
122
+ try {
123
+ process.kill(pid, 0);
124
+ return true;
125
+ } catch (error) {
126
+ return (error as NodeJS.ErrnoException).code === "EPERM";
127
+ }
128
+ }
129
+
130
+ function removeIfPresent(file: string): void {
131
+ try {
132
+ fs.unlinkSync(file);
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Take the lock for marketplace `name`. Same scheme as the dependency
140
+ * installer's: the pid is written to a file of our own and the lock is a hard
141
+ * link to it, so the lock never exists without its holder's pid. A live holder
142
+ * refuses with `locked`; a dead one's lock is taken over. Returns the release.
143
+ */
144
+ export async function acquireMarketLock(name: string): Promise<() => void> {
145
+ const lock = path.join(marketRoot(), `${name}.lock`);
146
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
147
+ const mine = String(process.pid);
148
+ const staged = `${lock}.${mine}`;
149
+ fs.writeFileSync(staged, mine, { mode: 0o600 });
150
+ const busy = (pid: number | null) =>
151
+ new MarketError(
152
+ "locked",
153
+ `Another magus market command is working on ${name}${pid ? ` (pid ${pid})` : ""}`,
154
+ "wait for it to finish, then run this again",
155
+ );
156
+ try {
157
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
158
+ try {
159
+ fs.linkSync(staged, lock);
160
+ return () => {
161
+ try {
162
+ if (fs.readFileSync(lock, "utf8").trim() === mine) {
163
+ fs.unlinkSync(lock);
164
+ }
165
+ } catch {
166
+ // Already released.
167
+ }
168
+ };
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
171
+ }
172
+ let stat: fs.Stats;
173
+ let text: string;
174
+ try {
175
+ stat = fs.lstatSync(lock);
176
+ text = stat.isFile() ? fs.readFileSync(lock, "utf8").trim() : "";
177
+ } catch (error) {
178
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
179
+ throw error;
180
+ }
181
+ if (!stat.isFile()) {
182
+ removeIfPresent(lock);
183
+ continue;
184
+ }
185
+ const pid = Number.parseInt(text, 10);
186
+ if (Number.isInteger(pid) && pid > 0) {
187
+ if (alive(pid)) throw busy(pid);
188
+ removeIfPresent(lock);
189
+ continue;
190
+ }
191
+ if (Date.now() - stat.mtimeMs > EMPTY_LOCK_STALE_MS) {
192
+ removeIfPresent(lock);
193
+ continue;
194
+ }
195
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
196
+ }
197
+ throw busy(null);
198
+ } finally {
199
+ fs.rmSync(staged, { force: true });
200
+ }
201
+ }
202
+
203
+ // ─── Syncing the working clone ───────────────────────────────────────────────
204
+
205
+ export interface SyncedClone {
206
+ dir: string;
207
+ /** The default branch; null for an empty repository. */
208
+ branch: string | null;
209
+ /** The default branch's commit; null for an empty repository. */
210
+ commit: string | null;
211
+ }
212
+
213
+ /**
214
+ * Bring the working clone of `name` to the remote's default branch: clone it
215
+ * when absent (or when it points elsewhere), else fetch; then `reset --hard`
216
+ * and `clean -ffdx`. Writes only the working clone.
217
+ */
218
+ export async function syncWorkingClone(
219
+ name: string,
220
+ url: string,
221
+ label = name,
222
+ ): Promise<SyncedClone> {
223
+ const dir = workingClonePath(name);
224
+ const head = must(await lsRemoteHead(url), label);
225
+ let fresh = !fs.existsSync(path.join(dir, ".git"));
226
+ if (!fresh) {
227
+ const current = await remoteUrl(dir);
228
+ if (!current.ok || current.value !== url) fresh = true;
229
+ }
230
+ if (fresh) {
231
+ fs.rmSync(dir, { recursive: true, force: true });
232
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
233
+ must(await cloneFull(url, dir), label);
234
+ } else {
235
+ must(await gitFetch(dir), label);
236
+ }
237
+ if (head.branch && head.commit) {
238
+ must(await resetToRemote(dir, head.branch), label);
239
+ }
240
+ must(await cleanAll(dir), label);
241
+ return { dir, branch: head.branch, commit: head.commit };
242
+ }
243
+
244
+ /** Return the working clone to the remote branch, untracked files removed. */
245
+ export async function resetWorkingClone(
246
+ dir: string,
247
+ branch: string | null,
248
+ ): Promise<void> {
249
+ if (!fs.existsSync(path.join(dir, ".git"))) return;
250
+ if (branch) await resetToRemote(dir, branch);
251
+ await cleanAll(dir);
252
+ }
253
+
254
+ // ─── Read-only snapshots ─────────────────────────────────────────────────────
255
+
256
+ /** A marketplace at one commit, read through git's object store. */
257
+ export interface Snapshot {
258
+ /** The local repository the objects come from. */
259
+ dir: string;
260
+ commit: string;
261
+ branch: string;
262
+ /** Every blob (and link) at the commit, by repository path. */
263
+ entries: ReadonlyMap<string, TreeEntry>;
264
+ /** A file's text, or null when the commit has no such file. */
265
+ readText(file: string): Promise<string | null>;
266
+ /** A JSON file, parsed; null when absent. Throws on a file that does not parse. */
267
+ readJson(file: string): Promise<unknown>;
268
+ }
269
+
270
+ /** Read `commit` from the local repository `dir`. */
271
+ export async function snapshotAt(
272
+ dir: string,
273
+ commit: string,
274
+ branch: string,
275
+ label: string,
276
+ ): Promise<Snapshot> {
277
+ const tree = must(await lsTree(dir, commit), label);
278
+ const entries = new Map(tree.map((e) => [e.path, e]));
279
+ const readText = async (file: string): Promise<string | null> => {
280
+ const entry = entries.get(file);
281
+ if (!entry) return null;
282
+ return must(await catFile(dir, entry.oid), label);
283
+ };
284
+ return {
285
+ dir,
286
+ commit,
287
+ branch,
288
+ entries,
289
+ readText,
290
+ async readJson(file) {
291
+ const text = await readText(file);
292
+ if (text === null) return null;
293
+ try {
294
+ return JSON.parse(text) as unknown;
295
+ } catch {
296
+ throw new MarketError(
297
+ "other",
298
+ `${label} holds a ${path.posix.basename(file)} that is not valid JSON (${file})`,
299
+ );
300
+ }
301
+ },
302
+ };
303
+ }
304
+
305
+ export type RemoteState =
306
+ | { fresh: true; snapshot: Snapshot }
307
+ | {
308
+ fresh: false;
309
+ /** Why the state is not current, in the user's words. */
310
+ reason: string;
311
+ fix: string;
312
+ /** The newest local copy, for display only. */
313
+ snapshot: Snapshot | null;
314
+ /** The failure behind an unreachable remote, when there was one. */
315
+ failure?: GitFailure;
316
+ };
317
+
318
+ async function headOf(dir: string): Promise<string | null> {
319
+ if (!fs.existsSync(path.join(dir, ".git")) && !isBareRepo(dir)) return null;
320
+ const r = await revParse(dir, "HEAD");
321
+ return r.ok ? r.value : null;
322
+ }
323
+
324
+ function isBareRepo(dir: string): boolean {
325
+ return (
326
+ fs.existsSync(path.join(dir, "HEAD")) &&
327
+ fs.existsSync(path.join(dir, "objects"))
328
+ );
329
+ }
330
+
331
+ /**
332
+ * The marketplace as GitHub has it now, without writing anything.
333
+ *
334
+ * `ls-remote` gives the default branch and its head. The first local copy at
335
+ * that head — magus's working clone, then Claude Code's — is read: fresh.
336
+ * When none is, or the remote cannot be reached, the state is stale and a
337
+ * write verb decides nothing from it.
338
+ */
339
+ export async function readRemoteState(
340
+ m: TrustedRegistration,
341
+ ): Promise<RemoteState> {
342
+ const candidates = [workingClonePath(m.name), m.location].filter(
343
+ (d): d is string => !!d,
344
+ );
345
+ const newest = async (): Promise<Snapshot | null> => {
346
+ for (const dir of candidates) {
347
+ const head = await headOf(dir);
348
+ if (head) {
349
+ try {
350
+ return await snapshotAt(dir, head, "", m.name);
351
+ } catch {
352
+ // Unreadable: try the next copy.
353
+ }
354
+ }
355
+ }
356
+ return null;
357
+ };
358
+ const remote = await lsRemoteHead(cloneUrl(m.source));
359
+ if (!remote.ok) {
360
+ const auth = remote.failure === "auth";
361
+ return {
362
+ fresh: false,
363
+ reason: auth
364
+ ? `this machine has no GitHub sign-in git can use for ${m.name}`
365
+ : `could not reach ${m.name}'s repository`,
366
+ fix: auth
367
+ ? "run `gh auth setup-git`"
368
+ : "check the connection, then run `magus market fetch`",
369
+ snapshot: await newest(),
370
+ failure: remote.failure,
371
+ };
372
+ }
373
+ const { branch, commit } = remote.value;
374
+ if (!branch || !commit) {
375
+ return {
376
+ fresh: false,
377
+ reason: `${m.name}'s repository is empty`,
378
+ fix: `run \`magus market connect ${m.source}\` to set it up`,
379
+ snapshot: null,
380
+ };
381
+ }
382
+ for (const dir of candidates) {
383
+ if ((await headOf(dir)) === commit) {
384
+ return {
385
+ fresh: true,
386
+ snapshot: await snapshotAt(dir, commit, branch, m.name),
387
+ };
388
+ }
389
+ }
390
+ return {
391
+ fresh: false,
392
+ reason: "GitHub has newer commits this machine has not fetched",
393
+ fix: "run `magus market fetch`",
394
+ snapshot: await newest(),
395
+ };
396
+ }
397
+
398
+ // ─── Delivering a commit ─────────────────────────────────────────────────────
399
+
400
+ export interface DeliverInput {
401
+ clone: string;
402
+ /** The default branch the change goes to. */
403
+ branch: string;
404
+ /** The marketplace's normalised source. */
405
+ source: string;
406
+ target: DeliveryTarget;
407
+ /** Names the pull-request branch, e.g. `review-kit-0.3.2`. */
408
+ slug: string;
409
+ title: string;
410
+ body: string;
411
+ label: string;
412
+ }
413
+
414
+ export type Delivery =
415
+ | { kind: "pushed" }
416
+ | { kind: "pull-request"; url: string; branch: string };
417
+
418
+ async function openPullRequest(
419
+ input: DeliverInput,
420
+ remote: string,
421
+ headPrefix: string,
422
+ ): Promise<Delivery> {
423
+ const branch = `magus/${input.slug}-${Date.now().toString(36)}`;
424
+ must(await pushRef(input.clone, remote, "HEAD", branch), input.label);
425
+ const bodyDir = fs.mkdtempSync(path.join(os.tmpdir(), "magus-pr-"));
426
+ const bodyFile = path.join(bodyDir, "body.md");
427
+ try {
428
+ fs.writeFileSync(bodyFile, input.body);
429
+ const pr = await prCreate({
430
+ repo: input.source,
431
+ head: `${headPrefix}${branch}`,
432
+ base: input.branch,
433
+ title: input.title,
434
+ bodyFile,
435
+ });
436
+ if (!pr.ok) {
437
+ const error = new MarketError(
438
+ pr.failure === "gh-auth" ? "gh-auth" : "other",
439
+ `The change is on branch ${branch}, but the pull request could not be opened: ${pr.message}`,
440
+ pr.failure === "gh-auth" ? SIGN_IN_FIX : undefined,
441
+ );
442
+ error.outsideWrite = true;
443
+ throw error;
444
+ }
445
+ return { kind: "pull-request", url: pr.value, branch };
446
+ } finally {
447
+ fs.rmSync(bodyDir, { recursive: true, force: true });
448
+ }
449
+ }
450
+
451
+ async function viaFork(input: DeliverInput): Promise<Delivery> {
452
+ if (!isGithubSource(input.source) || !(await ghAvailable())) {
453
+ throw new MarketError(
454
+ "no-access",
455
+ `Your account cannot write to ${input.label}`,
456
+ "ask the owner for write access, or install gh and sign in so magus can open a pull request",
457
+ );
458
+ }
459
+ const allowed = await repoForkAllowed(input.source);
460
+ if (!allowed.ok || !allowed.value) {
461
+ throw new MarketError(
462
+ "fork-not-allowed",
463
+ `${input.label} does not allow forks`,
464
+ "ask its owner for write access",
465
+ );
466
+ }
467
+ const login = await viewerLogin();
468
+ if (!login.ok) {
469
+ throw new MarketError("gh-auth", "Not signed in to GitHub", SIGN_IN_FIX);
470
+ }
471
+ const fork = await repoFork(input.source);
472
+ if (!fork.ok) {
473
+ throw new MarketError(
474
+ "other",
475
+ `Could not fork ${input.source}: ${fork.message}`,
476
+ );
477
+ }
478
+ // The fork now exists on GitHub: whatever fails after this has changed
479
+ // something outside the working clone.
480
+ try {
481
+ return await fromFork(input, fork.value, login.value);
482
+ } catch (error) {
483
+ if (error instanceof MarketError) error.outsideWrite = true;
484
+ throw error;
485
+ }
486
+ }
487
+
488
+ async function fromFork(
489
+ input: DeliverInput,
490
+ forkName: string,
491
+ login: string,
492
+ ): Promise<Delivery> {
493
+ // Push only to a repository GitHub itself names as this account's fork of
494
+ // the source: a guessed name can be an unrelated, even public, repository.
495
+ const [owner] = forkName.split("/");
496
+ const parent = await forkParent(forkName);
497
+ if (
498
+ !parent.ok ||
499
+ parent.value === null ||
500
+ parent.value.toLowerCase() !== input.source.toLowerCase() ||
501
+ owner?.toLowerCase() !== login.toLowerCase()
502
+ ) {
503
+ throw new MarketError(
504
+ "other",
505
+ `GitHub did not confirm ${forkName} as your fork of ${input.source} — nothing was pushed`,
506
+ "check your forks on GitHub, then run the command again",
507
+ );
508
+ }
509
+ const forkUrl = cloneUrl(forkName);
510
+ await waitForFork(forkUrl, forkName);
511
+ return openPullRequest(input, forkUrl, `${owner}:`);
512
+ }
513
+
514
+ const FORK_WAIT_TRIES = 10;
515
+ const FORK_WAIT_MS = 3_000;
516
+
517
+ /** GitHub creates a fork in the background: wait, bounded, until it answers. */
518
+ async function waitForFork(url: string, name: string): Promise<void> {
519
+ for (let i = 0; i < FORK_WAIT_TRIES; i++) {
520
+ const head = await lsRemoteHead(url);
521
+ if (head.ok && head.value.commit !== null) return;
522
+ // A fork that is not there yet answers "Repository not found" (read as
523
+ // `denied`), so that one is waited out. Missing credentials will not
524
+ // become ready by waiting.
525
+ if (!head.ok && head.failure === "auth") {
526
+ throw new MarketError(
527
+ "gh-auth",
528
+ `git cannot read your fork ${name} — nothing was pushed`,
529
+ SIGN_IN_FIX,
530
+ );
531
+ }
532
+ await new Promise((r) => setTimeout(r, FORK_WAIT_MS));
533
+ }
534
+ throw new MarketError(
535
+ "other",
536
+ `Your fork ${name} is not ready on GitHub yet — nothing was pushed`,
537
+ "run the command again in a minute",
538
+ );
539
+ }
540
+
541
+ /**
542
+ * Deliver the working clone's HEAD commit: push it to the default branch, or,
543
+ * when that branch is protected, push a `magus/…` branch and open a pull
544
+ * request; with no write access and target `fork-pr`, fork and open the pull
545
+ * request from the fork. A non-fast-forward rejection is `plan-changed`:
546
+ * nothing is pushed in its place.
547
+ */
548
+ export async function deliver(input: DeliverInput): Promise<Delivery> {
549
+ if (input.target === "fork-pr") return viaFork(input);
550
+ const pushed = await pushRef(input.clone, "origin", "HEAD", input.branch);
551
+ if (pushed.ok) return { kind: "pushed" };
552
+ if (pushed.failure === "rejected-protected") {
553
+ if (!isGithubSource(input.source) || !(await ghAvailable())) {
554
+ throw new MarketError(
555
+ "gh-missing",
556
+ `${input.label} only takes changes through pull requests, and gh is needed to open one`,
557
+ "install gh and run `gh auth login`",
558
+ );
559
+ }
560
+ return openPullRequest(input, "origin", "");
561
+ }
562
+ // The plan promised a push or a pull request on the source, never a fork
563
+ // the user did not agree to: a denied push stops here.
564
+ if (pushed.failure === "denied") {
565
+ throw new MarketError(
566
+ "no-access",
567
+ `Your account cannot write to ${input.label} — nothing was pushed`,
568
+ "ask its owner for write access",
569
+ );
570
+ }
571
+ throw gitError(pushed, input.label);
572
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * market-fs.ts — file writes that stay where they were aimed.
3
+ *
4
+ * A marketplace repository can commit symbolic links, and a working clone
5
+ * checks them out as links. A write joined under such a path would follow
6
+ * the link and land outside the clone or the skill folder, where no later
7
+ * reset or verification can undo it. Every write the market verbs make into
8
+ * a clone or a local skill folder first checks that no existing part of the
9
+ * path below its root is a link.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ /**
16
+ * The first existing part of `rel` under `root` that is a symbolic link, as a
17
+ * `/`-joined relative path, or null when there is none. `root` itself is
18
+ * trusted. Parts that do not exist yet end the walk: nothing below them can
19
+ * be a link.
20
+ */
21
+ export function linkUnder(root: string, rel: string): string | null {
22
+ const parts = rel.split("/").filter((p) => p !== "" && p !== ".");
23
+ let at = root;
24
+ for (let i = 0; i < parts.length; i++) {
25
+ at = path.join(at, parts[i] as string);
26
+ let stat: fs.Stats;
27
+ try {
28
+ stat = fs.lstatSync(at);
29
+ } catch {
30
+ return null;
31
+ }
32
+ if (stat.isSymbolicLink()) return parts.slice(0, i + 1).join("/");
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /** Throw `error(link)` when any existing part of `rel` under `root` is a link. */
38
+ export function refuseLinkUnder(
39
+ root: string,
40
+ rel: string,
41
+ error: (link: string) => Error,
42
+ ): void {
43
+ const link = linkUnder(root, rel);
44
+ if (link !== null) throw error(link);
45
+ }
46
+
47
+ /**
48
+ * Write `data` to `file` as a new regular file, never through a link: what
49
+ * is at `file` now (a file or a link) is removed first, and the write fails
50
+ * rather than follow anything that appears in between.
51
+ */
52
+ export function writeFileNoFollow(file: string, data: string): void {
53
+ fs.rmSync(file, { force: true });
54
+ fs.writeFileSync(file, data, { flag: "wx" });
55
+ }
56
+
57
+ /** How a refused write is reported: the link it would have gone through. */
58
+ export type LinkError = (link: string) => Error;
59
+
60
+ /**
61
+ * Make the folders of `rel` under `root`, after checking that no existing
62
+ * part of it is a link. Parts that do not exist are created as real folders.
63
+ */
64
+ export function mkdirUnder(root: string, rel: string, error: LinkError): void {
65
+ refuseLinkUnder(root, rel, error);
66
+ fs.mkdirSync(path.join(root, rel), { recursive: true });
67
+ }
68
+
69
+ /**
70
+ * Write `data` to `rel` under `root` as a new regular file: every existing
71
+ * part of the path is checked for a link first, the folders are made, and
72
+ * the leaf is written without following anything (`writeFileNoFollow`).
73
+ */
74
+ export function writeFileUnder(
75
+ root: string,
76
+ rel: string,
77
+ data: string,
78
+ error: LinkError,
79
+ ): void {
80
+ refuseLinkUnder(root, rel, error);
81
+ const out = path.join(root, rel);
82
+ fs.mkdirSync(path.dirname(out), { recursive: true });
83
+ refuseLinkUnder(root, rel, error);
84
+ writeFileNoFollow(out, data);
85
+ }
86
+
87
+ /** `writeFileUnder` for JSON, written the way marketplace files are. */
88
+ export function writeJsonUnder(
89
+ root: string,
90
+ rel: string,
91
+ value: unknown,
92
+ error: LinkError,
93
+ ): void {
94
+ writeFileUnder(root, rel, `${JSON.stringify(value, null, 2)}\n`, error);
95
+ }
96
+
97
+ /** Read JSON from `rel` under `root`, refusing a path that passes a link. */
98
+ export function readJsonUnder(
99
+ root: string,
100
+ rel: string,
101
+ error: LinkError,
102
+ ): Record<string, unknown> {
103
+ refuseLinkUnder(root, rel, error);
104
+ return JSON.parse(fs.readFileSync(path.join(root, rel), "utf8")) as Record<
105
+ string,
106
+ unknown
107
+ >;
108
+ }
109
+
110
+ /**
111
+ * Copy `from` to `rel` under `root` as a new regular file. Whatever is at
112
+ * the destination (a file or a link) is removed first and the copy is
113
+ * exclusive, so it never writes through a link.
114
+ */
115
+ export function copyFileUnder(
116
+ root: string,
117
+ rel: string,
118
+ from: string,
119
+ error: LinkError,
120
+ ): void {
121
+ refuseLinkUnder(root, rel, error);
122
+ const out = path.join(root, rel);
123
+ fs.mkdirSync(path.dirname(out), { recursive: true });
124
+ refuseLinkUnder(root, rel, error);
125
+ fs.rmSync(out, { force: true });
126
+ fs.copyFileSync(from, out, fs.constants.COPYFILE_EXCL);
127
+ }
128
+
129
+ /**
130
+ * Remove `rel` under `root` (recursively for a folder). The folders above it
131
+ * must not be links, or the removal would land outside `root`; the leaf
132
+ * itself may be one, and then only the link is removed.
133
+ */
134
+ export function removeUnder(root: string, rel: string, error: LinkError): void {
135
+ const parent = path.posix.dirname(rel);
136
+ if (parent !== "." && parent !== "") refuseLinkUnder(root, parent, error);
137
+ fs.rmSync(path.join(root, rel), { recursive: true, force: true });
138
+ }