@stigmer/cli 3.18.0-dev.20260918103812 → 3.18.1-dev.20260919070736

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 (109) hide show
  1. package/commands/bootstrap.d.ts +3 -0
  2. package/commands/bootstrap.d.ts.map +1 -0
  3. package/commands/bootstrap.js +75 -0
  4. package/commands/bootstrap.js.map +1 -0
  5. package/commands/marketplace.d.ts.map +1 -1
  6. package/commands/marketplace.js +7 -7
  7. package/commands/marketplace.js.map +1 -1
  8. package/config/resolve.d.ts.map +1 -1
  9. package/config/resolve.js +8 -7
  10. package/config/resolve.js.map +1 -1
  11. package/local/bootstrap.d.ts +32 -13
  12. package/local/bootstrap.d.ts.map +1 -1
  13. package/local/bootstrap.js +105 -64
  14. package/local/bootstrap.js.map +1 -1
  15. package/local/daemon/launch.d.ts +1 -1
  16. package/local/daemon/launch.d.ts.map +1 -1
  17. package/local/paths.d.ts +1 -1
  18. package/local/paths.js +1 -1
  19. package/local/plugins/defaults.d.ts +27 -7
  20. package/local/plugins/defaults.d.ts.map +1 -1
  21. package/local/plugins/defaults.js +55 -28
  22. package/local/plugins/defaults.js.map +1 -1
  23. package/local/runtime/runtimes-install.d.ts.map +1 -1
  24. package/local/runtime/runtimes-install.js +7 -4
  25. package/local/runtime/runtimes-install.js.map +1 -1
  26. package/local/seedpack-retire.d.ts +63 -0
  27. package/local/seedpack-retire.d.ts.map +1 -0
  28. package/local/seedpack-retire.js +361 -0
  29. package/local/seedpack-retire.js.map +1 -0
  30. package/local/system-org.d.ts +15 -0
  31. package/local/system-org.d.ts.map +1 -0
  32. package/local/system-org.js +57 -0
  33. package/local/system-org.js.map +1 -0
  34. package/marketplace/config.d.ts +3 -1
  35. package/marketplace/config.d.ts.map +1 -1
  36. package/marketplace/config.js +24 -11
  37. package/marketplace/config.js.map +1 -1
  38. package/marketplace/github.d.ts +37 -0
  39. package/marketplace/github.d.ts.map +1 -1
  40. package/marketplace/github.js +70 -1
  41. package/marketplace/github.js.map +1 -1
  42. package/marketplace/install.d.ts +13 -6
  43. package/marketplace/install.d.ts.map +1 -1
  44. package/marketplace/install.js +63 -16
  45. package/marketplace/install.js.map +1 -1
  46. package/marketplace/official.js +4 -4
  47. package/marketplace/official.js.map +1 -1
  48. package/marketplace/render.d.ts.map +1 -1
  49. package/marketplace/render.js +10 -4
  50. package/marketplace/render.js.map +1 -1
  51. package/package.json +7 -7
  52. package/program.js +2 -2
  53. package/program.js.map +1 -1
  54. package/resources/apply/declarative.js +2 -2
  55. package/resources/apply/declarative.js.map +1 -1
  56. package/resources/plugin.d.ts.map +1 -1
  57. package/resources/plugin.js +12 -9
  58. package/resources/plugin.js.map +1 -1
  59. package/src/commands/bootstrap.test.ts +253 -0
  60. package/src/commands/bootstrap.ts +91 -0
  61. package/src/commands/install.test.ts +28 -9
  62. package/src/commands/marketplace.test.ts +28 -14
  63. package/src/commands/marketplace.ts +9 -7
  64. package/src/config/resolve.test.ts +2 -2
  65. package/src/config/resolve.ts +8 -7
  66. package/src/local/bootstrap.test.ts +110 -38
  67. package/src/local/bootstrap.ts +179 -89
  68. package/src/local/daemon/launch.ts +1 -1
  69. package/src/local/paths.ts +1 -1
  70. package/src/local/plugins/defaults.test.ts +46 -13
  71. package/src/local/plugins/defaults.ts +77 -34
  72. package/src/local/runtime/runtimes-install.test.ts +2 -0
  73. package/src/local/runtime/runtimes-install.ts +7 -4
  74. package/src/local/seedpack-retire.test.ts +499 -0
  75. package/src/local/seedpack-retire.ts +474 -0
  76. package/src/local/system-org.test.ts +124 -0
  77. package/src/local/system-org.ts +63 -0
  78. package/src/marketplace/__fixtures__/cursor-marketplace.ts +5 -2
  79. package/src/marketplace/config.test.ts +47 -22
  80. package/src/marketplace/config.ts +27 -12
  81. package/src/marketplace/github.test.ts +77 -1
  82. package/src/marketplace/github.ts +115 -1
  83. package/src/marketplace/install.test.ts +84 -17
  84. package/src/marketplace/install.ts +73 -15
  85. package/src/marketplace/official.ts +4 -4
  86. package/src/marketplace/read.test.ts +1 -1
  87. package/src/marketplace/render.ts +13 -4
  88. package/src/program.ts +2 -2
  89. package/src/resources/apply/declarative.integration.test.ts +2 -2
  90. package/src/resources/apply/declarative.ts +2 -2
  91. package/src/resources/connect/discover.test.ts +1 -1
  92. package/src/resources/plugin.ts +11 -10
  93. package/commands/seedpack.d.ts +0 -3
  94. package/commands/seedpack.d.ts.map +0 -1
  95. package/commands/seedpack.js +0 -98
  96. package/commands/seedpack.js.map +0 -1
  97. package/local/seedpack/apply.d.ts +0 -72
  98. package/local/seedpack/apply.d.ts.map +0 -1
  99. package/local/seedpack/apply.js +0 -145
  100. package/local/seedpack/apply.js.map +0 -1
  101. package/local/seedpack/content.d.ts +0 -61
  102. package/local/seedpack/content.d.ts.map +0 -1
  103. package/local/seedpack/content.js +0 -178
  104. package/local/seedpack/content.js.map +0 -1
  105. package/src/commands/seedpack.ts +0 -116
  106. package/src/local/seedpack/apply.test.ts +0 -287
  107. package/src/local/seedpack/apply.ts +0 -199
  108. package/src/local/seedpack/content.test.ts +0 -169
  109. package/src/local/seedpack/content.ts +0 -222
