@stigmer/cli 3.18.0-dev.20260918103812 → 3.18.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/commands/bootstrap.d.ts +3 -0
- package/commands/bootstrap.d.ts.map +1 -0
- package/commands/bootstrap.js +75 -0
- package/commands/bootstrap.js.map +1 -0
- package/commands/marketplace.d.ts.map +1 -1
- package/commands/marketplace.js +7 -7
- package/commands/marketplace.js.map +1 -1
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +8 -7
- package/config/resolve.js.map +1 -1
- package/local/bootstrap.d.ts +32 -13
- package/local/bootstrap.d.ts.map +1 -1
- package/local/bootstrap.js +105 -64
- package/local/bootstrap.js.map +1 -1
- package/local/daemon/launch.d.ts +1 -1
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/paths.d.ts +1 -1
- package/local/paths.js +1 -1
- package/local/plugins/defaults.d.ts +27 -7
- package/local/plugins/defaults.d.ts.map +1 -1
- package/local/plugins/defaults.js +55 -28
- package/local/plugins/defaults.js.map +1 -1
- package/local/runtime/runtimes-install.d.ts.map +1 -1
- package/local/runtime/runtimes-install.js +7 -4
- package/local/runtime/runtimes-install.js.map +1 -1
- package/local/seedpack-retire.d.ts +63 -0
- package/local/seedpack-retire.d.ts.map +1 -0
- package/local/seedpack-retire.js +361 -0
- package/local/seedpack-retire.js.map +1 -0
- package/local/system-org.d.ts +15 -0
- package/local/system-org.d.ts.map +1 -0
- package/local/system-org.js +57 -0
- package/local/system-org.js.map +1 -0
- package/marketplace/config.d.ts +3 -1
- package/marketplace/config.d.ts.map +1 -1
- package/marketplace/config.js +24 -11
- package/marketplace/config.js.map +1 -1
- package/marketplace/github.d.ts +37 -0
- package/marketplace/github.d.ts.map +1 -1
- package/marketplace/github.js +70 -1
- package/marketplace/github.js.map +1 -1
- package/marketplace/install.d.ts +13 -6
- package/marketplace/install.d.ts.map +1 -1
- package/marketplace/install.js +63 -16
- package/marketplace/install.js.map +1 -1
- package/marketplace/official.js +4 -4
- package/marketplace/official.js.map +1 -1
- package/marketplace/render.d.ts.map +1 -1
- package/marketplace/render.js +10 -4
- package/marketplace/render.js.map +1 -1
- package/package.json +7 -7
- package/program.js +2 -2
- package/program.js.map +1 -1
- package/resources/apply/declarative.js +2 -2
- package/resources/apply/declarative.js.map +1 -1
- package/resources/plugin.d.ts.map +1 -1
- package/resources/plugin.js +12 -9
- package/resources/plugin.js.map +1 -1
- package/src/commands/bootstrap.test.ts +253 -0
- package/src/commands/bootstrap.ts +91 -0
- package/src/commands/install.test.ts +28 -9
- package/src/commands/marketplace.test.ts +28 -14
- package/src/commands/marketplace.ts +9 -7
- package/src/config/resolve.test.ts +2 -2
- package/src/config/resolve.ts +8 -7
- package/src/local/bootstrap.test.ts +110 -38
- package/src/local/bootstrap.ts +179 -89
- package/src/local/daemon/launch.ts +1 -1
- package/src/local/paths.ts +1 -1
- package/src/local/plugins/defaults.test.ts +46 -13
- package/src/local/plugins/defaults.ts +77 -34
- package/src/local/runtime/runtimes-install.test.ts +2 -0
- package/src/local/runtime/runtimes-install.ts +7 -4
- package/src/local/seedpack-retire.test.ts +499 -0
- package/src/local/seedpack-retire.ts +474 -0
- package/src/local/system-org.test.ts +124 -0
- package/src/local/system-org.ts +63 -0
- package/src/marketplace/__fixtures__/cursor-marketplace.ts +5 -2
- package/src/marketplace/config.test.ts +47 -22
- package/src/marketplace/config.ts +27 -12
- package/src/marketplace/github.test.ts +77 -1
- package/src/marketplace/github.ts +115 -1
- package/src/marketplace/install.test.ts +84 -17
- package/src/marketplace/install.ts +73 -15
- package/src/marketplace/official.ts +4 -4
- package/src/marketplace/read.test.ts +1 -1
- package/src/marketplace/render.ts +13 -4
- package/src/program.ts +2 -2
- package/src/resources/apply/declarative.integration.test.ts +2 -2
- package/src/resources/apply/declarative.ts +2 -2
- package/src/resources/connect/discover.test.ts +1 -1
- package/src/resources/plugin.ts +11 -10
- package/commands/seedpack.d.ts +0 -3
- package/commands/seedpack.d.ts.map +0 -1
- package/commands/seedpack.js +0 -98
- package/commands/seedpack.js.map +0 -1
- package/local/seedpack/apply.d.ts +0 -72
- package/local/seedpack/apply.d.ts.map +0 -1
- package/local/seedpack/apply.js +0 -145
- package/local/seedpack/apply.js.map +0 -1
- package/local/seedpack/content.d.ts +0 -61
- package/local/seedpack/content.d.ts.map +0 -1
- package/local/seedpack/content.js +0 -178
- package/local/seedpack/content.js.map +0 -1
- package/src/commands/seedpack.ts +0 -116
- package/src/local/seedpack/apply.test.ts +0 -287
- package/src/local/seedpack/apply.ts +0 -199
- package/src/local/seedpack/content.test.ts +0 -169
- 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
|
|
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
|
|
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:
|
|
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
|
-
|
|
88
|
-
"
|
|
100
|
+
...BUILT_IN_NAMES,
|
|
101
|
+
"acme",
|
|
89
102
|
"pinned",
|
|
90
103
|
"team",
|
|
91
104
|
]);
|
|
92
|
-
expect(listing.known[
|
|
105
|
+
expect(listing.known[4]?.source).toEqual({
|
|
93
106
|
type: "github",
|
|
94
|
-
repo: "
|
|
107
|
+
repo: "acme/plugins",
|
|
95
108
|
});
|
|
96
|
-
expect(listing.known[
|
|
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[
|
|
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("
|
|
134
|
+
addMarketplace("acme", {
|
|
118
135
|
type: "github",
|
|
119
|
-
repo: "
|
|
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:
|
|
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
|
-
|
|
130
|
-
"
|
|
146
|
+
...BUILT_IN_NAMES,
|
|
147
|
+
"acme",
|
|
131
148
|
"team",
|
|
132
149
|
]);
|
|
133
150
|
|
|
134
|
-
removeMarketplace("
|
|
151
|
+
removeMarketplace("acme");
|
|
135
152
|
expect(listMarketplaces().known.map((m) => m.name)).toEqual([
|
|
136
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
2
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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[] = [
|
|
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
|
|
126
|
+
if (isBuiltInMarketplaceName(name)) {
|
|
110
127
|
throw new UsageError(
|
|
111
|
-
|
|
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
|
|
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 {
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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", "
|
|
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 '
|
|
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
|
-
/**
|
|
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 () =>
|
|
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: "
|
|
121
|
+
name: "acme-plugins",
|
|
103
122
|
source: { type: "local", path: root } as const,
|
|
104
123
|
};
|
|
105
124
|
const located = await locateEntry(
|
|
106
|
-
parseInstallRef("
|
|
125
|
+
parseInstallRef("acme-plugins/thermos"),
|
|
107
126
|
listing(local),
|
|
108
127
|
);
|
|
109
128
|
try {
|
|
110
|
-
expect(located.marketplace.name).toBe("
|
|
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: "
|
|
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("
|
|
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: "
|
|
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,
|
|
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: "
|
|
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:
|
|
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",
|