@stigmer/cli 3.16.0 → 3.16.1-dev.20260917224426
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/agent-exec-flags.d.ts +2 -5
- package/commands/agent-exec-flags.d.ts.map +1 -1
- package/commands/agent-exec-flags.js +6 -13
- package/commands/agent-exec-flags.js.map +1 -1
- package/commands/connect.js +1 -1
- package/commands/connect.js.map +1 -1
- package/commands/install.d.ts +3 -0
- package/commands/install.d.ts.map +1 -0
- package/commands/install.js +68 -0
- package/commands/install.js.map +1 -0
- package/commands/marketplace.d.ts +3 -0
- package/commands/marketplace.d.ts.map +1 -0
- package/commands/marketplace.js +94 -0
- package/commands/marketplace.js.map +1 -0
- package/commands/push.js +2 -7
- package/commands/push.js.map +1 -1
- package/commands/run.js +0 -3
- package/commands/run.js.map +1 -1
- package/config/config.d.ts +19 -0
- package/config/config.d.ts.map +1 -1
- package/config/config.js +15 -0
- package/config/config.js.map +1 -1
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js.map +1 -1
- package/local/bootstrap.d.ts +21 -0
- package/local/bootstrap.d.ts.map +1 -0
- package/local/bootstrap.js +88 -0
- package/local/bootstrap.js.map +1 -0
- package/local/daemon/launch.d.ts +2 -2
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/daemon/launch.js +4 -34
- package/local/daemon/launch.js.map +1 -1
- package/local/daemon/process.d.ts +1 -1
- package/local/plugins/defaults.d.ts +35 -0
- package/local/plugins/defaults.d.ts.map +1 -0
- package/local/plugins/defaults.js +83 -0
- package/local/plugins/defaults.js.map +1 -0
- package/local/seedpack/content.d.ts +2 -1
- package/local/seedpack/content.d.ts.map +1 -1
- package/local/seedpack/content.js +5 -34
- package/local/seedpack/content.js.map +1 -1
- package/marketplace/config.d.ts +59 -0
- package/marketplace/config.d.ts.map +1 -0
- package/marketplace/config.js +162 -0
- package/marketplace/config.js.map +1 -0
- package/marketplace/github.d.ts +31 -0
- package/marketplace/github.d.ts.map +1 -0
- package/marketplace/github.js +203 -0
- package/marketplace/github.js.map +1 -0
- package/marketplace/index.d.ts +8 -0
- package/marketplace/index.d.ts.map +1 -0
- package/marketplace/index.js +12 -0
- package/marketplace/index.js.map +1 -0
- package/marketplace/install.d.ts +28 -0
- package/marketplace/install.d.ts.map +1 -0
- package/marketplace/install.js +109 -0
- package/marketplace/install.js.map +1 -0
- package/marketplace/materialize.d.ts +19 -0
- package/marketplace/materialize.d.ts.map +1 -0
- package/marketplace/materialize.js +53 -0
- package/marketplace/materialize.js.map +1 -0
- package/marketplace/official.d.ts +28 -0
- package/marketplace/official.d.ts.map +1 -0
- package/marketplace/official.js +86 -0
- package/marketplace/official.js.map +1 -0
- package/marketplace/read.d.ts +17 -0
- package/marketplace/read.d.ts.map +1 -0
- package/marketplace/read.js +44 -0
- package/marketplace/read.js.map +1 -0
- package/marketplace/ref.d.ts +13 -0
- package/marketplace/ref.d.ts.map +1 -0
- package/marketplace/ref.js +54 -0
- package/marketplace/ref.js.map +1 -0
- package/marketplace/render.d.ts +22 -0
- package/marketplace/render.d.ts.map +1 -0
- package/marketplace/render.js +118 -0
- package/marketplace/render.js.map +1 -0
- package/output/ndjson.js +1 -1
- package/output/ndjson.js.map +1 -1
- package/package.json +7 -6
- package/program.d.ts.map +1 -1
- package/program.js +4 -2
- package/program.js.map +1 -1
- package/resources/ignore/index.d.ts +2 -4
- package/resources/ignore/index.d.ts.map +1 -1
- package/resources/ignore/index.js +5 -5
- package/resources/ignore/index.js.map +1 -1
- package/resources/ignore/matcher.d.ts +2 -35
- package/resources/ignore/matcher.d.ts.map +1 -1
- package/resources/ignore/matcher.js +25 -109
- package/resources/ignore/matcher.js.map +1 -1
- package/resources/plugin.d.ts +50 -18
- package/resources/plugin.d.ts.map +1 -1
- package/resources/plugin.js +73 -74
- package/resources/plugin.js.map +1 -1
- package/resources/run/agent-exec.js +1 -1
- package/resources/run/agent-exec.js.map +1 -1
- package/resources/run/prepare.d.ts +8 -19
- package/resources/run/prepare.d.ts.map +1 -1
- package/resources/run/prepare.js +4 -4
- package/resources/run/prepare.js.map +1 -1
- package/resources/skill.d.ts +2 -2
- package/resources/skill.d.ts.map +1 -1
- package/resources/skill.js +6 -10
- package/resources/skill.js.map +1 -1
- package/src/commands/agent-exec-flags.ts +6 -12
- package/src/commands/connect.ts +1 -1
- package/src/commands/install.test.ts +289 -0
- package/src/commands/install.ts +108 -0
- package/src/commands/marketplace.test.ts +200 -0
- package/src/commands/marketplace.ts +131 -0
- package/src/commands/push.ts +5 -10
- package/src/commands/run.ts +0 -3
- package/src/config/config.ts +37 -0
- package/src/config/index.ts +1 -0
- package/src/local/bootstrap.test.ts +71 -0
- package/src/local/bootstrap.ts +133 -0
- package/src/local/daemon/launch.ts +5 -40
- package/src/local/daemon/process.ts +1 -1
- package/src/local/plugins/defaults.test.ts +238 -0
- package/src/local/plugins/defaults.ts +135 -0
- package/src/local/seedpack/content.ts +11 -42
- package/src/marketplace/__fixtures__/cursor-marketplace.ts +62 -0
- package/src/marketplace/config.test.ts +245 -0
- package/src/marketplace/config.ts +236 -0
- package/src/marketplace/github.test.ts +318 -0
- package/src/marketplace/github.ts +296 -0
- package/src/marketplace/index.ts +54 -0
- package/src/marketplace/install.test.ts +194 -0
- package/src/marketplace/install.ts +159 -0
- package/src/marketplace/materialize.ts +80 -0
- package/src/marketplace/official.test.ts +107 -0
- package/src/marketplace/official.ts +125 -0
- package/src/marketplace/read.test.ts +94 -0
- package/src/marketplace/read.ts +76 -0
- package/src/marketplace/ref.test.ts +179 -0
- package/src/marketplace/ref.ts +74 -0
- package/src/marketplace/render.ts +154 -0
- package/src/output/ndjson.ts +1 -1
- package/src/program.test.ts +4 -13
- package/src/program.ts +4 -2
- package/src/resources/ignore/ignore.test.ts +23 -100
- package/src/resources/ignore/index.ts +11 -8
- package/src/resources/ignore/matcher.ts +33 -136
- package/src/resources/plugin.test.ts +35 -6
- package/src/resources/plugin.ts +132 -85
- package/src/resources/run/agent-exec.ts +1 -1
- package/src/resources/run/prepare.test.ts +4 -18
- package/src/resources/run/prepare.ts +12 -24
- package/src/resources/skill.ts +6 -10
- package/commands/draft.d.ts +0 -3
- package/commands/draft.d.ts.map +0 -1
- package/commands/draft.js +0 -115
- package/commands/draft.js.map +0 -1
- package/resources/ignore/defaults.d.ts +0 -2
- package/resources/ignore/defaults.d.ts.map +0 -1
- package/resources/ignore/defaults.js +0 -140
- package/resources/ignore/defaults.js.map +0 -1
- package/resources/ignore/match.d.ts +0 -3
- package/resources/ignore/match.d.ts.map +0 -1
- package/resources/ignore/match.js +0 -166
- package/resources/ignore/match.js.map +0 -1
- package/resources/ignore/pattern.d.ts +0 -13
- package/resources/ignore/pattern.d.ts.map +0 -1
- package/resources/ignore/pattern.js +0 -118
- package/resources/ignore/pattern.js.map +0 -1
- package/src/commands/draft.ts +0 -189
- package/src/resources/ignore/defaults.ts +0 -155
- package/src/resources/ignore/match.ts +0 -158
- package/src/resources/ignore/pattern.ts +0 -131
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Pins the GitHub source over zipballs built in-test: a good tree lands with
|
|
2
|
+
// its top directory stripped; every cap refuses naming itself, from the
|
|
3
|
+
// central directory before anything inflates; an escaping path, a second
|
|
4
|
+
// top-level directory and a lying declared size refuse; 404 and a network
|
|
5
|
+
// failure carry their own exit codes.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { zipSync } from "fflate";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { ExitCode } from "../errors/index.js";
|
|
13
|
+
import {
|
|
14
|
+
GITHUB_ZIPBALL_LIMITS,
|
|
15
|
+
fetchGitHubTree,
|
|
16
|
+
zipballUrl,
|
|
17
|
+
} from "./github.js";
|
|
18
|
+
|
|
19
|
+
let dest: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dest = mkdtempSync(join(tmpdir(), "stigmer-github-tree-"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
rmSync(dest, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
|
|
31
|
+
function zipball(files: Record<string, string | Uint8Array>): Uint8Array {
|
|
32
|
+
const tree: Record<string, Uint8Array> = {};
|
|
33
|
+
for (const [path, content] of Object.entries(files)) {
|
|
34
|
+
tree[path] =
|
|
35
|
+
typeof content === "string" ? encoder.encode(content) : content;
|
|
36
|
+
}
|
|
37
|
+
return zipSync(tree, { level: 6 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function serving(
|
|
41
|
+
bytes: Uint8Array,
|
|
42
|
+
init: { status?: number; contentLength?: number } = {},
|
|
43
|
+
): typeof globalThis.fetch {
|
|
44
|
+
return async () => {
|
|
45
|
+
const headers = new Headers();
|
|
46
|
+
if (init.contentLength !== undefined)
|
|
47
|
+
headers.set("content-length", String(init.contentLength));
|
|
48
|
+
const body = bytes.buffer.slice(
|
|
49
|
+
bytes.byteOffset,
|
|
50
|
+
bytes.byteOffset + bytes.byteLength,
|
|
51
|
+
) as ArrayBuffer;
|
|
52
|
+
return new Response(
|
|
53
|
+
init.status === undefined || init.status === 200 ? body : "nope",
|
|
54
|
+
{
|
|
55
|
+
status: init.status ?? 200,
|
|
56
|
+
headers,
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same archive with one entry's central-directory record rewritten: a
|
|
64
|
+
* declaration that lies (`uncompressedSize`) or a checksum that does not
|
|
65
|
+
* match (`crc32`). The payload is untouched, so only a reader that budgets
|
|
66
|
+
* from the central directory and verifies the CRC can refuse it.
|
|
67
|
+
*/
|
|
68
|
+
function declaring(
|
|
69
|
+
bytes: Uint8Array,
|
|
70
|
+
entryName: string,
|
|
71
|
+
patch: { uncompressedSize?: number; crc32?: number },
|
|
72
|
+
): Uint8Array {
|
|
73
|
+
const patched = new Uint8Array(bytes);
|
|
74
|
+
const view = new DataView(
|
|
75
|
+
patched.buffer,
|
|
76
|
+
patched.byteOffset,
|
|
77
|
+
patched.byteLength,
|
|
78
|
+
);
|
|
79
|
+
const name = encoder.encode(entryName);
|
|
80
|
+
for (let offset = 0; offset + 46 <= patched.length; offset += 1) {
|
|
81
|
+
if (view.getUint32(offset, true) !== 0x02014b50) continue;
|
|
82
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
83
|
+
const recorded = patched.subarray(offset + 46, offset + 46 + nameLength);
|
|
84
|
+
if (
|
|
85
|
+
recorded.length === name.length &&
|
|
86
|
+
recorded.every((byte, i) => byte === name[i])
|
|
87
|
+
) {
|
|
88
|
+
if (patch.crc32 !== undefined)
|
|
89
|
+
view.setUint32(offset + 16, patch.crc32, true);
|
|
90
|
+
if (patch.uncompressedSize !== undefined)
|
|
91
|
+
view.setUint32(offset + 24, patch.uncompressedSize, true);
|
|
92
|
+
return patched;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`no central directory record for ${entryName}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function refusal(
|
|
99
|
+
promise: Promise<unknown>,
|
|
100
|
+
): Promise<{ message: string; exitCode: number }> {
|
|
101
|
+
try {
|
|
102
|
+
await promise;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const e = error as { message: string; exitCode: number };
|
|
105
|
+
return { message: e.message, exitCode: e.exitCode };
|
|
106
|
+
}
|
|
107
|
+
throw new Error("expected a refusal");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("zipballUrl", () => {
|
|
111
|
+
it("names codeload's HEAD by default and the ref when given", () => {
|
|
112
|
+
expect(zipballUrl({ repo: "cursor/plugins" })).toBe(
|
|
113
|
+
"https://codeload.github.com/cursor/plugins/zip/HEAD",
|
|
114
|
+
);
|
|
115
|
+
expect(zipballUrl({ repo: "cursor/plugins", ref: "v1" })).toBe(
|
|
116
|
+
"https://codeload.github.com/cursor/plugins/zip/v1",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("fetchGitHubTree", () => {
|
|
122
|
+
it("extracts the tree with the zipball's top directory stripped", async () => {
|
|
123
|
+
const bytes = zipball({
|
|
124
|
+
"plugins-HEAD/.cursor-plugin/marketplace.json": JSON.stringify({
|
|
125
|
+
name: "cursor-plugins",
|
|
126
|
+
plugins: [],
|
|
127
|
+
}),
|
|
128
|
+
"plugins-HEAD/thermos/.cursor-plugin/plugin.json": JSON.stringify({
|
|
129
|
+
name: "thermos",
|
|
130
|
+
}),
|
|
131
|
+
"plugins-HEAD/thermos/skills/x/SKILL.md":
|
|
132
|
+
"---\nname: x\ndescription: d\n---\nbody\n",
|
|
133
|
+
});
|
|
134
|
+
await fetchGitHubTree(
|
|
135
|
+
{ repo: "cursor/plugins" },
|
|
136
|
+
{ destDir: dest, fetchImpl: serving(bytes) },
|
|
137
|
+
);
|
|
138
|
+
expect(existsSync(join(dest, "plugins-HEAD"))).toBe(false);
|
|
139
|
+
expect(
|
|
140
|
+
JSON.parse(
|
|
141
|
+
readFileSync(join(dest, ".cursor-plugin", "marketplace.json"), "utf8"),
|
|
142
|
+
).name,
|
|
143
|
+
).toBe("cursor-plugins");
|
|
144
|
+
expect(
|
|
145
|
+
readFileSync(join(dest, "thermos", "skills", "x", "SKILL.md"), "utf8"),
|
|
146
|
+
).toContain("body");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses an archive over the download cap from content-length, before reading the body", async () => {
|
|
150
|
+
const bytes = zipball({ "r-HEAD/marketplace.json": "{}" });
|
|
151
|
+
const outcome = await refusal(
|
|
152
|
+
fetchGitHubTree(
|
|
153
|
+
{ repo: "a/b" },
|
|
154
|
+
{
|
|
155
|
+
destDir: dest,
|
|
156
|
+
fetchImpl: serving(bytes, {
|
|
157
|
+
contentLength: GITHUB_ZIPBALL_LIMITS.downloadBytes + 1,
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
expect(outcome.message).toMatch(/over the 64\.0 MiB download cap/);
|
|
163
|
+
expect(outcome.exitCode).toBe(ExitCode.Usage);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("refuses more entries than the cap from the central directory", async () => {
|
|
167
|
+
const files: Record<string, string> = {};
|
|
168
|
+
for (let i = 0; i <= GITHUB_ZIPBALL_LIMITS.entries; i += 1)
|
|
169
|
+
files[`r-HEAD/f${i}`] = "";
|
|
170
|
+
const outcome = await refusal(
|
|
171
|
+
fetchGitHubTree(
|
|
172
|
+
{ repo: "a/b" },
|
|
173
|
+
{ destDir: dest, fetchImpl: serving(zipball(files)) },
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
expect(outcome.message).toMatch(/entries, over the 20000 cap/);
|
|
177
|
+
expect(existsSync(join(dest, "f0"))).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("refuses a declared inflated size over the cap from the central directory, before inflating", async () => {
|
|
181
|
+
const bytes = declaring(
|
|
182
|
+
zipball({ "r-HEAD/marketplace.json": "{}", "r-HEAD/big.bin": "tiny" }),
|
|
183
|
+
"r-HEAD/big.bin",
|
|
184
|
+
{
|
|
185
|
+
uncompressedSize: GITHUB_ZIPBALL_LIMITS.inflatedBytes + 1,
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
const outcome = await refusal(
|
|
189
|
+
fetchGitHubTree(
|
|
190
|
+
{ repo: "a/b" },
|
|
191
|
+
{ destDir: dest, fetchImpl: serving(bytes) },
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
expect(outcome.message).toMatch(
|
|
195
|
+
/inflates to 256\.0 MiB, over the 256\.0 MiB cap/,
|
|
196
|
+
);
|
|
197
|
+
expect(existsSync(join(dest, "marketplace.json"))).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("refuses an entry whose declared size undercounts what it inflates to", async () => {
|
|
201
|
+
const bytes = declaring(
|
|
202
|
+
zipball({
|
|
203
|
+
"r-HEAD/marketplace.json": "{}",
|
|
204
|
+
"r-HEAD/liar.txt": "twelve bytes, and then some more text",
|
|
205
|
+
}),
|
|
206
|
+
"r-HEAD/liar.txt",
|
|
207
|
+
{ uncompressedSize: 3 },
|
|
208
|
+
);
|
|
209
|
+
const outcome = await refusal(
|
|
210
|
+
fetchGitHubTree(
|
|
211
|
+
{ repo: "a/b" },
|
|
212
|
+
{ destDir: dest, fetchImpl: serving(bytes) },
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
expect(outcome.message).toMatch(
|
|
216
|
+
/entry 'liar\.txt' inflated past its declared size/,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("refuses an entry whose bytes do not match its checksum", async () => {
|
|
221
|
+
const bytes = declaring(
|
|
222
|
+
zipball({
|
|
223
|
+
"r-HEAD/marketplace.json": "{}",
|
|
224
|
+
"r-HEAD/tampered.txt": "some content here",
|
|
225
|
+
}),
|
|
226
|
+
"r-HEAD/tampered.txt",
|
|
227
|
+
{ crc32: 0xdeadbeef },
|
|
228
|
+
);
|
|
229
|
+
const outcome = await refusal(
|
|
230
|
+
fetchGitHubTree(
|
|
231
|
+
{ repo: "a/b" },
|
|
232
|
+
{ destDir: dest, fetchImpl: serving(bytes) },
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
expect(outcome.message).toMatch(
|
|
236
|
+
/entry 'tampered\.txt' failed its checksum/,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("refuses an entry that would escape the destination", async () => {
|
|
241
|
+
const outcome = await refusal(
|
|
242
|
+
fetchGitHubTree(
|
|
243
|
+
{ repo: "a/b" },
|
|
244
|
+
{
|
|
245
|
+
destDir: dest,
|
|
246
|
+
fetchImpl: serving(
|
|
247
|
+
zipball({ "r-HEAD/marketplace.json": "{}", "r-HEAD/../evil": "x" }),
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
expect(outcome.message).toMatch(/would escape the destination directory/);
|
|
253
|
+
expect(existsSync(join(dest, "..", "evil"))).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("refuses an archive with more than one top-level directory", async () => {
|
|
257
|
+
const outcome = await refusal(
|
|
258
|
+
fetchGitHubTree(
|
|
259
|
+
{ repo: "a/b" },
|
|
260
|
+
{
|
|
261
|
+
destDir: dest,
|
|
262
|
+
fetchImpl: serving(
|
|
263
|
+
zipball({
|
|
264
|
+
"one/marketplace.json": "{}",
|
|
265
|
+
"two/marketplace.json": "{}",
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
},
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
expect(outcome.message).toMatch(
|
|
272
|
+
/more than one top-level directory \('one' and 'two'\)/,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("refuses bytes that are not a zip", async () => {
|
|
277
|
+
const outcome = await refusal(
|
|
278
|
+
fetchGitHubTree(
|
|
279
|
+
{ repo: "a/b" },
|
|
280
|
+
{
|
|
281
|
+
destDir: dest,
|
|
282
|
+
fetchImpl: serving(encoder.encode("<html>not a zip</html>")),
|
|
283
|
+
},
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
expect(outcome.message).toMatch(/not a readable zip/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("maps 404 to NotFound and a network failure to Connection, each with its own sentence", async () => {
|
|
290
|
+
const missing = await refusal(
|
|
291
|
+
fetchGitHubTree(
|
|
292
|
+
{ repo: "a/b", ref: "v9" },
|
|
293
|
+
{
|
|
294
|
+
destDir: dest,
|
|
295
|
+
fetchImpl: serving(new Uint8Array(), { status: 404 }),
|
|
296
|
+
},
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
expect(missing.message).toBe(
|
|
300
|
+
"GitHub has no public repository 'a/b' at ref 'v9'",
|
|
301
|
+
);
|
|
302
|
+
expect(missing.exitCode).toBe(ExitCode.NotFound);
|
|
303
|
+
|
|
304
|
+
const offline = await refusal(
|
|
305
|
+
fetchGitHubTree(
|
|
306
|
+
{ repo: "a/b" },
|
|
307
|
+
{
|
|
308
|
+
destDir: dest,
|
|
309
|
+
fetchImpl: async () => {
|
|
310
|
+
throw new Error("ENOTFOUND");
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
expect(offline.message).toBe("could not reach GitHub for a/b");
|
|
316
|
+
expect(offline.exitCode).toBe(ExitCode.Connection);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// A public GitHub repository as a marketplace tree: the zipball of one ref,
|
|
2
|
+
// downloaded from codeload and extracted into a directory the one plugin
|
|
3
|
+
// walker then reads like any other.
|
|
4
|
+
//
|
|
5
|
+
// Why a zipball and not `git`: codeload needs no API call, no token and no
|
|
6
|
+
// `git` binary, and the tree convention every vendor publishes (Cursor,
|
|
7
|
+
// Claude Code, Codex) is "the repository at a ref", which is exactly what a
|
|
8
|
+
// zipball is. Why to disk and not in memory: `install` and `push plugin`
|
|
9
|
+
// must yield one digest for one tree, and the digest is a function of the
|
|
10
|
+
// CLI's gitignore-aware, symlink-skipping walk over a directory. A second,
|
|
11
|
+
// in-memory walk with its own rules would be a second identity.
|
|
12
|
+
//
|
|
13
|
+
// The archive is untrusted input, so it is read the way the server's push
|
|
14
|
+
// gate reads one: the shape from the central directory (the shared
|
|
15
|
+
// `@stigmer/zip-structure` parser) BEFORE a byte is inflated, entry count and
|
|
16
|
+
// total declared size against the caps, every path contained; then each
|
|
17
|
+
// entry inflated with node:zlib under its declared size as a hard output cap
|
|
18
|
+
// and its CRC verified, because a declaration is a claim and the bytes are
|
|
19
|
+
// the fact. That per-entry read mirrors `archive/inflate.ts` in the server,
|
|
20
|
+
// its second consumer; when a third appears it moves to `@stigmer/zip-
|
|
21
|
+
// structure` beside `crc32`. The caps are set against the three vendors'
|
|
22
|
+
// real trees at HEAD (cursor/plugins 4.5 MiB down and 5.5 MiB inflated over
|
|
23
|
+
// 1,169 entries; anthropics/claude-code 11.7 / 13.6 / 1,595; openai/codex
|
|
24
|
+
// 18.6 / 76.8 / 9,188, its marketplace file at the repository root so the
|
|
25
|
+
// whole repository is the tree), with room above the largest.
|
|
26
|
+
|
|
27
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
import { inflateRawSync } from "node:zlib";
|
|
30
|
+
import { isContainedPath } from "@stigmer/plugin-package";
|
|
31
|
+
import {
|
|
32
|
+
type ZipStructuralEntry,
|
|
33
|
+
crc32,
|
|
34
|
+
parseZipStructure,
|
|
35
|
+
} from "@stigmer/zip-structure";
|
|
36
|
+
import { CliExitError } from "../errors/cli-exit-error.js";
|
|
37
|
+
import { ExitCode } from "../errors/exit-codes.js";
|
|
38
|
+
import { log } from "../logger.js";
|
|
39
|
+
|
|
40
|
+
/** ZIP compression methods this reader opens, the two the platform's gates register. */
|
|
41
|
+
const METHOD_STORED = 0;
|
|
42
|
+
const METHOD_DEFLATED = 8;
|
|
43
|
+
|
|
44
|
+
/** The caps a repository zipball must fit under; each refusal names the one it hit. */
|
|
45
|
+
export const GITHUB_ZIPBALL_LIMITS = {
|
|
46
|
+
/** The compressed archive as downloaded. */
|
|
47
|
+
downloadBytes: 64 * 1024 * 1024,
|
|
48
|
+
/** The sum of every entry's declared uncompressed size. */
|
|
49
|
+
inflatedBytes: 256 * 1024 * 1024,
|
|
50
|
+
/** Files and directories the central directory lists. */
|
|
51
|
+
entries: 20_000,
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
/** The ref codeload serves when none is named: the repository's default branch. */
|
|
55
|
+
const DEFAULT_REF = "HEAD";
|
|
56
|
+
|
|
57
|
+
export interface GitHubTreeSource {
|
|
58
|
+
/** `owner/repo`. */
|
|
59
|
+
readonly repo: string;
|
|
60
|
+
/** Branch, tag or commit; the default branch when absent. */
|
|
61
|
+
readonly ref?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FetchGitHubTreeOptions {
|
|
65
|
+
/** Where the tree is written; created if missing. The caller owns its lifetime. */
|
|
66
|
+
readonly destDir: string;
|
|
67
|
+
/** The HTTP client (injectable for tests). */
|
|
68
|
+
readonly fetchImpl?: typeof globalThis.fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The URL codeload answers with the zipball of `ref` (no API, no token). */
|
|
72
|
+
export function zipballUrl(source: GitHubTreeSource): string {
|
|
73
|
+
return `https://codeload.github.com/${source.repo}/zip/${source.ref ?? DEFAULT_REF}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Download the zipball and extract it into `destDir` with the zipball's
|
|
78
|
+
* single top-level directory (`<repo>-<ref>/`) stripped, so `destDir` IS the
|
|
79
|
+
* repository root. Throws a `CliExitError` naming what refused: the
|
|
80
|
+
* repository, a cap, or an entry that would escape the directory.
|
|
81
|
+
*/
|
|
82
|
+
export async function fetchGitHubTree(
|
|
83
|
+
source: GitHubTreeSource,
|
|
84
|
+
options: FetchGitHubTreeOptions,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const bytes = await download(source, options.fetchImpl ?? globalThis.fetch);
|
|
87
|
+
const entries = inspect(bytes, source);
|
|
88
|
+
|
|
89
|
+
mkdirSync(options.destDir, { recursive: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const content = inflate(entry, source);
|
|
92
|
+
const target = join(options.destDir, ...entry.relative.split("/"));
|
|
93
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
94
|
+
writeFileSync(target, content);
|
|
95
|
+
}
|
|
96
|
+
log.debug("github tree extracted", {
|
|
97
|
+
repo: source.repo,
|
|
98
|
+
ref: source.ref ?? DEFAULT_REF,
|
|
99
|
+
files: entries.length,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One entry's bytes, inflated under its declared size as the output cap
|
|
105
|
+
* and checked against its CRC. An entry that inflates past what it declared
|
|
106
|
+
* lied about its budget; one whose bytes do not match its CRC is corrupt or
|
|
107
|
+
* tampered; a method neither the server nor Go registers is not opened.
|
|
108
|
+
*/
|
|
109
|
+
function inflate(entry: PlannedEntry, source: GitHubTreeSource): Uint8Array {
|
|
110
|
+
let content: Uint8Array;
|
|
111
|
+
switch (entry.zip.compressionMethod) {
|
|
112
|
+
case METHOD_STORED:
|
|
113
|
+
content = entry.zip.compressedData;
|
|
114
|
+
break;
|
|
115
|
+
case METHOD_DEFLATED:
|
|
116
|
+
try {
|
|
117
|
+
content = new Uint8Array(
|
|
118
|
+
inflateRawSync(entry.zip.compressedData, {
|
|
119
|
+
maxOutputLength: entry.declaredSize + 1,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (isOutputLengthError(error)) {
|
|
124
|
+
throw refusal(
|
|
125
|
+
source,
|
|
126
|
+
`entry '${entry.relative}' inflated past its declared size`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw refusal(
|
|
130
|
+
source,
|
|
131
|
+
`entry '${entry.relative}' could not be inflated (${error instanceof Error ? error.message : String(error)})`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
throw refusal(
|
|
137
|
+
source,
|
|
138
|
+
`entry '${entry.relative}' uses compression method ${entry.zip.compressionMethod}, which is not supported`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (content.length > entry.declaredSize) {
|
|
142
|
+
throw refusal(
|
|
143
|
+
source,
|
|
144
|
+
`entry '${entry.relative}' inflated past its declared size`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (crc32(content) !== entry.zip.crc32) {
|
|
148
|
+
throw refusal(source, `entry '${entry.relative}' failed its checksum`);
|
|
149
|
+
}
|
|
150
|
+
return content;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function download(
|
|
154
|
+
source: GitHubTreeSource,
|
|
155
|
+
fetchImpl: typeof globalThis.fetch,
|
|
156
|
+
): Promise<Uint8Array> {
|
|
157
|
+
const url = zipballUrl(source);
|
|
158
|
+
let response: Response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(url, { redirect: "follow" });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new CliExitError(
|
|
163
|
+
`could not reach GitHub for ${source.repo}`,
|
|
164
|
+
ExitCode.Connection,
|
|
165
|
+
[
|
|
166
|
+
`URL: ${url}`,
|
|
167
|
+
"Check the network connection and retry.",
|
|
168
|
+
error instanceof Error ? error.message : String(error),
|
|
169
|
+
],
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (response.status === 404) {
|
|
173
|
+
throw new CliExitError(
|
|
174
|
+
`GitHub has no public repository '${source.repo}'${source.ref === undefined ? "" : ` at ref '${source.ref}'`}`,
|
|
175
|
+
ExitCode.NotFound,
|
|
176
|
+
[
|
|
177
|
+
"Check the owner, the repository name and the ref.",
|
|
178
|
+
"Private repositories are not supported; a marketplace is public content.",
|
|
179
|
+
],
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw new CliExitError(
|
|
184
|
+
`GitHub answered ${response.status} for ${source.repo}`,
|
|
185
|
+
ExitCode.General,
|
|
186
|
+
[`URL: ${url}`],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const declaredLength = Number(response.headers.get("content-length") ?? "0");
|
|
190
|
+
if (declaredLength > GITHUB_ZIPBALL_LIMITS.downloadBytes) {
|
|
191
|
+
throw refusal(
|
|
192
|
+
source,
|
|
193
|
+
`the archive is ${mib(declaredLength)}, over the ${mib(GITHUB_ZIPBALL_LIMITS.downloadBytes)} download cap`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
197
|
+
if (bytes.length > GITHUB_ZIPBALL_LIMITS.downloadBytes) {
|
|
198
|
+
throw refusal(
|
|
199
|
+
source,
|
|
200
|
+
`the archive is ${mib(bytes.length)}, over the ${mib(GITHUB_ZIPBALL_LIMITS.downloadBytes)} download cap`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return bytes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// node:zlib's maxOutputLength violation: the output-cap arm.
|
|
207
|
+
function isOutputLengthError(error: unknown): boolean {
|
|
208
|
+
return (
|
|
209
|
+
error instanceof Error &&
|
|
210
|
+
(("code" in error &&
|
|
211
|
+
(error as { code?: string }).code === "ERR_BUFFER_TOO_LARGE") ||
|
|
212
|
+
error.message.includes("maxOutputLength"))
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One file the archive will yield: where it lands, how large it said it is, and its record. */
|
|
217
|
+
interface PlannedEntry {
|
|
218
|
+
/** Repository-relative path, the zipball's top directory stripped. */
|
|
219
|
+
readonly relative: string;
|
|
220
|
+
/** The central directory's uncompressed size, the inflate budget. */
|
|
221
|
+
readonly declaredSize: number;
|
|
222
|
+
readonly zip: ZipStructuralEntry;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The files to extract, in archive order, decided from the central directory
|
|
227
|
+
* alone. Refuses the caps, an entry outside the one top directory, and a
|
|
228
|
+
* path that would escape the destination.
|
|
229
|
+
*/
|
|
230
|
+
function inspect(bytes: Uint8Array, source: GitHubTreeSource): PlannedEntry[] {
|
|
231
|
+
let structure;
|
|
232
|
+
try {
|
|
233
|
+
structure = parseZipStructure(bytes);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw refusal(
|
|
236
|
+
source,
|
|
237
|
+
`the archive is not a readable zip (${error instanceof Error ? error.message : String(error)})`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (structure.length > GITHUB_ZIPBALL_LIMITS.entries) {
|
|
241
|
+
throw refusal(
|
|
242
|
+
source,
|
|
243
|
+
`the archive lists ${structure.length} entries, over the ${GITHUB_ZIPBALL_LIMITS.entries} cap`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
let inflated = 0;
|
|
247
|
+
for (const entry of structure) inflated += entry.uncompressedSize;
|
|
248
|
+
if (inflated > GITHUB_ZIPBALL_LIMITS.inflatedBytes) {
|
|
249
|
+
throw refusal(
|
|
250
|
+
source,
|
|
251
|
+
`the archive inflates to ${mib(inflated)}, over the ${mib(GITHUB_ZIPBALL_LIMITS.inflatedBytes)} cap`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const entries: PlannedEntry[] = [];
|
|
256
|
+
let top: string | undefined;
|
|
257
|
+
for (const entry of structure) {
|
|
258
|
+
const slash = entry.name.indexOf("/");
|
|
259
|
+
const head = slash === -1 ? entry.name : entry.name.slice(0, slash);
|
|
260
|
+
if (top === undefined) top = head;
|
|
261
|
+
if (head !== top) {
|
|
262
|
+
throw refusal(
|
|
263
|
+
source,
|
|
264
|
+
`the archive holds more than one top-level directory ('${top}' and '${head}')`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const relative =
|
|
268
|
+
slash === -1 ? "" : entry.name.slice(slash + 1).replace(/\/+$/, "");
|
|
269
|
+
if (relative === "") continue; // the top directory itself
|
|
270
|
+
if (!isContainedPath(relative)) {
|
|
271
|
+
throw refusal(
|
|
272
|
+
source,
|
|
273
|
+
`entry '${entry.name}' would escape the destination directory`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (entry.isDirectory) continue;
|
|
277
|
+
entries.push({
|
|
278
|
+
relative,
|
|
279
|
+
declaredSize: entry.uncompressedSize,
|
|
280
|
+
zip: entry,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return entries;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function refusal(source: GitHubTreeSource, reason: string): CliExitError {
|
|
287
|
+
return new CliExitError(
|
|
288
|
+
`cannot read ${source.repo} as a marketplace: ${reason}`,
|
|
289
|
+
ExitCode.Usage,
|
|
290
|
+
["A marketplace is a repository tree with a marketplace file at its root."],
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mib(bytes: number): string {
|
|
295
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
296
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Public surface of the marketplace module: the sources a CLI knows, the two
|
|
2
|
+
// grammars a user types, one way to get a tree on disk, one way to read it,
|
|
3
|
+
// one way to turn an entry into a push, and the two renderers. Commands
|
|
4
|
+
// lazy-import this barrel inside their actions so `--help` stays fast.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
OFFICIAL_MARKETPLACE,
|
|
8
|
+
OFFICIAL_MARKETPLACE_NAME,
|
|
9
|
+
type KnownMarketplace,
|
|
10
|
+
type MarketplaceListing,
|
|
11
|
+
type MarketplaceSource,
|
|
12
|
+
type UnreadableMarketplace,
|
|
13
|
+
addMarketplace,
|
|
14
|
+
describeSource,
|
|
15
|
+
findMarketplace,
|
|
16
|
+
listMarketplaces,
|
|
17
|
+
removeMarketplace,
|
|
18
|
+
} from "./config.js";
|
|
19
|
+
export {
|
|
20
|
+
type LocatedEntry,
|
|
21
|
+
assertVersion,
|
|
22
|
+
locateEntry,
|
|
23
|
+
prepareEntry,
|
|
24
|
+
} from "./install.js";
|
|
25
|
+
export {
|
|
26
|
+
type OpenMarketplace,
|
|
27
|
+
type OpenMarketplaceOptions,
|
|
28
|
+
openMarketplace,
|
|
29
|
+
withMarketplace,
|
|
30
|
+
} from "./materialize.js";
|
|
31
|
+
export {
|
|
32
|
+
type OfficialMarketplaceDir,
|
|
33
|
+
type ResolveOfficialOptions,
|
|
34
|
+
resolveOfficialMarketplace,
|
|
35
|
+
} from "./official.js";
|
|
36
|
+
export {
|
|
37
|
+
type ReadMarketplaceTree,
|
|
38
|
+
entryDirectory,
|
|
39
|
+
findEntry,
|
|
40
|
+
marketplaceRefusal,
|
|
41
|
+
readMarketplaceTree,
|
|
42
|
+
} from "./read.js";
|
|
43
|
+
export {
|
|
44
|
+
type InstallRef,
|
|
45
|
+
formatInstallRef,
|
|
46
|
+
parseAddSource,
|
|
47
|
+
parseInstallRef,
|
|
48
|
+
} from "./ref.js";
|
|
49
|
+
export {
|
|
50
|
+
type ShownEntry,
|
|
51
|
+
inspectEntries,
|
|
52
|
+
renderMarketplaceList,
|
|
53
|
+
renderMarketplaceShow,
|
|
54
|
+
} from "./render.js";
|