@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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { expect } from "vitest";
|
|
11
11
|
|
|
12
12
|
import { inMemoryPluginFiles } from "../files.js";
|
|
13
|
-
import type { PluginFinding, PluginReadOutcome } from "../outcome.js";
|
|
13
|
+
import type { Finding, PluginFinding, PluginReadOutcome } from "../outcome.js";
|
|
14
14
|
import { readPluginPackage } from "../read-plugin-package.js";
|
|
15
15
|
import type { PluginFixture } from "../testing.js";
|
|
16
16
|
import type { PluginPackage } from "../types.js";
|
|
@@ -47,9 +47,9 @@ export function refused(outcome: PluginReadOutcome): readonly PluginFinding[] {
|
|
|
47
47
|
return outcome.errors;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** The one finding of `kind`, failing when it is absent or repeated. */
|
|
51
|
-
export function findingOf(findings: readonly
|
|
50
|
+
/** The one finding of `kind`, failing when it is absent or repeated; over either vocabulary. */
|
|
51
|
+
export function findingOf<K extends string>(findings: readonly Finding<K>[], kind: string): Finding<K> {
|
|
52
52
|
const matches = findings.filter((f) => f.kind === kind);
|
|
53
53
|
expect(matches, `exactly one '${kind}' finding`).toHaveLength(1);
|
|
54
|
-
return matches[0] as
|
|
54
|
+
return matches[0] as Finding<K>;
|
|
55
55
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ignore engine's table tests: the filepath.Match port, the Pattern
|
|
3
|
+
* matcher (scoping, dir-only, globs, negation) and the composed matcher's
|
|
4
|
+
* source precedence with last-match-wins. Moved here with the engine from
|
|
5
|
+
* the CLI; the matcher is now built from the TEXT of the ignore files, so
|
|
6
|
+
* the precedence cases hand it text where they once wrote a temp directory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { matchName } from "../client/ignore/match.js";
|
|
12
|
+
import { buildMatcher } from "../client/ignore/matcher.js";
|
|
13
|
+
import { MatchResult, parsePattern } from "../client/ignore/pattern.js";
|
|
14
|
+
|
|
15
|
+
describe("matchName (filepath.Match port)", () => {
|
|
16
|
+
const cases: [string, string, boolean][] = [
|
|
17
|
+
["*.go", "main.go", true],
|
|
18
|
+
["*.go", "main.rs", false],
|
|
19
|
+
["*", "anything", true],
|
|
20
|
+
["a?c", "abc", true],
|
|
21
|
+
["a?c", "ac", false],
|
|
22
|
+
["[abc]", "b", true],
|
|
23
|
+
["[abc]", "d", false],
|
|
24
|
+
["[a-c]", "b", true],
|
|
25
|
+
["[a-c]", "z", false],
|
|
26
|
+
["[^a]", "b", true],
|
|
27
|
+
["[^a]", "a", false],
|
|
28
|
+
["foo*", "foobar", true],
|
|
29
|
+
["*bar", "foobar", true],
|
|
30
|
+
["h[a-z]llo", "hello", true],
|
|
31
|
+
["node_modules", "node_modules", true],
|
|
32
|
+
["\\*.go", "*.go", true],
|
|
33
|
+
// Malformed patterns: Go returns ErrBadPattern → callers treat as no match.
|
|
34
|
+
["[", "a", false],
|
|
35
|
+
["[a-", "a", false],
|
|
36
|
+
];
|
|
37
|
+
for (const [pattern, name, expected] of cases) {
|
|
38
|
+
it(`${pattern} ~ ${name} => ${expected}`, () => {
|
|
39
|
+
expect(matchName(pattern, name)).toBe(expected);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("parsePattern / Pattern.match", () => {
|
|
45
|
+
it("directory-only patterns only match directories (or ancestors)", () => {
|
|
46
|
+
const p = parsePattern("node_modules/", []);
|
|
47
|
+
expect(p.match(["node_modules"], true)).toBe(MatchResult.Exclude);
|
|
48
|
+
expect(p.match(["node_modules"], false)).toBe(MatchResult.NoMatch);
|
|
49
|
+
expect(p.match(["src", "node_modules"], true)).toBe(MatchResult.Exclude);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("simple name patterns match at any depth", () => {
|
|
53
|
+
const p = parsePattern("*.log", []);
|
|
54
|
+
expect(p.match(["app.log"], false)).toBe(MatchResult.Exclude);
|
|
55
|
+
expect(p.match(["sub", "app.log"], false)).toBe(MatchResult.Exclude);
|
|
56
|
+
expect(p.match(["app.txt"], false)).toBe(MatchResult.NoMatch);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leading slash anchors to the domain root", () => {
|
|
60
|
+
const p = parsePattern("/build", []);
|
|
61
|
+
expect(p.match(["build"], true)).toBe(MatchResult.Exclude);
|
|
62
|
+
expect(p.match(["src", "build"], true)).toBe(MatchResult.NoMatch);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("** matches zero-to-many directories", () => {
|
|
66
|
+
const p = parsePattern("**/foo", []);
|
|
67
|
+
expect(p.match(["foo"], false)).toBe(MatchResult.Exclude);
|
|
68
|
+
expect(p.match(["a", "b", "foo"], false)).toBe(MatchResult.Exclude);
|
|
69
|
+
expect(p.match(["a", "bar"], false)).toBe(MatchResult.NoMatch);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("negation patterns produce Include", () => {
|
|
73
|
+
const p = parsePattern("!keep.txt", []);
|
|
74
|
+
expect(p.inclusion).toBe(true);
|
|
75
|
+
expect(p.match(["keep.txt"], false)).toBe(MatchResult.Include);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("domain scopes matching below a subtree", () => {
|
|
79
|
+
const p = parsePattern("*.txt", ["sub"]);
|
|
80
|
+
expect(p.match(["sub", "a.txt"], false)).toBe(MatchResult.Exclude);
|
|
81
|
+
expect(p.match(["a.txt"], false)).toBe(MatchResult.NoMatch);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("buildMatcher (source precedence, last-match-wins)", () => {
|
|
86
|
+
it("applies security defaults including the .env.example negation", () => {
|
|
87
|
+
const m = buildMatcher({ includeDefaults: true });
|
|
88
|
+
expect(m.match(".env", false)).toBe(true);
|
|
89
|
+
expect(m.match(".env.example", false)).toBe(false);
|
|
90
|
+
expect(m.match("id_rsa", false)).toBe(true);
|
|
91
|
+
expect(m.match("node_modules", true)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("honors .gitignore with negation (last match wins)", () => {
|
|
95
|
+
const m = buildMatcher({ includeDefaults: true, gitignore: "*.draft\n!keep.draft\n" });
|
|
96
|
+
expect(m.match("notes.draft", false)).toBe(true);
|
|
97
|
+
expect(m.match("keep.draft", false)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("an absent .gitignore contributes nothing", () => {
|
|
101
|
+
const m = buildMatcher({ includeDefaults: true });
|
|
102
|
+
expect(m.match("notes.draft", false)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it(".stigmerignore outranks .gitignore", () => {
|
|
106
|
+
const m = buildMatcher({ includeDefaults: true, gitignore: "!shared.draft\n", stigmerignore: "*.draft\n" });
|
|
107
|
+
expect(m.match("shared.draft", false)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("caller extra ignore/include override file sources", () => {
|
|
111
|
+
const m = buildMatcher({
|
|
112
|
+
includeDefaults: true,
|
|
113
|
+
gitignore: "*.draft\n",
|
|
114
|
+
extraIgnore: ["*.tmp"],
|
|
115
|
+
extraInclude: ["important.draft"],
|
|
116
|
+
});
|
|
117
|
+
expect(m.match("scratch.tmp", false)).toBe(true);
|
|
118
|
+
expect(m.match("important.draft", false)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reports the matching source and reason", () => {
|
|
122
|
+
const m = buildMatcher({ includeDefaults: true });
|
|
123
|
+
const reason = m.matchWithReason(".env", false);
|
|
124
|
+
expect(reason.ignored).toBe(true);
|
|
125
|
+
expect(reason.source).toBe("defaults");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("CRLF ignore files parse line by line", () => {
|
|
129
|
+
const m = buildMatcher({ includeDefaults: false, gitignore: "*.a\r\n*.b\r\n" });
|
|
130
|
+
expect(m.match("x.a", false)).toBe(true);
|
|
131
|
+
expect(m.match("x.b", false)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two grammars every client parses: an install ref and a GitHub
|
|
3
|
+
* marketplace source. Pins the splits (`/` then `@`), the path refusal that
|
|
4
|
+
* gets its own kind so a client can name its folder command, the URL forms
|
|
5
|
+
* that normalise to `owner/repo[@ref]`, and that a refusal is returned with
|
|
6
|
+
* a sentence rather than thrown.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
describeGitHubSource,
|
|
13
|
+
formatInstallRef,
|
|
14
|
+
isOwnerRepo,
|
|
15
|
+
looksLikePath,
|
|
16
|
+
parseGitHubSource,
|
|
17
|
+
parseInstallRef,
|
|
18
|
+
} from "../client/refs.js";
|
|
19
|
+
|
|
20
|
+
describe("parseInstallRef", () => {
|
|
21
|
+
it("a bare name", () => {
|
|
22
|
+
expect(parseInstallRef("thermos")).toEqual({ ok: true, ref: { name: "thermos" } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("marketplace/name@version", () => {
|
|
26
|
+
expect(parseInstallRef("cursor-plugins/thermos@1.0.0")).toEqual({
|
|
27
|
+
ok: true,
|
|
28
|
+
ref: { marketplace: "cursor-plugins", name: "thermos", version: "1.0.0" },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("refuses a path with its own kind", () => {
|
|
33
|
+
for (const text of ["./thermos", "../x", ".", "..", "/tmp/x", "~/x", "C:\\x", "a\\b"]) {
|
|
34
|
+
const outcome = parseInstallRef(text);
|
|
35
|
+
expect(outcome.ok).toBe(false);
|
|
36
|
+
if (!outcome.ok) expect(outcome.kind).toBe("path");
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("refuses a bad marketplace name, version and plugin name with the shape kind", () => {
|
|
41
|
+
const bad = ["Upper/thermos", "thermos@", "thermos@1.0!", "Thermos", "a/b/c"];
|
|
42
|
+
for (const text of bad) {
|
|
43
|
+
const outcome = parseInstallRef(text);
|
|
44
|
+
expect(outcome.ok).toBe(false);
|
|
45
|
+
if (!outcome.ok) expect(outcome.kind).toBe("shape");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("formatInstallRef is the inverse", () => {
|
|
50
|
+
const outcome = parseInstallRef("m/n@v");
|
|
51
|
+
if (!outcome.ok) throw new Error("expected ok");
|
|
52
|
+
expect(formatInstallRef(outcome.ref)).toBe("m/n@v");
|
|
53
|
+
expect(formatInstallRef({ name: "n" })).toBe("n");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("parseGitHubSource", () => {
|
|
58
|
+
it("owner/repo and owner/repo@ref", () => {
|
|
59
|
+
expect(parseGitHubSource("cursor/plugins")).toEqual({ ok: true, source: { type: "github", repo: "cursor/plugins" } });
|
|
60
|
+
expect(parseGitHubSource("cursor/plugins@main")).toEqual({
|
|
61
|
+
ok: true,
|
|
62
|
+
source: { type: "github", repo: "cursor/plugins", ref: "main" },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("the browser's URL, with and without /tree/<ref> and .git", () => {
|
|
67
|
+
for (const text of ["https://github.com/cursor/plugins", "https://www.github.com/cursor/plugins.git", "http://github.com/cursor/plugins/"]) {
|
|
68
|
+
expect(parseGitHubSource(text)).toEqual({ ok: true, source: { type: "github", repo: "cursor/plugins" } });
|
|
69
|
+
}
|
|
70
|
+
expect(parseGitHubSource("https://github.com/cursor/plugins/tree/v1.2")).toEqual({
|
|
71
|
+
ok: true,
|
|
72
|
+
source: { type: "github", repo: "cursor/plugins", ref: "v1.2" },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("refuses what is not a repository", () => {
|
|
77
|
+
for (const text of ["cursor", "cursor/plugins@", "a/b/c", "https://gitlab.com/a/b", "../x"]) {
|
|
78
|
+
expect(parseGitHubSource(text).ok).toBe(false);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("describes a source in one phrase", () => {
|
|
83
|
+
expect(describeGitHubSource({ type: "github", repo: "a/b" })).toBe("github.com/a/b");
|
|
84
|
+
expect(describeGitHubSource({ type: "github", repo: "a/b", ref: "r" })).toBe("github.com/a/b@r");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("isOwnerRepo and looksLikePath", () => {
|
|
89
|
+
it("two segments of GitHub's character set", () => {
|
|
90
|
+
expect(isOwnerRepo("a/b")).toBe(true);
|
|
91
|
+
expect(isOwnerRepo("a.b/c-d_e")).toBe(true);
|
|
92
|
+
expect(isOwnerRepo("./b")).toBe(false);
|
|
93
|
+
expect(isOwnerRepo("a/../b")).toBe(false);
|
|
94
|
+
expect(isOwnerRepo("-a/b")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a path on any platform", () => {
|
|
98
|
+
expect(looksLikePath("D:\\plugins")).toBe(true);
|
|
99
|
+
expect(looksLikePath("thermos")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client entry's identity discipline: the same tree selected and
|
|
3
|
+
* archived from a directory (the CLI's edge) and from a flat in-memory file
|
|
4
|
+
* list (the console's edge) yields one digest, and that digest is the one
|
|
5
|
+
* the CLI produced before this module existed.
|
|
6
|
+
*
|
|
7
|
+
* The two constants below were computed with the unmodified CLI at
|
|
8
|
+
* `d75b0724a` (`preparePluginPush` over `plugins/assistant` and over the
|
|
9
|
+
* vendored `thermos` fixture). `8d1000b4…` is also the digest the live
|
|
10
|
+
* `assistant` plugin carried after `stigmer up` in that release. If either
|
|
11
|
+
* changes, every installed plugin would look like an upgrade; this test is
|
|
12
|
+
* the tripwire.
|
|
13
|
+
*
|
|
14
|
+
* The selection cases pin the walk order (component-wise, directories and
|
|
15
|
+
* files interleaved by name), the whole-subtree skip of an ignored
|
|
16
|
+
* directory, the root `.gitignore` and `.stigmerignore`, and that a
|
|
17
|
+
* negation inside a skipped directory cannot pull a file back.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
|
|
25
|
+
import { archivePlugin, digestArchive } from "../client/archive.js";
|
|
26
|
+
import { type CandidateFile, compareWalkOrder, selectPluginFiles } from "../client/select.js";
|
|
27
|
+
|
|
28
|
+
const FIXTURES = fileURLToPath(new URL("./fixtures/cursor-plugins/", import.meta.url));
|
|
29
|
+
const REPO_PLUGINS = fileURLToPath(new URL("../../../../../../plugins/", import.meta.url));
|
|
30
|
+
|
|
31
|
+
const BASELINE = {
|
|
32
|
+
assistant: "8d1000b4a48136a8530509f603805693fc2478d279962d4fade40716435064ce",
|
|
33
|
+
thermos: "51bc4e5450e24762bb8515765c76bfe262f65c57f9afeda015c5818038396d5a",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
|
|
38
|
+
/** Every file under `root`, unordered, the shape a hosted tree listing has. */
|
|
39
|
+
function listDirectory(root: string): { candidates: CandidateFile[]; read: (path: string) => Uint8Array } {
|
|
40
|
+
const candidates: CandidateFile[] = [];
|
|
41
|
+
const walk = (dir: string, prefix: string): void => {
|
|
42
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
44
|
+
const full = `${dir}/${entry.name}`;
|
|
45
|
+
if (entry.isDirectory()) walk(full, path);
|
|
46
|
+
else if (entry.isFile()) candidates.push({ path, size: statSync(full).size });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(root, "");
|
|
50
|
+
// Reverse so the test proves selection imposes the order, not the listing.
|
|
51
|
+
candidates.reverse();
|
|
52
|
+
return { candidates, read: (path) => new Uint8Array(readFileSync(`${root}/${path}`)) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function inMemory(files: Record<string, string>): { candidates: CandidateFile[]; read: (path: string) => Uint8Array } {
|
|
56
|
+
const bytes = new Map(Object.entries(files).map(([path, text]) => [path, encoder.encode(text)]));
|
|
57
|
+
return {
|
|
58
|
+
candidates: [...bytes].map(([path, content]) => ({ path, size: content.length })),
|
|
59
|
+
read: (path) => {
|
|
60
|
+
const content = bytes.get(path);
|
|
61
|
+
if (content === undefined) throw new Error(`unlisted ${path}`);
|
|
62
|
+
return content;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const RESPECT = { respectGitignore: true };
|
|
68
|
+
|
|
69
|
+
describe("parity with the CLI's original walk", () => {
|
|
70
|
+
it("plugins/assistant archives to the digest stigmer up installed", async () => {
|
|
71
|
+
const { candidates, read } = listDirectory(`${REPO_PLUGINS}assistant`);
|
|
72
|
+
const selection = selectPluginFiles(candidates, read, RESPECT);
|
|
73
|
+
expect(selection.files.entries.map((entry) => entry.path)).toEqual([
|
|
74
|
+
"ai.stigmer/agent.yaml",
|
|
75
|
+
"icon.svg",
|
|
76
|
+
"plugin.json",
|
|
77
|
+
]);
|
|
78
|
+
expect(await digestArchive(archivePlugin(selection.files))).toBe(BASELINE.assistant);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("the thermos fixture archives to the CLI's digest", async () => {
|
|
82
|
+
const { candidates, read } = listDirectory(`${FIXTURES}thermos`);
|
|
83
|
+
const selection = selectPluginFiles(candidates, read, RESPECT);
|
|
84
|
+
expect(selection.stats.filesIncluded).toBe(10);
|
|
85
|
+
expect(await digestArchive(archivePlugin(selection.files))).toBe(BASELINE.thermos);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("the same bytes from an in-memory listing yield the same digest", async () => {
|
|
89
|
+
const dir = listDirectory(`${REPO_PLUGINS}assistant`);
|
|
90
|
+
const memory = inMemory(
|
|
91
|
+
Object.fromEntries(dir.candidates.map((c) => [c.path, new TextDecoder().decode(dir.read(c.path))])),
|
|
92
|
+
);
|
|
93
|
+
const selection = selectPluginFiles(memory.candidates, memory.read, RESPECT);
|
|
94
|
+
expect(await digestArchive(archivePlugin(selection.files))).toBe(BASELINE.assistant);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("compareWalkOrder", () => {
|
|
99
|
+
it("visits a directory before a file that sorts after it by name", () => {
|
|
100
|
+
// A code-point sort of full paths would put "b.txt" before "b/x.txt"
|
|
101
|
+
// ('.' < '/'); the walk visits the directory `b` first.
|
|
102
|
+
expect(["b.txt", "b/x.txt", "a.txt"].sort(compareWalkOrder)).toEqual(["a.txt", "b/x.txt", "b.txt"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("orders siblings by code point, the CLI's comparison", () => {
|
|
106
|
+
expect(["B.md", "a.md", "_x.md"].sort(compareWalkOrder)).toEqual(["B.md", "_x.md", "a.md"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("selectPluginFiles", () => {
|
|
111
|
+
it("skips an ignored directory whole, and a negation inside it cannot pull a file back", () => {
|
|
112
|
+
const { candidates, read } = inMemory({
|
|
113
|
+
"plugin.json": "{}",
|
|
114
|
+
".gitignore": "build/\n!build/keep.txt\n",
|
|
115
|
+
"build/keep.txt": "x",
|
|
116
|
+
"build/out.js": "y",
|
|
117
|
+
"src/a.ts": "z",
|
|
118
|
+
});
|
|
119
|
+
const selection = selectPluginFiles(candidates, read, RESPECT);
|
|
120
|
+
expect(selection.files.entries.map((e) => e.path)).toEqual([".gitignore", "plugin.json", "src/a.ts"]);
|
|
121
|
+
expect(selection.stats).toEqual({ filesIncluded: 3, filesIgnored: 0, dirsSkipped: 1, totalSize: 26 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("applies the security defaults and lets .env.example through", () => {
|
|
125
|
+
const { candidates, read } = inMemory({ "plugin.json": "{}", ".env": "S=1", ".env.example": "S=" });
|
|
126
|
+
const selection = selectPluginFiles(candidates, read, RESPECT);
|
|
127
|
+
expect(selection.files.entries.map((e) => e.path)).toEqual([".env.example", "plugin.json"]);
|
|
128
|
+
expect(selection.stats.filesIgnored).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("ignores .gitignore when told to, and always reads .stigmerignore", () => {
|
|
132
|
+
const files = { "plugin.json": "{}", ".gitignore": "*.draft\n", ".stigmerignore": "*.tmp\n", "a.draft": "", "b.tmp": "" };
|
|
133
|
+
const loose = selectPluginFiles(inMemory(files).candidates, inMemory(files).read, { respectGitignore: false });
|
|
134
|
+
expect(loose.files.entries.map((e) => e.path)).toContain("a.draft");
|
|
135
|
+
expect(loose.files.entries.map((e) => e.path)).not.toContain("b.tmp");
|
|
136
|
+
const strict = selectPluginFiles(inMemory(files).candidates, inMemory(files).read, RESPECT);
|
|
137
|
+
expect(strict.files.entries.map((e) => e.path)).not.toContain("a.draft");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("the selected reader refuses a path it did not list", () => {
|
|
141
|
+
const { candidates, read } = inMemory({ "plugin.json": "{}" });
|
|
142
|
+
const selection = selectPluginFiles(candidates, read, RESPECT);
|
|
143
|
+
expect(() => selection.files.read("missing.md")).toThrow("plugin file 'missing.md' is not listed");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("digestArchive", () => {
|
|
148
|
+
it("is lowercase hex SHA-256", async () => {
|
|
149
|
+
const digest = await digestArchive(new Uint8Array([1, 2, 3]));
|
|
150
|
+
expect(digest).toBe("039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81");
|
|
151
|
+
});
|
|
152
|
+
});
|