@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,158 @@
|
|
|
1
|
+
// Faithful port of Go's path/filepath Match, used by the gitignore pattern
|
|
2
|
+
// engine to match a single glob against a single path component.
|
|
3
|
+
//
|
|
4
|
+
// The separator is fixed to "/" (paths are normalized to forward slashes and
|
|
5
|
+
// individual components never contain a separator). Matching operates on UTF-16
|
|
6
|
+
// code units rather than runes — correct for the ASCII glob syntax gitignore
|
|
7
|
+
// patterns use; exotic multi-byte classes are out of scope.
|
|
8
|
+
//
|
|
9
|
+
// Go returns ErrBadPattern for malformed patterns; every go-git caller treats
|
|
10
|
+
// that as "no match", so `matchName` swallows the error and returns false.
|
|
11
|
+
|
|
12
|
+
const SEPARATOR = "/";
|
|
13
|
+
|
|
14
|
+
class BadPatternError extends Error {}
|
|
15
|
+
|
|
16
|
+
/** Match `name` against the glob `pattern` (Go filepath.Match semantics). */
|
|
17
|
+
export function matchName(pattern: string, name: string): boolean {
|
|
18
|
+
try {
|
|
19
|
+
return matchImpl(pattern, name);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err instanceof BadPatternError) return false;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function matchImpl(pattern: string, name: string): boolean {
|
|
27
|
+
outer: while (pattern.length > 0) {
|
|
28
|
+
const [star, chunk, rest] = scanChunk(pattern);
|
|
29
|
+
pattern = rest;
|
|
30
|
+
|
|
31
|
+
if (star && chunk === "") {
|
|
32
|
+
// Trailing `*` matches the rest of the component (which has no separator).
|
|
33
|
+
return !name.includes(SEPARATOR);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const [t, ok] = matchChunk(chunk, name);
|
|
37
|
+
if (ok && (t.length === 0 || pattern.length > 0)) {
|
|
38
|
+
name = t;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (star) {
|
|
43
|
+
for (let i = 0; i < name.length && name[i] !== SEPARATOR; i++) {
|
|
44
|
+
const [t2, ok2] = matchChunk(chunk, name.slice(i + 1));
|
|
45
|
+
if (ok2) {
|
|
46
|
+
if (pattern.length === 0 && t2.length > 0) continue;
|
|
47
|
+
name = t2;
|
|
48
|
+
continue outer;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return name.length === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scanChunk(pattern: string): [boolean, string, string] {
|
|
58
|
+
let star = false;
|
|
59
|
+
while (pattern.length > 0 && pattern[0] === "*") {
|
|
60
|
+
pattern = pattern.slice(1);
|
|
61
|
+
star = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let inrange = false;
|
|
65
|
+
let i = 0;
|
|
66
|
+
scan: for (i = 0; i < pattern.length; i++) {
|
|
67
|
+
switch (pattern[i]) {
|
|
68
|
+
case "\\":
|
|
69
|
+
if (i + 1 < pattern.length) i++;
|
|
70
|
+
break;
|
|
71
|
+
case "[":
|
|
72
|
+
inrange = true;
|
|
73
|
+
break;
|
|
74
|
+
case "]":
|
|
75
|
+
inrange = false;
|
|
76
|
+
break;
|
|
77
|
+
case "*":
|
|
78
|
+
if (!inrange) break scan;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [star, pattern.slice(0, i), pattern.slice(i)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function matchChunk(chunk: string, s: string): [string, boolean] {
|
|
86
|
+
let failed = false;
|
|
87
|
+
while (chunk.length > 0) {
|
|
88
|
+
if (!failed && s.length === 0) failed = true;
|
|
89
|
+
|
|
90
|
+
const c = chunk[0];
|
|
91
|
+
if (c === "[") {
|
|
92
|
+
let r = "";
|
|
93
|
+
if (!failed) {
|
|
94
|
+
r = s[0] ?? "";
|
|
95
|
+
s = s.slice(1);
|
|
96
|
+
}
|
|
97
|
+
chunk = chunk.slice(1);
|
|
98
|
+
let negated = false;
|
|
99
|
+
if (chunk.length > 0 && chunk[0] === "^") {
|
|
100
|
+
negated = true;
|
|
101
|
+
chunk = chunk.slice(1);
|
|
102
|
+
}
|
|
103
|
+
let match = false;
|
|
104
|
+
let nrange = 0;
|
|
105
|
+
for (;;) {
|
|
106
|
+
if (chunk.length > 0 && chunk[0] === "]" && nrange > 0) {
|
|
107
|
+
chunk = chunk.slice(1);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
let lo: string;
|
|
111
|
+
[lo, chunk] = getEsc(chunk);
|
|
112
|
+
let hi = lo;
|
|
113
|
+
if (chunk[0] === "-") {
|
|
114
|
+
[hi, chunk] = getEsc(chunk.slice(1));
|
|
115
|
+
}
|
|
116
|
+
if (lo <= r && r <= hi) match = true;
|
|
117
|
+
nrange++;
|
|
118
|
+
}
|
|
119
|
+
if (match === negated) failed = true;
|
|
120
|
+
} else if (c === "?") {
|
|
121
|
+
if (!failed) {
|
|
122
|
+
if (s[0] === SEPARATOR) failed = true;
|
|
123
|
+
s = s.slice(1);
|
|
124
|
+
}
|
|
125
|
+
chunk = chunk.slice(1);
|
|
126
|
+
} else if (c === "\\") {
|
|
127
|
+
chunk = chunk.slice(1);
|
|
128
|
+
if (chunk.length === 0) throw new BadPatternError("bad pattern");
|
|
129
|
+
if (!failed) {
|
|
130
|
+
if (chunk[0] !== s[0]) failed = true;
|
|
131
|
+
s = s.slice(1);
|
|
132
|
+
}
|
|
133
|
+
chunk = chunk.slice(1);
|
|
134
|
+
} else {
|
|
135
|
+
if (!failed) {
|
|
136
|
+
if (chunk[0] !== s[0]) failed = true;
|
|
137
|
+
s = s.slice(1);
|
|
138
|
+
}
|
|
139
|
+
chunk = chunk.slice(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (failed) return ["", false];
|
|
143
|
+
return [s, true];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getEsc(chunk: string): [string, string] {
|
|
147
|
+
if (chunk.length === 0 || chunk[0] === "-" || chunk[0] === "]") {
|
|
148
|
+
throw new BadPatternError("bad pattern");
|
|
149
|
+
}
|
|
150
|
+
if (chunk[0] === "\\") {
|
|
151
|
+
chunk = chunk.slice(1);
|
|
152
|
+
if (chunk.length === 0) throw new BadPatternError("bad pattern");
|
|
153
|
+
}
|
|
154
|
+
const r = chunk[0] ?? "";
|
|
155
|
+
const nchunk = chunk.slice(1);
|
|
156
|
+
if (nchunk.length === 0) throw new BadPatternError("bad pattern");
|
|
157
|
+
return [r, nchunk];
|
|
158
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Ignore matcher: composes pattern sources with gitignore precedence (defaults
|
|
2
|
+
// → .gitignore → .stigmerignore → caller flags), evaluating "last match wins".
|
|
3
|
+
// A faithful port of the Go CLI's pkg/ignore Matcher.
|
|
4
|
+
//
|
|
5
|
+
// The matcher is pure over the TEXT of the ignore files. Where that text
|
|
6
|
+
// comes from is the caller's edge: the CLI reads `.gitignore` and
|
|
7
|
+
// `.stigmerignore` from the directory it is about to package, the console
|
|
8
|
+
// finds them among the files a marketplace tree lists. Both build the same
|
|
9
|
+
// matcher from the same sources, which is what makes a plugin selected in
|
|
10
|
+
// the browser the same set of files the CLI would have selected.
|
|
11
|
+
|
|
12
|
+
import { DEFAULT_PATTERNS } from "./defaults.js";
|
|
13
|
+
import { MatchResult, type Pattern, parsePattern } from "./pattern.js";
|
|
14
|
+
|
|
15
|
+
export const SOURCE_DEFAULTS = "defaults";
|
|
16
|
+
export const SOURCE_GITIGNORE = ".gitignore";
|
|
17
|
+
export const SOURCE_STIGMERIGNORE = ".stigmerignore";
|
|
18
|
+
export const SOURCE_CLI = "cli";
|
|
19
|
+
|
|
20
|
+
export enum Reason {
|
|
21
|
+
NoMatch = 0,
|
|
22
|
+
Excluded = 1,
|
|
23
|
+
Included = 2,
|
|
24
|
+
DefaultDeny = 3,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const REASON_TEXT: Readonly<Record<Reason, string>> = {
|
|
28
|
+
[Reason.NoMatch]: "no pattern matched",
|
|
29
|
+
[Reason.Excluded]: "excluded by pattern",
|
|
30
|
+
[Reason.Included]: "included by negation pattern",
|
|
31
|
+
[Reason.DefaultDeny]: "excluded by security default",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface MatchReason {
|
|
35
|
+
readonly ignored: boolean;
|
|
36
|
+
readonly reason: Reason;
|
|
37
|
+
readonly source: string;
|
|
38
|
+
readonly pattern: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PatternEntry {
|
|
42
|
+
readonly pattern: Pattern;
|
|
43
|
+
readonly source: string;
|
|
44
|
+
readonly raw: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pattern sources a matcher is built from, in precedence order (later wins). */
|
|
48
|
+
export interface IgnoreSources {
|
|
49
|
+
/** Whether the built-in security defaults apply (every push says yes). */
|
|
50
|
+
readonly includeDefaults: boolean;
|
|
51
|
+
/** The text of the root `.gitignore`, or `undefined` when absent or not respected. */
|
|
52
|
+
readonly gitignore?: string;
|
|
53
|
+
/** The text of the root `.stigmerignore`, or `undefined` when absent. */
|
|
54
|
+
readonly stigmerignore?: string;
|
|
55
|
+
/** Extra exclusion patterns from the caller (`--ignore`). */
|
|
56
|
+
readonly extraIgnore?: readonly string[];
|
|
57
|
+
/** Extra inclusion patterns from the caller (`--include`), negated if not already. */
|
|
58
|
+
readonly extraInclude?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class Matcher {
|
|
62
|
+
private readonly entries: readonly PatternEntry[];
|
|
63
|
+
|
|
64
|
+
constructor(entries: readonly PatternEntry[]) {
|
|
65
|
+
this.entries = entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Decide whether a path (relative, forward-slash) is ignored, with reason. */
|
|
69
|
+
matchWithReason(path: string, isDir: boolean): MatchReason {
|
|
70
|
+
const components = pathToComponents(path);
|
|
71
|
+
if (components.length === 0) {
|
|
72
|
+
return { ignored: false, reason: Reason.NoMatch, source: "", pattern: "" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let lastMatch: PatternEntry | undefined;
|
|
76
|
+
let lastResult = MatchResult.NoMatch;
|
|
77
|
+
for (const entry of this.entries) {
|
|
78
|
+
const result = entry.pattern.match(components, isDir);
|
|
79
|
+
if (result !== MatchResult.NoMatch) {
|
|
80
|
+
lastMatch = entry;
|
|
81
|
+
lastResult = result;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (lastMatch === undefined) {
|
|
86
|
+
return { ignored: false, reason: Reason.NoMatch, source: "", pattern: "" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (lastResult === MatchResult.Exclude) {
|
|
90
|
+
const reason = lastMatch.source === SOURCE_DEFAULTS ? Reason.DefaultDeny : Reason.Excluded;
|
|
91
|
+
return { ignored: true, reason, source: lastMatch.source, pattern: lastMatch.raw };
|
|
92
|
+
}
|
|
93
|
+
// Include (negation): explicitly un-ignored.
|
|
94
|
+
return { ignored: false, reason: Reason.Included, source: lastMatch.source, pattern: lastMatch.raw };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the path should be excluded from the artifact. */
|
|
98
|
+
match(path: string, isDir: boolean): boolean {
|
|
99
|
+
return this.matchWithReason(path, isDir).ignored;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** All loaded patterns as "[source] pattern" (for dry-run diagnostics). */
|
|
103
|
+
patterns(): string[] {
|
|
104
|
+
return this.entries.map((entry) => `[${entry.source}] ${entry.raw}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build a matcher from its sources; the pure core every reader shares. */
|
|
109
|
+
export function buildMatcher(sources: IgnoreSources): Matcher {
|
|
110
|
+
const entries: PatternEntry[] = [];
|
|
111
|
+
if (sources.includeDefaults) {
|
|
112
|
+
entries.push(...parseEntries(DEFAULT_PATTERNS, SOURCE_DEFAULTS));
|
|
113
|
+
}
|
|
114
|
+
if (sources.gitignore !== undefined) {
|
|
115
|
+
entries.push(...parseEntries(sources.gitignore.split(/\r?\n/), SOURCE_GITIGNORE));
|
|
116
|
+
}
|
|
117
|
+
if (sources.stigmerignore !== undefined) {
|
|
118
|
+
entries.push(...parseEntries(sources.stigmerignore.split(/\r?\n/), SOURCE_STIGMERIGNORE));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const extraIgnore = sources.extraIgnore ?? [];
|
|
122
|
+
const extraInclude = sources.extraInclude ?? [];
|
|
123
|
+
if (extraIgnore.length > 0 || extraInclude.length > 0) {
|
|
124
|
+
entries.push(...loadCliPatterns(extraIgnore, extraInclude));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return new Matcher(entries);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseEntries(patterns: readonly string[], source: string): PatternEntry[] {
|
|
131
|
+
const entries: PatternEntry[] = [];
|
|
132
|
+
for (const raw of patterns) {
|
|
133
|
+
const trimmed = raw.trim();
|
|
134
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
135
|
+
entries.push({ pattern: parsePattern(trimmed, []), source, raw: trimmed });
|
|
136
|
+
}
|
|
137
|
+
return entries;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function loadCliPatterns(ignore: readonly string[], include: readonly string[]): PatternEntry[] {
|
|
141
|
+
const entries = parseEntries(ignore, SOURCE_CLI);
|
|
142
|
+
const negations: string[] = [];
|
|
143
|
+
for (const pattern of include) {
|
|
144
|
+
const trimmed = pattern.trim();
|
|
145
|
+
if (trimmed === "") continue;
|
|
146
|
+
negations.push(trimmed.startsWith("!") ? trimmed : `!${trimmed}`);
|
|
147
|
+
}
|
|
148
|
+
entries.push(...parseEntries(negations, SOURCE_CLI));
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pathToComponents(path: string): string[] {
|
|
153
|
+
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
154
|
+
if (normalized === "" || normalized === ".") return [];
|
|
155
|
+
return normalized.split("/");
|
|
156
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Faithful port of go-git's plumbing/format/gitignore Pattern (ParsePattern +
|
|
2
|
+
// Match). This is what gives `push` its exact gitignore semantics: domain
|
|
3
|
+
// scoping, directory-only patterns, single-name vs glob matching, and the
|
|
4
|
+
// `**` zero-to-many-directories wildcard.
|
|
5
|
+
|
|
6
|
+
import { matchName } from "./match.js";
|
|
7
|
+
|
|
8
|
+
export enum MatchResult {
|
|
9
|
+
NoMatch = 0,
|
|
10
|
+
Exclude = 1,
|
|
11
|
+
Include = 2,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const INCLUSION_PREFIX = "!";
|
|
15
|
+
const ZERO_TO_MANY = "**";
|
|
16
|
+
const DIR_SEP = "/";
|
|
17
|
+
|
|
18
|
+
export interface Pattern {
|
|
19
|
+
/** Whether this is a negation (`!`) pattern — needed for source ordering. */
|
|
20
|
+
readonly inclusion: boolean;
|
|
21
|
+
match(path: readonly string[], isDir: boolean): MatchResult;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class GitignorePattern implements Pattern {
|
|
25
|
+
readonly inclusion: boolean;
|
|
26
|
+
private readonly domain: readonly string[];
|
|
27
|
+
private readonly parts: readonly string[];
|
|
28
|
+
private readonly dirOnly: boolean;
|
|
29
|
+
private readonly isGlob: boolean;
|
|
30
|
+
|
|
31
|
+
constructor(domain: readonly string[], parts: readonly string[], inclusion: boolean, dirOnly: boolean, isGlob: boolean) {
|
|
32
|
+
this.domain = domain;
|
|
33
|
+
this.parts = parts;
|
|
34
|
+
this.inclusion = inclusion;
|
|
35
|
+
this.dirOnly = dirOnly;
|
|
36
|
+
this.isGlob = isGlob;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
match(path: readonly string[], isDir: boolean): MatchResult {
|
|
40
|
+
const matchResult = this.inclusion ? MatchResult.Include : MatchResult.Exclude;
|
|
41
|
+
|
|
42
|
+
if (path.length <= this.domain.length) return MatchResult.NoMatch;
|
|
43
|
+
for (let i = 0; i < this.domain.length; i++) {
|
|
44
|
+
if (path[i] !== this.domain[i]) return MatchResult.NoMatch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scoped = path.slice(this.domain.length);
|
|
48
|
+
const matched = this.isGlob ? this.globMatch(scoped, isDir) : this.simpleNameMatch(scoped, isDir);
|
|
49
|
+
return matched ? matchResult : MatchResult.NoMatch;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private simpleNameMatch(path: readonly string[], isDir: boolean): boolean {
|
|
53
|
+
for (let i = 0; i < path.length; i++) {
|
|
54
|
+
if (!matchName(this.parts[0] ?? "", path[i] ?? "")) continue;
|
|
55
|
+
if (this.dirOnly && !isDir && i === path.length - 1) return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private globMatch(path: readonly string[], isDir: boolean): boolean {
|
|
62
|
+
let matched = false;
|
|
63
|
+
let canTraverse = false;
|
|
64
|
+
let remaining = path;
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < this.parts.length; i++) {
|
|
67
|
+
const part = this.parts[i] ?? "";
|
|
68
|
+
if (part === "") {
|
|
69
|
+
canTraverse = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (part === ZERO_TO_MANY) {
|
|
73
|
+
if (i === this.parts.length - 1) break;
|
|
74
|
+
canTraverse = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (part.includes(ZERO_TO_MANY)) return false;
|
|
78
|
+
if (remaining.length === 0) return false;
|
|
79
|
+
|
|
80
|
+
if (canTraverse) {
|
|
81
|
+
canTraverse = false;
|
|
82
|
+
for (;;) {
|
|
83
|
+
if (remaining.length === 0) {
|
|
84
|
+
matched = false;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
const e = remaining[0] ?? "";
|
|
88
|
+
remaining = remaining.slice(1);
|
|
89
|
+
if (matchName(part, e)) {
|
|
90
|
+
matched = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
if (!matchName(part, remaining[0] ?? "")) return false;
|
|
96
|
+
matched = true;
|
|
97
|
+
remaining = remaining.slice(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (matched && this.dirOnly && !isDir && remaining.length === 0) {
|
|
102
|
+
matched = false;
|
|
103
|
+
}
|
|
104
|
+
return matched;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse a gitignore pattern string into a matchable Pattern (go-git parity). */
|
|
109
|
+
export function parsePattern(raw: string, domain: readonly string[]): Pattern {
|
|
110
|
+
let p = raw;
|
|
111
|
+
let inclusion = false;
|
|
112
|
+
|
|
113
|
+
if (p.startsWith(INCLUSION_PREFIX)) {
|
|
114
|
+
inclusion = true;
|
|
115
|
+
p = p.slice(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!p.endsWith("\\ ")) {
|
|
119
|
+
p = p.replace(/ +$/, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let dirOnly = false;
|
|
123
|
+
if (p.endsWith(DIR_SEP)) {
|
|
124
|
+
dirOnly = true;
|
|
125
|
+
p = p.slice(0, -1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isGlob = p.includes(DIR_SEP);
|
|
129
|
+
const parts = p.split(DIR_SEP);
|
|
130
|
+
return new GitignorePattern(domain, parts, inclusion, dirOnly, isGlob);
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two grammars a user types at any marketplace surface, and the words
|
|
3
|
+
* every client uses for what they name.
|
|
4
|
+
*
|
|
5
|
+
* `install [marketplace/]name[@version]`: the plugin's name in its
|
|
6
|
+
* marketplace, optionally qualified by the marketplace's name and pinned to
|
|
7
|
+
* the version the entry's manifest must carry. Both names obey the plugin
|
|
8
|
+
* name rule (`isValidPluginName`: lowercase letters, digits, `.` and `-`,
|
|
9
|
+
* never `/` or `@`), which is what makes the grammar unambiguous: the first
|
|
10
|
+
* `/` splits the marketplace off, the first `@` splits the version off, and
|
|
11
|
+
* anything else is not a ref. A path (`./thermos`, `/tmp/x`, `..`) is the
|
|
12
|
+
* one likely mistake and gets its own sentence, so a client can point at
|
|
13
|
+
* the command that installs a folder.
|
|
14
|
+
*
|
|
15
|
+
* A GitHub marketplace source: `owner/repo[@ref]`, or the URL a browser
|
|
16
|
+
* shows for that repository (`https://github.com/owner/repo[/tree/<ref>]`),
|
|
17
|
+
* normalised to one record. Every vendor keeps its marketplace file at the
|
|
18
|
+
* repository root, so a subdirectory grammar is not offered.
|
|
19
|
+
*
|
|
20
|
+
* Refusals are returned, not thrown: the CLI wraps one in its `UsageError`
|
|
21
|
+
* and the console renders it, and the sentence is the same in both. A
|
|
22
|
+
* client adds the second paragraph naming its own command.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { isValidPluginName } from "../detect.js";
|
|
26
|
+
|
|
27
|
+
/** The built-in marketplace's name: the prefix in `install stigmer/<plugin>`; reserved in every client. */
|
|
28
|
+
export const OFFICIAL_MARKETPLACE_NAME = "stigmer";
|
|
29
|
+
|
|
30
|
+
export interface InstallRef {
|
|
31
|
+
/** The marketplace named before the `/`, or `undefined` when the name stands alone. */
|
|
32
|
+
readonly marketplace?: string;
|
|
33
|
+
readonly name: string;
|
|
34
|
+
/** The version named after the `@`, asserted against the entry's manifest. */
|
|
35
|
+
readonly version?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type InstallRefOutcome =
|
|
39
|
+
| { readonly ok: true; readonly ref: InstallRef }
|
|
40
|
+
| {
|
|
41
|
+
readonly ok: false;
|
|
42
|
+
/** `path` when the text names a folder rather than a plugin; `shape` for every other refusal. */
|
|
43
|
+
readonly kind: "path" | "shape";
|
|
44
|
+
readonly message: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The characters a `@version` may carry: the plugin's tag pattern, so what a ref pins is a tag the server can hold. */
|
|
48
|
+
const VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
49
|
+
|
|
50
|
+
/** The grammar in the user's terms, quoted by every refusal. */
|
|
51
|
+
export const INSTALL_REF_SHAPE =
|
|
52
|
+
"[marketplace/]name[@version], for example 'thermos', 'cursor-plugins/thermos' or 'thermos@1.0.0'";
|
|
53
|
+
|
|
54
|
+
export function parseInstallRef(text: string): InstallRefOutcome {
|
|
55
|
+
if (looksLikePath(text)) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
kind: "path",
|
|
59
|
+
message: `'${text}' is a path, and 'install' takes a plugin's name in a marketplace`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let rest = text;
|
|
63
|
+
let marketplace: string | undefined;
|
|
64
|
+
const slash = rest.indexOf("/");
|
|
65
|
+
if (slash !== -1) {
|
|
66
|
+
marketplace = rest.slice(0, slash);
|
|
67
|
+
rest = rest.slice(slash + 1);
|
|
68
|
+
if (!isValidPluginName(marketplace)) {
|
|
69
|
+
return { ok: false, kind: "shape", message: `'${marketplace}' is not a marketplace name` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let version: string | undefined;
|
|
73
|
+
const at = rest.indexOf("@");
|
|
74
|
+
if (at !== -1) {
|
|
75
|
+
version = rest.slice(at + 1);
|
|
76
|
+
rest = rest.slice(0, at);
|
|
77
|
+
if (version === "" || !VERSION_PATTERN.test(version)) {
|
|
78
|
+
return { ok: false, kind: "shape", message: `'${version}' is not a version` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!isValidPluginName(rest)) {
|
|
82
|
+
return { ok: false, kind: "shape", message: `'${rest}' is not a plugin name` };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
ref: {
|
|
87
|
+
...(marketplace !== undefined && { marketplace }),
|
|
88
|
+
name: rest,
|
|
89
|
+
...(version !== undefined && { version }),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The grammar's own text for a ref, the inverse of `parseInstallRef`. */
|
|
95
|
+
export function formatInstallRef(ref: InstallRef): string {
|
|
96
|
+
const prefix = ref.marketplace === undefined ? "" : `${ref.marketplace}/`;
|
|
97
|
+
const suffix = ref.version === undefined ? "" : `@${ref.version}`;
|
|
98
|
+
return `${prefix}${ref.name}${suffix}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether `text` reads as a filesystem path on any platform (relative, home, absolute, Windows). */
|
|
102
|
+
export function looksLikePath(text: string): boolean {
|
|
103
|
+
return (
|
|
104
|
+
text === "." ||
|
|
105
|
+
text === ".." ||
|
|
106
|
+
text.startsWith("./") ||
|
|
107
|
+
text.startsWith("../") ||
|
|
108
|
+
text.startsWith("~") ||
|
|
109
|
+
text.startsWith("/") ||
|
|
110
|
+
text.includes("\\") ||
|
|
111
|
+
/^[A-Za-z]:/.test(text)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A public GitHub repository at a ref; the default branch when `ref` is absent. */
|
|
116
|
+
export interface GitHubMarketplaceSource {
|
|
117
|
+
readonly type: "github";
|
|
118
|
+
/** `owner/repo`. */
|
|
119
|
+
readonly repo: string;
|
|
120
|
+
/** Branch, tag or commit. */
|
|
121
|
+
readonly ref?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type GitHubSourceOutcome =
|
|
125
|
+
| { readonly ok: true; readonly source: GitHubMarketplaceSource }
|
|
126
|
+
| { readonly ok: false; readonly message: string };
|
|
127
|
+
|
|
128
|
+
/** What a GitHub source may be typed as, quoted by every refusal. */
|
|
129
|
+
export const GITHUB_SOURCE_SHAPE =
|
|
130
|
+
"a GitHub repository as 'owner/repo' or 'owner/repo@ref', or that repository's URL (https://github.com/owner/repo)";
|
|
131
|
+
|
|
132
|
+
const GITHUB_URL = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:\/tree\/([^\s#?]+))?\/?$/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `owner/repo[@ref]` or a github.com URL as one source. The URL form is
|
|
136
|
+
* tried first because it contains a `/` too; a text that is neither is
|
|
137
|
+
* refused with the shape.
|
|
138
|
+
*/
|
|
139
|
+
export function parseGitHubSource(text: string): GitHubSourceOutcome {
|
|
140
|
+
const url = GITHUB_URL.exec(text);
|
|
141
|
+
if (url !== null) {
|
|
142
|
+
const repo = `${url[1] ?? ""}/${url[2] ?? ""}`;
|
|
143
|
+
if (!isOwnerRepo(repo)) {
|
|
144
|
+
return { ok: false, message: `'${text}' does not name a GitHub repository` };
|
|
145
|
+
}
|
|
146
|
+
const ref = url[3];
|
|
147
|
+
return { ok: true, source: { type: "github", repo, ...(ref !== undefined && ref !== "" && { ref }) } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const at = text.indexOf("@");
|
|
151
|
+
const repo = at === -1 ? text : text.slice(0, at);
|
|
152
|
+
const ref = at === -1 ? undefined : text.slice(at + 1);
|
|
153
|
+
if (isOwnerRepo(repo) && (ref === undefined || ref !== "")) {
|
|
154
|
+
return { ok: true, source: { type: "github", repo, ...(ref !== undefined && { ref }) } };
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, message: `'${text}' is not a GitHub 'owner/repo'` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One phrase naming a GitHub source, for lists and sentences. */
|
|
160
|
+
export function describeGitHubSource(source: GitHubMarketplaceSource): string {
|
|
161
|
+
return source.ref === undefined ? `github.com/${source.repo}` : `github.com/${source.repo}@${source.ref}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const OWNER_REPO_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
165
|
+
|
|
166
|
+
/** `owner/repo` as GitHub spells it: two segments, no `.git` suffix required or refused. */
|
|
167
|
+
export function isOwnerRepo(value: string): boolean {
|
|
168
|
+
const parts = value.split("/");
|
|
169
|
+
return parts.length === 2 && parts.every((part) => OWNER_REPO_SEGMENT.test(part) && part !== "." && part !== "..");
|
|
170
|
+
}
|