@@ -48,25 +48,38 @@ function writeConfig(yaml: string): void {
48
48
  writeFileSync(configPath(), yaml);
49
49
  }
50
50
 
51
+ const BUILT_IN_NAMES = [
52
+ OFFICIAL_MARKETPLACE_NAME,
53
+ "cursor-plugins",
54
+ "claude-code-plugins",
55
+ "codex-plugins",
56
+ ];
57
+
51
58
  describe("listMarketplaces", () => {
52
- it("is the official marketplace alone on a fresh machine", () => {
59
+ it("is the built-in sources alone on a fresh machine, the official one first", () => {
53
60
  const listing = listMarketplaces();
54
- expect(listing.known.map((m) => m.name)).toEqual([
55
- OFFICIAL_MARKETPLACE_NAME,
56
- ]);
61
+ expect(listing.known.map((m) => m.name)).toEqual(BUILT_IN_NAMES);
57
62
  expect(listing.known[0]?.source).toEqual({ type: "official" });
63
+ expect(listing.known[1]?.source).toEqual({
64
+ type: "github",
65
+ repo: "cursor/plugins",
66
+ });
58
67
  expect(listing.unreadable).toEqual([]);
59
68
  });
60
69
 
61
- it("lists configured entries after the official one, in file order, and names the unreadable ones", () => {
70
+ it("lists configured entries after the built-ins, in file order, names the unreadable ones, and lets a built-in win over a same-named entry", () => {
62
71
  writeConfig(
63
72
  [
64
73
  "backend:",
65
74
  " type: local",
66
75
  "marketplaces:",
76
+ " acme:",
77
+ " type: github",
78
+ " repo: acme/plugins",
79
+ // Written before the vendors were built in: the built-in wins, the entry stays in the file.
67
80
  " cursor-plugins:",
68
81
  " type: github",
69
- " repo: cursor/plugins",
82
+ " repo: someone/fork",
70
83
  " pinned:",
71
84
  " type: github",
72
85
  " repo: anthropics/claude-code",
@@ -84,24 +97,28 @@ describe("listMarketplaces", () => {
84
97
  );
85
98
  const listing = listMarketplaces();
86
99
  expect(listing.known.map((m) => m.name)).toEqual([
87
- "stigmer",
88
- "cursor-plugins",
100
+ ...BUILT_IN_NAMES,
101
+ "acme",
89
102
  "pinned",
90
103
  "team",
91
104
  ]);
92
- expect(listing.known[1]?.source).toEqual({
105
+ expect(listing.known[4]?.source).toEqual({
93
106
  type: "github",
94
- repo: "cursor/plugins",
107
+ repo: "acme/plugins",
95
108
  });
96
- expect(listing.known[2]?.source).toEqual({
109
+ expect(listing.known[5]?.source).toEqual({
97
110
  type: "github",
98
111
  repo: "anthropics/claude-code",
99
112
  ref: "v1.2.3",
100
113
  });
101
- expect(listing.known[3]?.source).toEqual({
114
+ expect(listing.known[6]?.source).toEqual({
102
115
  type: "local",
103
116
  path: "/srv/plugins",
104
117
  });
118
+ expect(listing.known[1]?.source).toEqual({
119
+ type: "github",
120
+ repo: "cursor/plugins",
121
+ });
105
122
  expect(listing.unreadable).toEqual([
106
123
  {
107
124
  name: "broken",
@@ -114,26 +131,26 @@ describe("listMarketplaces", () => {
114
131
 
115
132
  describe("addMarketplace and removeMarketplace", () => {
116
133
  it("round-trips a github source and a local source through the file", () => {
117
- addMarketplace("cursor-plugins", {
134
+ addMarketplace("acme", {
118
135
  type: "github",
119
- repo: "cursor/plugins",
136
+ repo: "acme/plugins",
120
137
  });
121
138
  addMarketplace("team", { type: "local", path: "/srv/plugins" });
122
139
  const text = readFileSync(configPath(), "utf8");
123
140
  expect(text).toContain("marketplaces:");
124
141
  expect(text).toContain("type: github");
125
- expect(text).toContain("repo: cursor/plugins");
142
+ expect(text).toContain("repo: acme/plugins");
126
143
  expect(text).not.toContain("ref:");
127
144
  expect(text).toContain("path: /srv/plugins");
128
145
  expect(listMarketplaces().known.map((m) => m.name)).toEqual([
129
- "stigmer",
130
- "cursor-plugins",
146
+ ...BUILT_IN_NAMES,
147
+ "acme",
131
148
  "team",
132
149
  ]);
133
150
 
134
- removeMarketplace("cursor-plugins");
151
+ removeMarketplace("acme");
135
152
  expect(listMarketplaces().known.map((m) => m.name)).toEqual([
136
- "stigmer",
153
+ ...BUILT_IN_NAMES,
137
154
  "team",
138
155
  ]);
139
156
  removeMarketplace("team");
@@ -156,10 +173,13 @@ describe("addMarketplace and removeMarketplace", () => {
156
173
  });
157
174
  });
158
175
 
159
- it("refuses the reserved name, a duplicate, an unknown removal, and the reserved removal", () => {
176
+ it("refuses every built-in name, a duplicate, an unknown removal, and a built-in removal", () => {
160
177
  expect(() =>
161
178
  addMarketplace("stigmer", { type: "local", path: "/x" }),
162
- ).toThrow(/built-in marketplace/);
179
+ ).toThrow(/'stigmer' is a built-in source and cannot be added or replaced/);
180
+ expect(() =>
181
+ addMarketplace("cursor-plugins", { type: "local", path: "/x" }),
182
+ ).toThrow(/'cursor-plugins' is a built-in source/);
163
183
  addMarketplace("team", { type: "local", path: "/srv/plugins" });
164
184
  expect(() =>
165
185
  addMarketplace("team", { type: "local", path: "/other" }),
@@ -167,7 +187,12 @@ describe("addMarketplace and removeMarketplace", () => {
167
187
  expect(() => removeMarketplace("nope")).toThrow(
168
188
  /no marketplace named 'nope'/,
169
189
  );
170
- expect(() => removeMarketplace("stigmer")).toThrow(/cannot be removed/);
190
+ expect(() => removeMarketplace("stigmer")).toThrow(
191
+ /'stigmer' is a built-in source and cannot be removed/,
192
+ );
193
+ expect(() => removeMarketplace("codex-plugins")).toThrow(
194
+ /'codex-plugins' is a built-in source and cannot be removed/,
195
+ );
171
196
  });
172
197
 
173
198
  it("refuses under --standalone instead of writing a default file over the user's", () => {
@@ -1,10 +1,14 @@
1
- // The marketplaces a CLI knows: the official one, built in, and the ones a
2
- // user added under the `marketplaces` key of ~/.stigmer/config.yaml.
1
+ // The sources a CLI knows: the built-in ones (the official catalogue and the
2
+ // three vendors' public ones, the same list the console ships), and the ones
3
+ // a user added under the `marketplaces` key of ~/.stigmer/config.yaml.
3
4
  //
4
- // A marketplace is client-side configuration, never a server resource: the
5
+ // A source is client-side configuration, never a server resource: the
5
6
  // server only ever sees the plugin archive a client pushes. That is why the
6
7
  // list lives beside the named backends and the context, and why `stigmer
7
- // marketplace` is a noun group like `config` and `auth`.
8
+ // marketplace` is a noun group like `config` and `auth`. The built-ins are
9
+ // code, not config: they cannot be added over or removed, so `stigmer
10
+ // install cursor-plugins/thermos` works on a fresh machine and the CLI's
11
+ // list never drifts from the console's.
8
12
  //
9
13
  // The config module keeps the on-disk entry loose (`MarketplaceEntryConfig`,
10
14
  // every field optional) so a hand-edited entry this CLI cannot read survives
@@ -20,8 +24,11 @@ import {
20
24
  } from "../config/index.js";
21
25
  import {
22
26
  type GitHubMarketplaceSource,
27
+ BUILT_IN_MARKETPLACES,
23
28
  OFFICIAL_MARKETPLACE_NAME,
29
+ builtInSourceRefusal,
24
30
  describeGitHubSource,
31
+ isBuiltInMarketplaceName,
25
32
  isOwnerRepo,
26
33
  } from "@stigmer/plugin-package/client";
27
34
  import { UsageError } from "../errors/index.js";
@@ -61,13 +68,23 @@ export const OFFICIAL_MARKETPLACE: KnownMarketplace = {
61
68
  source: { type: "official" },
62
69
  };
63
70
 
64
- /** Every marketplace the config names, the official one first. */
71
+ /** The sources every CLI ships with, in listing order; the official one is first. */
72
+ export const BUILT_IN_SOURCES: readonly KnownMarketplace[] =
73
+ BUILT_IN_MARKETPLACES.map((entry) => ({
74
+ name: entry.name,
75
+ source: entry.source,
76
+ }));
77
+
78
+ /** Every source the CLI knows: the built-ins first, then the config's entries in file order. */
65
79
  export function listMarketplaces(
66
80
  config: Config = loadConfig(),
67
81
  ): MarketplaceListing {
68
- const known: KnownMarketplace[] = [OFFICIAL_MARKETPLACE];
82
+ const known: KnownMarketplace[] = [...BUILT_IN_SOURCES];
69
83
  const unreadable: UnreadableMarketplace[] = [];
70
84
  for (const [name, entry] of Object.entries(config.marketplaces ?? {})) {
85
+ // A config written before the vendors were built in may name one; the
86
+ // built-in wins and the entry is left in the file untouched.
87
+ if (isBuiltInMarketplaceName(name)) continue;
71
88
  const narrowed = narrowEntry(entry);
72
89
  if (narrowed.ok) known.push({ name, source: narrowed.source });
73
90
  else unreadable.push({ name, reason: narrowed.reason });
@@ -106,9 +123,9 @@ export function addMarketplace(
106
123
  source: Exclude<MarketplaceSource, { type: "official" }>,
107
124
  ): void {
108
125
  refuseStandalone("add");
109
- if (name === OFFICIAL_MARKETPLACE_NAME) {
126
+ if (isBuiltInMarketplaceName(name)) {
110
127
  throw new UsageError(
111
- `'${OFFICIAL_MARKETPLACE_NAME}' is the built-in marketplace and cannot be added or replaced\n\n` +
128
+ `${builtInSourceRefusal(name, "add")}\n\n` +
112
129
  "Pass --name <other-name> to add this source under a different name.",
113
130
  );
114
131
  }
@@ -128,10 +145,8 @@ export function addMarketplace(
128
145
  /** Remove the marketplace called `name` and save. Refuses the reserved name and an unknown one. */
129
146
  export function removeMarketplace(name: string): void {
130
147
  refuseStandalone("remove");
131
- if (name === OFFICIAL_MARKETPLACE_NAME) {
132
- throw new UsageError(
133
- `'${OFFICIAL_MARKETPLACE_NAME}' is the built-in marketplace and cannot be removed`,
134
- );
148
+ if (isBuiltInMarketplaceName(name)) {
149
+ throw new UsageError(builtInSourceRefusal(name, "remove"));
135
150
  }
136
151
  const config = loadConfig();
137
152
  if (config.marketplaces?.[name] === undefined) {
@@ -2,7 +2,10 @@
2
2
  // its top directory stripped; every cap refuses naming itself, from the
3
3
  // central directory before anything inflates; an escaping path, a second
4
4
  // top-level directory and a lying declared size refuse; 404 and a network
5
- // failure carry their own exit codes.
5
+ // failure carry their own exit codes. The peek reads one raw file, the first
6
+ // of the four locations that exists, as declared; a repository with none is
7
+ // not a marketplace, a refused file travels with its sentences, and a
8
+ // network failure carries its exit code.
6
9
 
7
10
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8
11
  import { tmpdir } from "node:os";
@@ -13,6 +16,8 @@ import { ExitCode } from "../errors/index.js";
13
16
  import {
14
17
  GITHUB_ZIPBALL_LIMITS,
15
18
  fetchGitHubTree,
19
+ peekGitHubMarketplace,
20
+ rawFileUrl,
16
21
  zipballUrl,
17
22
  } from "./github.js";
18
23
 
@@ -316,3 +321,74 @@ describe("fetchGitHubTree", () => {
316
321
  expect(offline.exitCode).toBe(ExitCode.Connection);
317
322
  });
318
323
  });
324
+
325
+ /** A fetch answering raw.githubusercontent.com from a path map, 404 elsewhere. */
326
+ function rawServing(
327
+ files: Record<string, string>,
328
+ ): typeof globalThis.fetch {
329
+ return async (input) => {
330
+ const url = String(input);
331
+ const prefix = "https://raw.githubusercontent.com/a/b/HEAD/";
332
+ const path = url.startsWith(prefix) ? url.slice(prefix.length) : undefined;
333
+ const content = path === undefined ? undefined : files[path];
334
+ return content === undefined
335
+ ? new Response("Not Found", { status: 404 })
336
+ : new Response(content, { status: 200 });
337
+ };
338
+ }
339
+
340
+ describe("rawFileUrl", () => {
341
+ it("names raw.githubusercontent.com at HEAD by default and the ref when given", () => {
342
+ expect(rawFileUrl({ repo: "a/b" }, ".cursor-plugin/marketplace.json")).toBe(
343
+ "https://raw.githubusercontent.com/a/b/HEAD/.cursor-plugin/marketplace.json",
344
+ );
345
+ expect(rawFileUrl({ repo: "a/b", ref: "v1" }, "marketplace.json")).toBe(
346
+ "https://raw.githubusercontent.com/a/b/v1/marketplace.json",
347
+ );
348
+ });
349
+ });
350
+
351
+ describe("peekGitHubMarketplace", () => {
352
+ it("reads the first location present in the library's precedence, as declared, tree unverified", async () => {
353
+ const peek = await peekGitHubMarketplace(
354
+ { repo: "a/b" },
355
+ rawServing({
356
+ ".cursor-plugin/marketplace.json": JSON.stringify({
357
+ name: "acme",
358
+ plugins: [{ name: "thermos", source: "thermos" }],
359
+ }),
360
+ }),
361
+ );
362
+ expect(peek.ok).toBe(true);
363
+ if (!peek.ok) return;
364
+ expect(peek.peeked.location).toBe(".cursor-plugin/marketplace.json");
365
+ expect(peek.peeked.marketplace.plugins.map((p) => p.name)).toEqual([
366
+ "thermos",
367
+ ]);
368
+ });
369
+
370
+ it("is not-a-marketplace when no location exists, and refused with the library's sentences when the file is broken", async () => {
371
+ expect(await peekGitHubMarketplace({ repo: "a/b" }, rawServing({}))).toEqual({
372
+ ok: false,
373
+ kind: "not-a-marketplace",
374
+ });
375
+ const refused = await peekGitHubMarketplace(
376
+ { repo: "a/b" },
377
+ rawServing({ "marketplace.json": JSON.stringify({ plugins: "no" }) }),
378
+ );
379
+ expect(refused.ok).toBe(false);
380
+ if (refused.ok || refused.kind !== "refused") throw new Error("expected refused");
381
+ expect(refused.location).toBe("marketplace.json");
382
+ expect(refused.errors.length).toBeGreaterThan(0);
383
+ });
384
+
385
+ it("carries the connection exit code when GitHub cannot be reached", async () => {
386
+ const offline = await peekGitHubMarketplace({ repo: "a/b" }, async () => {
387
+ throw new Error("ENOTFOUND");
388
+ }).catch((e: unknown) => e);
389
+ expect(offline).toMatchObject({
390
+ message: "could not reach GitHub for a/b",
391
+ exitCode: ExitCode.Connection,
392
+ });
393
+ });
394
+ });
@@ -23,11 +23,27 @@
23
23
  // 1,169 entries; anthropics/claude-code 11.7 / 13.6 / 1,595; openai/codex
24
24
  // 18.6 / 76.8 / 9,188, its marketplace file at the repository root so the
25
25
  // whole repository is the tree), with room above the largest.
26
+ //
27
+ // The peek. A bare `stigmer install <name>` asks every known source whether
28
+ // it offers the name, and with the vendors' catalogues built in that would be
29
+ // three zipballs (about 35 MiB) per install. `peekGitHubMarketplace` reads
30
+ // only the marketplace file instead: the four locations the library knows,
31
+ // tried in its precedence, from raw.githubusercontent.com at the ref (no API
32
+ // call, no token, no rate budget, the same reasons as the zipball). It
33
+ // answers what the file declares; the zipball is then downloaded for the one
34
+ // holder and the tree read verifies the entry before anything is installed.
26
35
 
27
36
  import { mkdirSync, writeFileSync } from "node:fs";
28
37
  import { dirname, join } from "node:path";
29
38
  import { inflateRawSync } from "node:zlib";
30
- import { isContainedPath } from "@stigmer/plugin-package";
39
+ import {
40
+ MARKETPLACE_LOCATIONS,
41
+ type Marketplace,
42
+ type MarketplaceFinding,
43
+ inMemoryPluginFiles,
44
+ isContainedPath,
45
+ readMarketplaceFile,
46
+ } from "@stigmer/plugin-package";
31
47
  import {
32
48
  type ZipStructuralEntry,
33
49
  crc32,
@@ -73,6 +89,104 @@ export function zipballUrl(source: GitHubTreeSource): string {
73
89
  return `https://codeload.github.com/${source.repo}/zip/${source.ref ?? DEFAULT_REF}`;
74
90
  }
75
91
 
92
+ /** The URL raw.githubusercontent.com serves `path` from at `ref` (no API, no token). */
93
+ export function rawFileUrl(source: GitHubTreeSource, path: string): string {
94
+ return `https://raw.githubusercontent.com/${source.repo}/${source.ref ?? DEFAULT_REF}/${path}`;
95
+ }
96
+
97
+ /** What a peek learned: the file as declared, and where it sat. */
98
+ export interface PeekedMarketplace {
99
+ readonly marketplace: Marketplace;
100
+ readonly warnings: readonly MarketplaceFinding[];
101
+ /** The marketplace file's path in the tree, the first present in the library's precedence. */
102
+ readonly location: string;
103
+ }
104
+
105
+ export type PeekOutcome =
106
+ | { readonly ok: true; readonly peeked: PeekedMarketplace }
107
+ /** No marketplace file at any of the four locations: the repository is not a marketplace. */
108
+ | { readonly ok: false; readonly kind: "not-a-marketplace" }
109
+ /** A file was found and the library refused it; its sentences travel. */
110
+ | {
111
+ readonly ok: false;
112
+ readonly kind: "refused";
113
+ readonly location: string;
114
+ readonly errors: readonly MarketplaceFinding[];
115
+ readonly warnings: readonly MarketplaceFinding[];
116
+ };
117
+
118
+ /**
119
+ * Read only the marketplace file of a GitHub source. The four locations are
120
+ * tried in the library's precedence and the first that exists is read as
121
+ * declared (`readMarketplaceFile`: the tree is not consulted). Network and
122
+ * server faults throw a `CliExitError` naming the repository, as the
123
+ * zipball download does; a 404 at every location is the `not-a-marketplace`
124
+ * outcome, not an error, because a bare-name search must say which sources
125
+ * it looked at and move on.
126
+ */
127
+ export async function peekGitHubMarketplace(
128
+ source: GitHubTreeSource,
129
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
130
+ ): Promise<PeekOutcome> {
131
+ for (const location of Object.values(MARKETPLACE_LOCATIONS)) {
132
+ const bytes = await fetchRawFile(source, location, fetchImpl);
133
+ if (bytes === undefined) continue;
134
+ const outcome = readMarketplaceFile(
135
+ inMemoryPluginFiles(new Map([[location, bytes]])),
136
+ );
137
+ if (!outcome.ok) {
138
+ return {
139
+ ok: false,
140
+ kind: "refused",
141
+ location,
142
+ errors: outcome.errors,
143
+ warnings: outcome.warnings,
144
+ };
145
+ }
146
+ return {
147
+ ok: true,
148
+ peeked: {
149
+ marketplace: outcome.marketplace,
150
+ warnings: outcome.warnings,
151
+ location,
152
+ },
153
+ };
154
+ }
155
+ return { ok: false, kind: "not-a-marketplace" };
156
+ }
157
+
158
+ /** One raw file, or `undefined` when the path does not exist at the ref. */
159
+ async function fetchRawFile(
160
+ source: GitHubTreeSource,
161
+ path: string,
162
+ fetchImpl: typeof globalThis.fetch,
163
+ ): Promise<Uint8Array | undefined> {
164
+ const url = rawFileUrl(source, path);
165
+ let response: Response;
166
+ try {
167
+ response = await fetchImpl(url, { redirect: "follow" });
168
+ } catch (error) {
169
+ throw new CliExitError(
170
+ `could not reach GitHub for ${source.repo}`,
171
+ ExitCode.Connection,
172
+ [
173
+ `URL: ${url}`,
174
+ "Check the network connection and retry.",
175
+ error instanceof Error ? error.message : String(error),
176
+ ],
177
+ );
178
+ }
179
+ if (response.status === 404) return undefined;
180
+ if (!response.ok) {
181
+ throw new CliExitError(
182
+ `GitHub answered ${response.status} for ${source.repo}`,
183
+ ExitCode.Connection,
184
+ [`URL: ${url}`, "Retry; if it persists, the repository or GitHub is unavailable."],
185
+ );
186
+ }
187
+ return new Uint8Array(await response.arrayBuffer());
188
+ }
189
+
76
190
  /**
77
191
  * Download the zipball and extract it into `destDir` with the zipball's
78
192
  * single top-level directory (`<repo>-<ref>/`) stripped, so `destDir` IS the
@@ -1,8 +1,11 @@
1
1
  // Pins how a ref becomes a prepared push: an offered entry prepares through
2
2
  // the same walker `push plugin` uses (one digest for one tree); a version
3
- // pin is an assertion; a bare name is searched across the configured
4
- // marketplaces and must land in exactly one; every temp tree a search opened
5
- // is disposed, whatever the outcome.
3
+ // pin is an assertion; a bare name is searched across the known sources and
4
+ // must land in exactly one; a GitHub source is asked through its marketplace
5
+ // file alone and its zipball fetched only when the file declares the name (a
6
+ // repository with no file declares nothing; a declared entry the tree does
7
+ // not hold is not a holder); every temp tree a search opened is disposed,
8
+ // whatever the outcome.
6
9
 
7
10
  import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
8
11
  import { tmpdir } from "node:os";
@@ -44,7 +47,7 @@ describe("prepareEntry", () => {
44
47
  it("refuses an entry the marketplace does not offer, quoting the drop sentence when there is one", async () => {
45
48
  const tree = readMarketplaceTree(root);
46
49
  await expect(prepareEntry(tree, "ghost")).rejects.toThrow(
47
- /does not offer a plugin named 'ghost'\n\n.*ghost.*\n\nRun 'stigmer marketplace show cursor-plugins' to see the 2 it offers/s,
50
+ /does not offer a plugin named 'ghost'\n\n.*ghost.*\n\nRun 'stigmer marketplace show acme-plugins' to see the 2 it offers/s,
48
51
  );
49
52
  await expect(prepareEntry(tree, "nope")).rejects.toThrow(
50
53
  /does not offer a plugin named 'nope'\n\nRun 'stigmer marketplace show/,
@@ -60,9 +63,9 @@ describe("assertVersion", () => {
60
63
  assertVersion(prepared, "1.0.0", "thermos@1.0.0"),
61
64
  ).not.toThrow();
62
65
  expect(() =>
63
- assertVersion(prepared, "9.9.9", "cursor-plugins/thermos@9.9.9"),
66
+ assertVersion(prepared, "9.9.9", "acme-plugins/thermos@9.9.9"),
64
67
  ).toThrow(
65
- /pins version 9\.9\.9, but the marketplace offers 'thermos' at 1\.0\.0\n\nInstall it as 'cursor-plugins\/thermos@1\.0\.0'/,
68
+ /pins version 9\.9\.9, but the marketplace offers 'thermos' at 1\.0\.0\n\nInstall it as 'acme-plugins\/thermos@1\.0\.0'/,
66
69
  );
67
70
  });
68
71
  });
@@ -70,16 +73,32 @@ describe("assertVersion", () => {
70
73
  describe("locateEntry", () => {
71
74
  const encoder = new TextEncoder();
72
75
 
73
- /** A GitHub source served from memory: the fixture tree zipped under one top directory. */
76
+ /**
77
+ * A GitHub source served from memory: raw.githubusercontent.com answers a
78
+ * single file from the map (404 when absent), codeload answers the tree
79
+ * zipped under one top directory. `requests` records every URL asked.
80
+ */
74
81
  function servingZipOf(
75
82
  files: Record<string, string>,
76
83
  top = "r-HEAD",
84
+ requests: string[] = [],
77
85
  ): typeof globalThis.fetch {
78
86
  const tree: Record<string, Uint8Array> = {};
79
87
  for (const [path, content] of Object.entries(files))
80
88
  tree[`${top}/${path}`] = encoder.encode(content);
81
89
  const bytes = zipSync(tree);
82
- return async () => new Response(bytes, { status: 200 });
90
+ return async (input) => {
91
+ const url = String(input);
92
+ requests.push(url);
93
+ const raw = url.match(/^https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/[^/]+\/(.+)$/);
94
+ if (raw !== null) {
95
+ const content = files[raw[1] ?? ""];
96
+ return content === undefined
97
+ ? new Response("Not Found", { status: 404 })
98
+ : new Response(content, { status: 200 });
99
+ }
100
+ return new Response(bytes, { status: 200 });
101
+ };
83
102
  }
84
103
 
85
104
  const remoteFiles = {
@@ -99,15 +118,15 @@ describe("locateEntry", () => {
99
118
 
100
119
  it("opens the marketplace a prefixed ref names and refuses an unknown prefix", async () => {
101
120
  const local = {
102
- name: "cursor-plugins",
121
+ name: "acme-plugins",
103
122
  source: { type: "local", path: root } as const,
104
123
  };
105
124
  const located = await locateEntry(
106
- parseInstallRef("cursor-plugins/thermos"),
125
+ parseInstallRef("acme-plugins/thermos"),
107
126
  listing(local),
108
127
  );
109
128
  try {
110
- expect(located.marketplace.name).toBe("cursor-plugins");
129
+ expect(located.marketplace.name).toBe("acme-plugins");
111
130
  expect(located.tree.root).toBe(root);
112
131
  } finally {
113
132
  located.open.dispose();
@@ -119,7 +138,7 @@ describe("locateEntry", () => {
119
138
 
120
139
  it("searches a bare name across every marketplace and lands in the one holder, disposing the rest", async () => {
121
140
  const local = {
122
- name: "cursor-plugins",
141
+ name: "acme-plugins",
123
142
  source: { type: "local", path: root } as const,
124
143
  };
125
144
  const located = await locateEntry(
@@ -127,7 +146,7 @@ describe("locateEntry", () => {
127
146
  listing(local),
128
147
  );
129
148
  try {
130
- expect(located.marketplace.name).toBe("cursor-plugins");
149
+ expect(located.marketplace.name).toBe("acme-plugins");
131
150
  } finally {
132
151
  located.open.dispose();
133
152
  }
@@ -135,7 +154,7 @@ describe("locateEntry", () => {
135
154
 
136
155
  it("refuses a bare name nobody offers, naming what was searched and what could not be read", async () => {
137
156
  const local = {
138
- name: "cursor-plugins",
157
+ name: "acme-plugins",
139
158
  source: { type: "local", path: root } as const,
140
159
  };
141
160
  await expect(
@@ -144,13 +163,13 @@ describe("locateEntry", () => {
144
163
  unreadable: [{ name: "broken", reason: "x" }],
145
164
  }),
146
165
  ).rejects.toThrow(
147
- /no configured marketplace offers a plugin named 'nope'\n\nSearched: stigmer, cursor-plugins\. Not searched.*broken/,
166
+ /no configured marketplace offers a plugin named 'nope'\n\nSearched: stigmer, acme-plugins\. Not searched.*broken/,
148
167
  );
149
168
  });
150
169
 
151
170
  it("refuses a bare name two marketplaces offer, naming each qualified ref, and leaves no temp tree behind", async () => {
152
171
  const local = {
153
- name: "cursor-plugins",
172
+ name: "acme-plugins",
154
173
  source: { type: "local", path: root } as const,
155
174
  };
156
175
  const remote = {
@@ -163,11 +182,59 @@ describe("locateEntry", () => {
163
182
  fetchImpl: servingZipOf(remoteFiles),
164
183
  }),
165
184
  ).rejects.toThrow(
166
- /offered by more than one marketplace: cursor-plugins, remote\n\nName the one you mean: cursor-plugins\/thermos or remote\/thermos/,
185
+ /offered by more than one marketplace: acme-plugins, remote\n\nName the one you mean: acme-plugins\/thermos or remote\/thermos/,
167
186
  );
168
187
  expect(countTempTrees()).toBe(before);
169
188
  });
170
189
 
190
+ it("asks a GitHub source through its marketplace file alone and downloads the zipball only for a holder", async () => {
191
+ const local = {
192
+ name: "acme-plugins",
193
+ source: { type: "local", path: root } as const,
194
+ };
195
+ const remote = {
196
+ name: "remote",
197
+ source: { type: "github", repo: "a/b" } as const,
198
+ };
199
+ const requests: string[] = [];
200
+ // `github` is offered by the local fixture alone: the remote is peeked, not downloaded.
201
+ const located = await locateEntry(parseInstallRef("github"), listing(local, remote), {
202
+ fetchImpl: servingZipOf(remoteFiles, "r-HEAD", requests),
203
+ });
204
+ located.open.dispose();
205
+ expect(requests.some((url) => url.startsWith("https://raw.githubusercontent.com/a/b/"))).toBe(true);
206
+ expect(requests.some((url) => url.startsWith("https://codeload.github.com/"))).toBe(false);
207
+ });
208
+
209
+ it("treats a GitHub repository with no marketplace file as declaring nothing, and a declared entry the tree lacks as not held", async () => {
210
+ const empty = {
211
+ name: "empty",
212
+ source: { type: "github", repo: "a/b" } as const,
213
+ };
214
+ await expect(
215
+ locateEntry(parseInstallRef("nope"), listing(empty), {
216
+ fetchImpl: servingZipOf({}),
217
+ }),
218
+ ).rejects.toThrow(/no configured marketplace offers a plugin named 'nope'\n\nSearched: stigmer, empty\./);
219
+
220
+ const phantom = {
221
+ name: "phantom",
222
+ source: { type: "github", repo: "a/b" } as const,
223
+ };
224
+ const before = countTempTrees();
225
+ await expect(
226
+ locateEntry(parseInstallRef("ghost"), listing(phantom), {
227
+ fetchImpl: servingZipOf({
228
+ "marketplace.json": JSON.stringify({
229
+ name: "phantom",
230
+ plugins: [{ name: "ghost", source: "./ghost" }],
231
+ }),
232
+ }),
233
+ }),
234
+ ).rejects.toThrow(/no configured marketplace offers a plugin named 'ghost'/);
235
+ expect(countTempTrees()).toBe(before);
236
+ });
237
+
171
238
  it("disposes a fetched tree when the located entry is released", async () => {
172
239
  const remote = {
173
240
  name: "remote",