@stigmer/plugin-package 3.15.3-dev.20260916211208 → 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/client/archive.d.ts +27 -0
- package/client/archive.d.ts.map +1 -0
- package/client/archive.js +38 -0
- package/client/archive.js.map +1 -0
- package/client/ignore/defaults.d.ts +2 -0
- package/client/ignore/defaults.d.ts.map +1 -0
- package/client/ignore/defaults.js +140 -0
- package/client/ignore/defaults.js.map +1 -0
- package/client/ignore/match.d.ts +3 -0
- package/client/ignore/match.d.ts.map +1 -0
- package/client/ignore/match.js +166 -0
- package/client/ignore/match.js.map +1 -0
- package/client/ignore/matcher.d.ts +50 -0
- package/client/ignore/matcher.d.ts.map +1 -0
- package/client/ignore/matcher.js +116 -0
- package/client/ignore/matcher.js.map +1 -0
- package/client/ignore/pattern.d.ts +13 -0
- package/client/ignore/pattern.d.ts.map +1 -0
- package/client/ignore/pattern.js +118 -0
- package/client/ignore/pattern.js.map +1 -0
- package/client/refs.d.ts +76 -0
- package/client/refs.d.ts.map +1 -0
- package/client/refs.js +123 -0
- package/client/refs.js.map +1 -0
- package/client/select.d.ts +74 -0
- package/client/select.d.ts.map +1 -0
- package/client/select.js +121 -0
- package/client/select.js.map +1 -0
- package/client/vocabulary.d.ts +13 -0
- package/client/vocabulary.d.ts.map +1 -0
- package/client/vocabulary.js +17 -0
- package/client/vocabulary.js.map +1 -0
- package/client.d.ts +34 -0
- package/client.d.ts.map +1 -0
- package/client.js +34 -0
- package/client.js.map +1 -0
- package/files.d.ts +6 -0
- package/files.d.ts.map +1 -1
- package/files.js +6 -0
- package/files.js.map +1 -1
- package/index.d.ts +11 -6
- package/index.d.ts.map +1 -1
- package/index.js +9 -5
- package/index.js.map +1 -1
- package/marketplace/messages.d.ts +20 -0
- package/marketplace/messages.d.ts.map +1 -0
- package/marketplace/messages.js +50 -0
- package/marketplace/messages.js.map +1 -0
- package/marketplace/outcome.d.ts +72 -0
- package/marketplace/outcome.d.ts.map +1 -0
- package/marketplace/outcome.js +29 -0
- package/marketplace/outcome.js.map +1 -0
- package/marketplace/read-marketplace.d.ts +34 -0
- package/marketplace/read-marketplace.d.ts.map +1 -0
- package/marketplace/read-marketplace.js +359 -0
- package/marketplace/read-marketplace.js.map +1 -0
- package/messages.d.ts +25 -9
- package/messages.d.ts.map +1 -1
- package/messages.js +24 -8
- package/messages.js.map +1 -1
- package/outcome.d.ts +13 -6
- package/outcome.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/__test-utils__/read.ts +4 -4
- package/src/__tests__/client-ignore.test.ts +133 -0
- package/src/__tests__/client-refs.test.ts +101 -0
- package/src/__tests__/client-select-archive.test.ts +152 -0
- package/src/__tests__/fixtures/cursor-plugins/.cursor-plugin/marketplace.json +412 -0
- package/src/__tests__/fixtures/cursor-plugins/NOTICE +5 -0
- package/src/__tests__/marketplace.test.ts +373 -0
- package/src/client/archive.ts +44 -0
- package/src/client/ignore/defaults.ts +155 -0
- package/src/client/ignore/match.ts +158 -0
- package/src/client/ignore/matcher.ts +156 -0
- package/src/client/ignore/pattern.ts +131 -0
- package/src/client/refs.ts +170 -0
- package/src/client/select.ts +161 -0
- package/src/client/vocabulary.ts +19 -0
- package/src/client.ts +67 -0
- package/src/files.ts +6 -0
- package/src/index.ts +21 -5
- package/src/marketplace/messages.ts +62 -0
- package/src/marketplace/outcome.ts +95 -0
- package/src/marketplace/read-marketplace.ts +402 -0
- package/src/messages.ts +34 -21
- package/src/outcome.ts +14 -6
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marketplace reader's suite: one accepted fixture per dialect pinning
|
|
3
|
+
* the normalised shape, the verbatim `cursor/plugins` catalogue read over a
|
|
4
|
+
* partial tree (the six vendored plugins at their source paths, the other
|
|
5
|
+
* seventy-three reported as directories the tree does not hold), and the
|
|
6
|
+
* adversarial contract: one fixture per finding kind, asserting the complete
|
|
7
|
+
* sorted list of kinds and the sentence of the kind under test. The two
|
|
8
|
+
* adversarial tables are `Record`s over the kind unions, so a kind added to
|
|
9
|
+
* `marketplace/outcome.ts` without a case here does not compile.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { inMemoryPluginFiles, PLUGIN_DOCUMENT_LIMITS, type PluginFileEntry, type PluginFiles } from "../files.js";
|
|
17
|
+
import type { Marketplace, MarketplaceErrorKind, MarketplaceFinding, MarketplaceReadOutcome, MarketplaceWarningKind } from "../marketplace/outcome.js";
|
|
18
|
+
import { hasMarketplaceFile, readMarketplace } from "../marketplace/read-marketplace.js";
|
|
19
|
+
import { cursorPlugin, openPlugin, type PluginFixture } from "../testing.js";
|
|
20
|
+
import { directoryPluginFiles } from "../__test-utils__/directory-files.js";
|
|
21
|
+
import { findingOf, type Kinds } from "../__test-utils__/read.js";
|
|
22
|
+
|
|
23
|
+
const FIXTURES = fileURLToPath(new URL("./fixtures/cursor-plugins/", import.meta.url));
|
|
24
|
+
|
|
25
|
+
/** A marketplace tree from a map, with plugin fixtures mounted under their directories. */
|
|
26
|
+
function tree(marketplaceFile: string, content: unknown, plugins: Readonly<Record<string, PluginFixture>> = {}): PluginFiles {
|
|
27
|
+
const files = new Map<string, string | Uint8Array>();
|
|
28
|
+
files.set(marketplaceFile, typeof content === "string" ? content : JSON.stringify(content));
|
|
29
|
+
for (const [dir, fixture] of Object.entries(plugins)) {
|
|
30
|
+
for (const [path, bytes] of fixture) files.set(dir === "" ? path : `${dir}/${path}`, bytes);
|
|
31
|
+
}
|
|
32
|
+
return inMemoryPluginFiles(files);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function kindsOf(outcome: MarketplaceReadOutcome): Kinds {
|
|
36
|
+
const sorted = (findings: readonly MarketplaceFinding[]): string[] => findings.map((f) => f.kind).sort();
|
|
37
|
+
return outcome.ok
|
|
38
|
+
? { errors: [], warnings: sorted(outcome.warnings) }
|
|
39
|
+
: { errors: sorted(outcome.errors), warnings: sorted(outcome.warnings) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function accepted(outcome: MarketplaceReadOutcome): Marketplace {
|
|
43
|
+
if (!outcome.ok) {
|
|
44
|
+
expect.fail(`expected an accepted marketplace, got refusals:\n${outcome.errors.map((f) => ` ${f.kind}: ${f.message}`).join("\n")}`);
|
|
45
|
+
}
|
|
46
|
+
return outcome.marketplace;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function refused(outcome: MarketplaceReadOutcome): readonly MarketplaceFinding[] {
|
|
50
|
+
if (outcome.ok) {
|
|
51
|
+
expect.fail(`expected a refusal, got an accepted marketplace '${outcome.marketplace.name}'`);
|
|
52
|
+
}
|
|
53
|
+
return outcome.errors;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const errors = (...kinds: MarketplaceErrorKind[]): Kinds => ({ errors: [...kinds].sort(), warnings: [] });
|
|
57
|
+
const warnings = (...kinds: MarketplaceWarningKind[]): Kinds => ({ errors: [], warnings: [...kinds].sort() });
|
|
58
|
+
|
|
59
|
+
const STIGMER_FILE = "marketplace.json";
|
|
60
|
+
const alpha = openPlugin({ name: "alpha" });
|
|
61
|
+
const beta = openPlugin({ name: "beta" });
|
|
62
|
+
|
|
63
|
+
describe("hasMarketplaceFile", () => {
|
|
64
|
+
it("routes on any of the four locations and nothing else", () => {
|
|
65
|
+
expect(hasMarketplaceFile(["marketplace.json"])).toBe(true);
|
|
66
|
+
expect(hasMarketplaceFile([".claude-plugin/marketplace.json"])).toBe(true);
|
|
67
|
+
expect(hasMarketplaceFile([".cursor-plugin/marketplace.json"])).toBe(true);
|
|
68
|
+
expect(hasMarketplaceFile([".agents/plugins/marketplace.json"])).toBe(true);
|
|
69
|
+
expect(hasMarketplaceFile(["plugin.json", "thermos/marketplace.json"])).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("the Stigmer file (root marketplace.json)", () => {
|
|
74
|
+
const outcome = readMarketplace(
|
|
75
|
+
tree(
|
|
76
|
+
STIGMER_FILE,
|
|
77
|
+
{
|
|
78
|
+
name: "stigmer",
|
|
79
|
+
description: "The official catalogue.",
|
|
80
|
+
owner: { name: "Stigmer", url: "https://stigmer.ai" },
|
|
81
|
+
plugins: [
|
|
82
|
+
{ name: "alpha", source: "./alpha" },
|
|
83
|
+
{ name: "beta", source: "beta/", description: "Beta, described by the catalogue." },
|
|
84
|
+
],
|
|
85
|
+
defaults: ["beta", "alpha"],
|
|
86
|
+
},
|
|
87
|
+
{ alpha, beta },
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
it("is accepted with no warnings", () => {
|
|
92
|
+
expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("carries identity, dialect, path, entries in file order and defaults in install order", () => {
|
|
96
|
+
const marketplace = accepted(outcome);
|
|
97
|
+
expect(marketplace.name).toBe("stigmer");
|
|
98
|
+
expect(marketplace.description).toBe("The official catalogue.");
|
|
99
|
+
expect(marketplace.owner).toEqual({ name: "Stigmer", url: "https://stigmer.ai" });
|
|
100
|
+
expect(marketplace.dialect).toBe("stigmer");
|
|
101
|
+
expect(marketplace.path).toBe(STIGMER_FILE);
|
|
102
|
+
expect(marketplace.plugins).toEqual([
|
|
103
|
+
{ name: "alpha", dir: "alpha" },
|
|
104
|
+
{ name: "beta", dir: "beta", description: "Beta, described by the catalogue." },
|
|
105
|
+
]);
|
|
106
|
+
expect(marketplace.defaults).toEqual(["beta", "alpha"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("offers the tree root itself when the source is '.' (a single-plugin marketplace)", () => {
|
|
110
|
+
const single = readMarketplace(tree(STIGMER_FILE, { name: "solo", plugins: [{ name: "alpha", source: "." }] }, { "": alpha }));
|
|
111
|
+
expect(accepted(single).plugins).toEqual([{ name: "alpha", dir: "" }]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("takes precedence over a vendor file in the same tree", () => {
|
|
115
|
+
const files = tree(STIGMER_FILE, { name: "ours", plugins: [] });
|
|
116
|
+
const both = inMemoryPluginFiles(
|
|
117
|
+
new Map<string, string>([
|
|
118
|
+
...files.entries.map((e: PluginFileEntry) => [e.path, new TextDecoder().decode(files.read(e.path))] as const),
|
|
119
|
+
[".cursor-plugin/marketplace.json", JSON.stringify({ name: "theirs", plugins: [] })],
|
|
120
|
+
]),
|
|
121
|
+
);
|
|
122
|
+
expect(accepted(readMarketplace(both)).name).toBe("ours");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("the Claude Code file (.claude-plugin/marketplace.json)", () => {
|
|
127
|
+
const outcome = readMarketplace(
|
|
128
|
+
tree(
|
|
129
|
+
".claude-plugin/marketplace.json",
|
|
130
|
+
{
|
|
131
|
+
name: "acme-tools",
|
|
132
|
+
owner: { name: "Acme", email: "plugins@acme.example" },
|
|
133
|
+
metadata: { description: "Acme's plugins", version: "3", pluginRoot: "./plugins" },
|
|
134
|
+
plugins: [
|
|
135
|
+
{ name: "alpha", source: "./alpha", description: "Alpha." },
|
|
136
|
+
{ name: "remote", source: { source: "github", repo: "acme/remote" } },
|
|
137
|
+
{ name: "subdir", source: { source: "git-subdir", url: "acme/mono", path: "plugins/x" } },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
{ "plugins/alpha": alpha },
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it("reads metadata.description and metadata.pluginRoot, drops remote sources with one warning each", () => {
|
|
145
|
+
expect(kindsOf(outcome)).toEqual(warnings("entry-source-unsupported", "entry-source-unsupported"));
|
|
146
|
+
const marketplace = accepted(outcome);
|
|
147
|
+
expect(marketplace.dialect).toBe("claude");
|
|
148
|
+
expect(marketplace.description).toBe("Acme's plugins");
|
|
149
|
+
expect(marketplace.owner).toEqual({ name: "Acme", email: "plugins@acme.example" });
|
|
150
|
+
expect(marketplace.plugins).toEqual([{ name: "alpha", dir: "plugins/alpha", description: "Alpha." }]);
|
|
151
|
+
expect(marketplace.defaults).toEqual([]);
|
|
152
|
+
const messages = outcome.warnings.map((w) => w.message);
|
|
153
|
+
expect(messages).toContain(
|
|
154
|
+
"plugin 'remote' in '.claude-plugin/marketplace.json' has source 'github', a form Stigmer does not fetch (only a directory inside the marketplace); not offered",
|
|
155
|
+
);
|
|
156
|
+
expect(messages).toContain(
|
|
157
|
+
"plugin 'subdir' in '.claude-plugin/marketplace.json' has source 'git-subdir', a form Stigmer does not fetch (only a directory inside the marketplace); not offered",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("the Codex file (.agents/plugins/marketplace.json)", () => {
|
|
163
|
+
const outcome = readMarketplace(
|
|
164
|
+
tree(
|
|
165
|
+
".agents/plugins/marketplace.json",
|
|
166
|
+
{
|
|
167
|
+
name: "local",
|
|
168
|
+
interface: { displayName: "Local Plugins" },
|
|
169
|
+
plugins: [
|
|
170
|
+
{ name: "alpha", source: { source: "local", path: "./plugins/alpha" }, policy: { installation: "INSTALLED_BY_DEFAULT", authentication: "ON_INSTALL" }, category: "Productivity" },
|
|
171
|
+
{ name: "beta", source: { source: "local", path: "./plugins/beta" }, policy: { installation: "AVAILABLE", authentication: "ON_USE" } },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
{ "plugins/alpha": alpha, "plugins/beta": beta },
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
it("reads local sources and maps INSTALLED_BY_DEFAULT onto defaults; the display name is not a description", () => {
|
|
179
|
+
expect(kindsOf(outcome)).toEqual({ errors: [], warnings: [] });
|
|
180
|
+
const marketplace = accepted(outcome);
|
|
181
|
+
expect(marketplace.dialect).toBe("codex");
|
|
182
|
+
expect(marketplace.description).toBeUndefined();
|
|
183
|
+
expect(marketplace.plugins.map((p) => p.dir)).toEqual(["plugins/alpha", "plugins/beta"]);
|
|
184
|
+
expect(marketplace.defaults).toEqual(["alpha"]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("the Cursor file, verbatim from cursor/plugins at c1c0a32, over a partial tree", () => {
|
|
189
|
+
// The six vendored plugin directories mounted where the catalogue names
|
|
190
|
+
// them; the marketplace file byte-for-byte. Cursor writes sources without
|
|
191
|
+
// a `./` prefix and nests third-party plugins under `third_party/`.
|
|
192
|
+
const mounts: Readonly<Record<string, string>> = {
|
|
193
|
+
thermos: "thermos",
|
|
194
|
+
advisor: "advisor",
|
|
195
|
+
"third_party/github": "github",
|
|
196
|
+
"third_party/xero": "xero",
|
|
197
|
+
"third_party/playwright": "playwright",
|
|
198
|
+
"third_party/salesforce": "salesforce",
|
|
199
|
+
};
|
|
200
|
+
const catalogue = directoryPluginFiles(`${FIXTURES}.cursor-plugin`);
|
|
201
|
+
const files = new Map<string, Uint8Array>();
|
|
202
|
+
files.set(".cursor-plugin/marketplace.json", catalogue.read("marketplace.json"));
|
|
203
|
+
for (const [dir, fixture] of Object.entries(mounts)) {
|
|
204
|
+
const plugin = directoryPluginFiles(`${FIXTURES}${fixture}`);
|
|
205
|
+
for (const entry of plugin.entries) files.set(`${dir}/${entry.path}`, plugin.read(entry.path));
|
|
206
|
+
}
|
|
207
|
+
const outcome = readMarketplace(inMemoryPluginFiles(files));
|
|
208
|
+
|
|
209
|
+
it("offers the six the tree holds and reports the seventy-three it does not, one warning each", () => {
|
|
210
|
+
const marketplace = accepted(outcome);
|
|
211
|
+
expect(marketplace.name).toBe("cursor-plugins");
|
|
212
|
+
expect(marketplace.dialect).toBe("cursor");
|
|
213
|
+
expect(marketplace.owner).toEqual({ name: "Cursor", email: "plugins@cursor.com" });
|
|
214
|
+
expect(marketplace.description).toBe("Official Cursor plugin marketplace: developer tools, framework rules, MCP integrations, and agent skills");
|
|
215
|
+
expect(marketplace.plugins.map((p) => p.name).sort()).toEqual(["advisor", "github", "playwright", "salesforce", "thermos", "xero"]);
|
|
216
|
+
expect(marketplace.plugins.find((p) => p.name === "github")).toEqual({
|
|
217
|
+
name: "github",
|
|
218
|
+
dir: "third_party/github",
|
|
219
|
+
description: "Manage repos, issues, pull requests, and Actions.",
|
|
220
|
+
});
|
|
221
|
+
expect(marketplace.defaults).toEqual([]);
|
|
222
|
+
expect(outcome.warnings).toHaveLength(73);
|
|
223
|
+
expect(new Set(outcome.warnings.map((w) => w.kind))).toEqual(new Set(["entry-directory-missing"]));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("offers every entry when the whole catalogue is present (the sources all resolve inside the root)", () => {
|
|
227
|
+
// Every source directory given one manifest, so the file's own shape is
|
|
228
|
+
// proven independently of which plugins the fixtures vendor.
|
|
229
|
+
const complete = new Map<string, Uint8Array | string>([[".cursor-plugin/marketplace.json", catalogue.read("marketplace.json")]]);
|
|
230
|
+
const parsed = JSON.parse(new TextDecoder().decode(catalogue.read("marketplace.json"))) as { plugins: { name: string; source: string }[] };
|
|
231
|
+
for (const entry of parsed.plugins) {
|
|
232
|
+
for (const [path, bytes] of cursorPlugin({ name: entry.name })) complete.set(`${entry.source}/${path}`, bytes);
|
|
233
|
+
}
|
|
234
|
+
const whole = readMarketplace(inMemoryPluginFiles(complete));
|
|
235
|
+
expect(kindsOf(whole)).toEqual({ errors: [], warnings: [] });
|
|
236
|
+
expect(accepted(whole).plugins).toHaveLength(79);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
interface Case {
|
|
241
|
+
readonly files: PluginFiles;
|
|
242
|
+
/** Every kind the read produces, sorted. */
|
|
243
|
+
readonly kinds: Kinds;
|
|
244
|
+
/** The sentence of the kind under test (a string, or a regex source for a parser's own text). */
|
|
245
|
+
readonly message: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const ERROR_CASES: Record<MarketplaceErrorKind, Case> = {
|
|
249
|
+
"marketplace-not-found": {
|
|
250
|
+
files: inMemoryPluginFiles(new Map([["README.md", "no marketplace here"]])),
|
|
251
|
+
kinds: errors("marketplace-not-found"),
|
|
252
|
+
message:
|
|
253
|
+
"no marketplace file found: expected one of 'marketplace.json', '.claude-plugin/marketplace.json', '.cursor-plugin/marketplace.json', '.agents/plugins/marketplace.json'",
|
|
254
|
+
},
|
|
255
|
+
"marketplace-too-large": {
|
|
256
|
+
files: inMemoryPluginFiles(new Map([[STIGMER_FILE, new Uint8Array(PLUGIN_DOCUMENT_LIMITS.marketplace + 1)]])),
|
|
257
|
+
kinds: errors("marketplace-too-large"),
|
|
258
|
+
message: `'marketplace.json' is ${PLUGIN_DOCUMENT_LIMITS.marketplace + 1} bytes, above the ${PLUGIN_DOCUMENT_LIMITS.marketplace} byte cap for a marketplace file`,
|
|
259
|
+
},
|
|
260
|
+
"marketplace-unreadable": {
|
|
261
|
+
files: tree(STIGMER_FILE, "{ oops"),
|
|
262
|
+
kinds: errors("marketplace-unreadable"),
|
|
263
|
+
message: /^'marketplace.json' is not a readable marketplace file: /.source,
|
|
264
|
+
},
|
|
265
|
+
"marketplace-field-type": {
|
|
266
|
+
files: tree(STIGMER_FILE, { name: 7, plugins: [] }),
|
|
267
|
+
kinds: errors("marketplace-field-type"),
|
|
268
|
+
message: "'marketplace.json' field 'name' must be a string",
|
|
269
|
+
},
|
|
270
|
+
"marketplace-name-missing": {
|
|
271
|
+
files: tree(STIGMER_FILE, { plugins: [] }),
|
|
272
|
+
kinds: errors("marketplace-name-missing"),
|
|
273
|
+
message: "'marketplace.json' has no 'name'; a marketplace needs one so its plugins can be addressed as '<marketplace>/<plugin>'",
|
|
274
|
+
},
|
|
275
|
+
"marketplace-name-invalid": {
|
|
276
|
+
files: tree(STIGMER_FILE, { name: "Acme Tools", plugins: [] }),
|
|
277
|
+
kinds: errors("marketplace-name-invalid"),
|
|
278
|
+
message: "'marketplace.json' name 'Acme Tools' is invalid: 1 to 64 characters of a-z, 0-9, '-' and '.', starting and ending alphanumeric, no '--' or '..'",
|
|
279
|
+
},
|
|
280
|
+
"marketplace-plugins-missing": {
|
|
281
|
+
files: tree(STIGMER_FILE, { name: "acme" }),
|
|
282
|
+
kinds: errors("marketplace-plugins-missing"),
|
|
283
|
+
message: "'marketplace.json' has no 'plugins' list",
|
|
284
|
+
},
|
|
285
|
+
"entry-shape": {
|
|
286
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: ["alpha"] }),
|
|
287
|
+
kinds: errors("entry-shape"),
|
|
288
|
+
message: "'marketplace.json' plugins[0] must be an object with 'name' and 'source'",
|
|
289
|
+
},
|
|
290
|
+
"entry-name-missing": {
|
|
291
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ source: "./alpha" }] }),
|
|
292
|
+
kinds: errors("entry-name-missing"),
|
|
293
|
+
message: "'marketplace.json' plugins[0] has no 'name'",
|
|
294
|
+
},
|
|
295
|
+
"entry-name-invalid": {
|
|
296
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "Alpha!", source: "./alpha" }] }),
|
|
297
|
+
kinds: errors("entry-name-invalid"),
|
|
298
|
+
message: "'marketplace.json' plugin 'Alpha!' has an invalid name: 1 to 64 characters of a-z, 0-9, '-' and '.', starting and ending alphanumeric, no '--' or '..'",
|
|
299
|
+
},
|
|
300
|
+
"entry-name-duplicate": {
|
|
301
|
+
files: tree(
|
|
302
|
+
STIGMER_FILE,
|
|
303
|
+
{ name: "acme", plugins: [{ name: "alpha", source: "./alpha" }, { name: "alpha", source: "./beta" }] },
|
|
304
|
+
{ alpha, beta },
|
|
305
|
+
),
|
|
306
|
+
kinds: errors("entry-name-duplicate"),
|
|
307
|
+
message: "'marketplace.json' lists plugin 'alpha' more than once; 'install alpha' would be ambiguous",
|
|
308
|
+
},
|
|
309
|
+
"entry-source-missing": {
|
|
310
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha" }] }),
|
|
311
|
+
kinds: errors("entry-source-missing"),
|
|
312
|
+
message: "'marketplace.json' plugin 'alpha' has no 'source'",
|
|
313
|
+
},
|
|
314
|
+
"entry-source-escapes-root": {
|
|
315
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha", source: "../alpha" }] }),
|
|
316
|
+
kinds: errors("entry-source-escapes-root"),
|
|
317
|
+
message: "'marketplace.json' plugin 'alpha' has source '../alpha' outside the marketplace root; a source is a directory inside the marketplace",
|
|
318
|
+
},
|
|
319
|
+
"default-unknown": {
|
|
320
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha", source: "./alpha" }], defaults: ["gamma"] }, { alpha }),
|
|
321
|
+
kinds: errors("default-unknown"),
|
|
322
|
+
message: "'marketplace.json' names 'gamma' as a default but lists no plugin of that name",
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const WARNING_CASES: Record<MarketplaceWarningKind, Case> = {
|
|
327
|
+
"entry-source-unsupported": {
|
|
328
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha", source: "https://example.com/alpha.zip" }] }),
|
|
329
|
+
kinds: warnings("entry-source-unsupported"),
|
|
330
|
+
message:
|
|
331
|
+
"plugin 'alpha' in 'marketplace.json' has source 'https://example.com/alpha.zip', a form Stigmer does not fetch (only a directory inside the marketplace); not offered",
|
|
332
|
+
},
|
|
333
|
+
"entry-directory-missing": {
|
|
334
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha", source: "./alpha" }] }),
|
|
335
|
+
kinds: warnings("entry-directory-missing"),
|
|
336
|
+
message: "plugin 'alpha' in 'marketplace.json' names directory 'alpha', which the marketplace does not contain; not offered",
|
|
337
|
+
},
|
|
338
|
+
"entry-not-a-plugin": {
|
|
339
|
+
files: tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha", source: "./alpha" }] }, { alpha: new Map([["README.md", "not a plugin"]]) }),
|
|
340
|
+
kinds: warnings("entry-not-a-plugin"),
|
|
341
|
+
message: "plugin 'alpha' in 'marketplace.json' names directory 'alpha', which holds no plugin manifest; not offered",
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
describe("adversarial marketplaces: one fixture per error kind", () => {
|
|
346
|
+
for (const [kind, testCase] of Object.entries(ERROR_CASES) as [MarketplaceErrorKind, Case][]) {
|
|
347
|
+
it(`${kind}: refused with its sentence and nothing else`, () => {
|
|
348
|
+
const outcome = readMarketplace(testCase.files);
|
|
349
|
+
expect(kindsOf(outcome)).toEqual(testCase.kinds);
|
|
350
|
+
const finding = findingOf(refused(outcome), kind);
|
|
351
|
+
if (testCase.message.startsWith("^")) expect(finding.message).toMatch(new RegExp(testCase.message));
|
|
352
|
+
else expect(finding.message).toBe(testCase.message);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("adversarial marketplaces: one fixture per warning kind", () => {
|
|
358
|
+
for (const [kind, testCase] of Object.entries(WARNING_CASES) as [MarketplaceWarningKind, Case][]) {
|
|
359
|
+
it(`${kind}: the entry is dropped with its sentence and the marketplace is still offered`, () => {
|
|
360
|
+
const outcome = readMarketplace(testCase.files);
|
|
361
|
+
expect(kindsOf(outcome)).toEqual(testCase.kinds);
|
|
362
|
+
const marketplace = accepted(outcome);
|
|
363
|
+
expect(marketplace.plugins).toEqual([]);
|
|
364
|
+
expect(findingOf(outcome.warnings, kind).message).toBe(testCase.message);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
it("a default whose entry was dropped disappears with the entry's warning, never as a second finding", () => {
|
|
369
|
+
const outcome = readMarketplace(tree(STIGMER_FILE, { name: "acme", plugins: [{ name: "alpha", source: "./alpha" }], defaults: ["alpha"] }));
|
|
370
|
+
expect(kindsOf(outcome)).toEqual(warnings("entry-directory-missing"));
|
|
371
|
+
expect(accepted(outcome).defaults).toEqual([]);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The archive a plugin push sends, and its digest.
|
|
3
|
+
*
|
|
4
|
+
* The server's identity for a plugin version is the SHA-256 of the bytes it
|
|
5
|
+
* receives, so a client that can compute those bytes knows "already
|
|
6
|
+
* installed" and "upgrade available" without pushing. That is only true if
|
|
7
|
+
* every client builds the archive the same way: the selected files in
|
|
8
|
+
* selection order, one DOS-epoch mtime on every entry, one compression
|
|
9
|
+
* level. These are the CLI's original options (the skill packager's
|
|
10
|
+
* discipline, kept byte for byte); changing any of them would make every
|
|
11
|
+
* installed plugin look like an upgrade.
|
|
12
|
+
*
|
|
13
|
+
* The digest is WebCrypto's, which Node 22 and every browser provide, so
|
|
14
|
+
* there is one hash implementation and it is asynchronous everywhere.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { zipSync } from "fflate";
|
|
18
|
+
|
|
19
|
+
import type { PluginFiles } from "../files.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The one mtime every entry carries: the DOS epoch, at local midnight so
|
|
23
|
+
* the DOS time field reads 00:00 in every zone. Construct it locally rather
|
|
24
|
+
* than from UTC: fflate converts through the Date's local getters.
|
|
25
|
+
*/
|
|
26
|
+
export const DETERMINISTIC_ZIP_MTIME = new Date(1980, 0, 1);
|
|
27
|
+
|
|
28
|
+
/** The deflate level the skill packager chose; part of the identity. */
|
|
29
|
+
const ARCHIVE_LEVEL = 6;
|
|
30
|
+
|
|
31
|
+
/** Zip the selected files in their listed order. */
|
|
32
|
+
export function archivePlugin(files: PluginFiles): Uint8Array {
|
|
33
|
+
const tree: Record<string, Uint8Array> = {};
|
|
34
|
+
for (const entry of files.entries) {
|
|
35
|
+
tree[entry.path] = files.read(entry.path);
|
|
36
|
+
}
|
|
37
|
+
return zipSync(tree, { level: ARCHIVE_LEVEL, mtime: DETERMINISTIC_ZIP_MTIME });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Lowercase hex SHA-256 of `bytes`: the server's `status.digest` for these bytes. */
|
|
41
|
+
export async function digestArchive(bytes: Uint8Array): Promise<string> {
|
|
42
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
43
|
+
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
44
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Built-in security-first ignore patterns, applied (lowest priority) unless
|
|
2
|
+
// disabled. A verbatim port of the Go CLI's DefaultPatterns so `push` excludes
|
|
3
|
+
// the same secrets, VCS metadata, and build artifacts on both CLIs.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PATTERNS: readonly string[] = [
|
|
6
|
+
// Version control
|
|
7
|
+
".git/",
|
|
8
|
+
".svn/",
|
|
9
|
+
".hg/",
|
|
10
|
+
".bzr/",
|
|
11
|
+
|
|
12
|
+
// Environment files (often contain secrets)
|
|
13
|
+
".env",
|
|
14
|
+
".env.*",
|
|
15
|
+
"!.env.example",
|
|
16
|
+
"!.env.template",
|
|
17
|
+
"!.env.sample",
|
|
18
|
+
|
|
19
|
+
// Private keys and certificates
|
|
20
|
+
"*.pem",
|
|
21
|
+
"*.key",
|
|
22
|
+
"*.p12",
|
|
23
|
+
"*.pfx",
|
|
24
|
+
"*.keystore",
|
|
25
|
+
"*.jks",
|
|
26
|
+
"id_rsa",
|
|
27
|
+
"id_rsa.*",
|
|
28
|
+
"id_ed25519",
|
|
29
|
+
"id_ed25519.*",
|
|
30
|
+
"id_dsa",
|
|
31
|
+
"id_dsa.*",
|
|
32
|
+
|
|
33
|
+
// Cloud credentials
|
|
34
|
+
"credentials.json",
|
|
35
|
+
"service-account*.json",
|
|
36
|
+
"gcp-credentials*.json",
|
|
37
|
+
"aws-credentials",
|
|
38
|
+
".aws/credentials",
|
|
39
|
+
".aws/config",
|
|
40
|
+
|
|
41
|
+
// Generic secrets
|
|
42
|
+
".secrets",
|
|
43
|
+
".secrets/",
|
|
44
|
+
"*.secret",
|
|
45
|
+
".credentials/",
|
|
46
|
+
"secrets.yaml",
|
|
47
|
+
"secrets.yml",
|
|
48
|
+
"secrets.json",
|
|
49
|
+
|
|
50
|
+
// Package manager auth
|
|
51
|
+
".npmrc",
|
|
52
|
+
".pypirc",
|
|
53
|
+
".gem/credentials",
|
|
54
|
+
".docker/config.json",
|
|
55
|
+
|
|
56
|
+
// IDE and editor
|
|
57
|
+
".idea/",
|
|
58
|
+
".vscode/",
|
|
59
|
+
"*.swp",
|
|
60
|
+
"*.swo",
|
|
61
|
+
"*.swn",
|
|
62
|
+
"*~",
|
|
63
|
+
".project",
|
|
64
|
+
".classpath",
|
|
65
|
+
".settings/",
|
|
66
|
+
"*.sublime-workspace",
|
|
67
|
+
"*.sublime-project",
|
|
68
|
+
".vs/",
|
|
69
|
+
|
|
70
|
+
// Operating system
|
|
71
|
+
".DS_Store",
|
|
72
|
+
".DS_Store?",
|
|
73
|
+
"._*",
|
|
74
|
+
".Spotlight-V100",
|
|
75
|
+
".Trashes",
|
|
76
|
+
"Thumbs.db",
|
|
77
|
+
"Thumbs.db:encryptable",
|
|
78
|
+
"desktop.ini",
|
|
79
|
+
"ehthumbs.db",
|
|
80
|
+
"ehthumbs_vista.db",
|
|
81
|
+
|
|
82
|
+
// Node.js
|
|
83
|
+
"node_modules/",
|
|
84
|
+
"npm-debug.log*",
|
|
85
|
+
"yarn-debug.log*",
|
|
86
|
+
"yarn-error.log*",
|
|
87
|
+
".npm/",
|
|
88
|
+
".yarn/cache/",
|
|
89
|
+
".yarn/unplugged/",
|
|
90
|
+
".pnp.*",
|
|
91
|
+
|
|
92
|
+
// Python
|
|
93
|
+
"__pycache__/",
|
|
94
|
+
"*.py[cod]",
|
|
95
|
+
"*$py.class",
|
|
96
|
+
".Python",
|
|
97
|
+
"*.so",
|
|
98
|
+
".venv/",
|
|
99
|
+
"venv/",
|
|
100
|
+
"env/",
|
|
101
|
+
"ENV/",
|
|
102
|
+
".pytest_cache/",
|
|
103
|
+
".mypy_cache/",
|
|
104
|
+
".ruff_cache/",
|
|
105
|
+
".tox/",
|
|
106
|
+
".nox/",
|
|
107
|
+
"*.egg-info/",
|
|
108
|
+
".eggs/",
|
|
109
|
+
"dist/",
|
|
110
|
+
"build/",
|
|
111
|
+
"develop-eggs/",
|
|
112
|
+
".installed.cfg",
|
|
113
|
+
"*.manifest",
|
|
114
|
+
"*.spec",
|
|
115
|
+
"pip-log.txt",
|
|
116
|
+
"pip-delete-this-directory.txt",
|
|
117
|
+
|
|
118
|
+
// Java/JVM
|
|
119
|
+
"target/",
|
|
120
|
+
"*.class",
|
|
121
|
+
"*.jar",
|
|
122
|
+
"*.war",
|
|
123
|
+
"*.ear",
|
|
124
|
+
".gradle/",
|
|
125
|
+
"gradle-app.setting",
|
|
126
|
+
".gradletasknamecache",
|
|
127
|
+
|
|
128
|
+
// Go
|
|
129
|
+
"vendor/",
|
|
130
|
+
|
|
131
|
+
// Rust
|
|
132
|
+
"target/",
|
|
133
|
+
"Cargo.lock",
|
|
134
|
+
|
|
135
|
+
// Coverage and test reports
|
|
136
|
+
"coverage/",
|
|
137
|
+
".coverage",
|
|
138
|
+
"htmlcov/",
|
|
139
|
+
".nyc_output/",
|
|
140
|
+
"coverage.xml",
|
|
141
|
+
"*.cover",
|
|
142
|
+
".hypothesis/",
|
|
143
|
+
|
|
144
|
+
// Logs
|
|
145
|
+
"*.log",
|
|
146
|
+
"logs/",
|
|
147
|
+
|
|
148
|
+
// Temporary files
|
|
149
|
+
"tmp/",
|
|
150
|
+
"temp/",
|
|
151
|
+
"*.tmp",
|
|
152
|
+
"*.temp",
|
|
153
|
+
"*.bak",
|
|
154
|
+
"*.backup",
|
|
155
|
+
];
|