dsh-agent-plugins-market 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1388 -204
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Claude Code-specific keys this integration intentionally ignores. */
|
|
2
|
+
const CC_ONLY_KEYS = new Set(['startupTimeout']);
|
|
3
|
+
/** Keys accepted by the normalized spec; anything else is reported as ignored. */
|
|
4
|
+
const KNOWN_KEYS = new Set(['command', 'args', 'extensionToLanguage', 'env', 'initializationOptions', 'configuration', ...CC_ONLY_KEYS]);
|
|
5
|
+
/** Parse one inline `lspServers` table into normalized specs plus diagnostics. */
|
|
6
|
+
export function parseLspServers(raw, errors) {
|
|
7
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
8
|
+
if (raw !== undefined)
|
|
9
|
+
errors.push('lspServers: not an object');
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
const specs = {};
|
|
13
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
14
|
+
const spec = parseOneServer(key, value, errors);
|
|
15
|
+
if (spec !== undefined)
|
|
16
|
+
specs[key] = spec;
|
|
17
|
+
}
|
|
18
|
+
return specs;
|
|
19
|
+
}
|
|
20
|
+
function parseOneServer(key, raw, errors) {
|
|
21
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
22
|
+
errors.push(`lspServers.${key}: server entry is not an object`);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const record = raw;
|
|
26
|
+
for (const field of Object.keys(record)) {
|
|
27
|
+
if (!KNOWN_KEYS.has(field))
|
|
28
|
+
errors.push(`lspServers.${key}: ignoring unknown field "${field}"`);
|
|
29
|
+
}
|
|
30
|
+
const command = record['command'];
|
|
31
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
32
|
+
errors.push(`lspServers.${key}: "command" must be a non-empty string`);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const args = record['args'];
|
|
36
|
+
if (args !== undefined && (!Array.isArray(args) || args.some(entry => typeof entry !== 'string'))) {
|
|
37
|
+
errors.push(`lspServers.${key}: "args" must be an array of strings`);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const mapping = parseExtensionToLanguage(key, record['extensionToLanguage'], errors);
|
|
41
|
+
if (mapping === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
const env = record['env'];
|
|
44
|
+
if (env !== undefined && (typeof env !== 'object' || env === null || Array.isArray(env) || Object.values(env).some(value => typeof value !== 'string'))) {
|
|
45
|
+
errors.push(`lspServers.${key}: "env" must be an object of strings`);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
key,
|
|
50
|
+
command,
|
|
51
|
+
args: Array.isArray(args) ? args : [],
|
|
52
|
+
extensionToLanguage: mapping,
|
|
53
|
+
...(typeof env === 'object' && env !== null && !Array.isArray(env) ? { env: env } : {}),
|
|
54
|
+
...(record['initializationOptions'] !== undefined ? { initializationOptions: record['initializationOptions'] } : {}),
|
|
55
|
+
...(record['configuration'] !== undefined ? { configuration: record['configuration'] } : {})
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Normalize and validate `extensionToLanguage`: lowercase leading-dot keys, non-empty ids. */
|
|
59
|
+
function parseExtensionToLanguage(key, raw, errors) {
|
|
60
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
61
|
+
errors.push(`lspServers.${key}: "extensionToLanguage" must be a non-empty object`);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const mapping = {};
|
|
65
|
+
for (const [rawExt, languageId] of Object.entries(raw)) {
|
|
66
|
+
const ext = rawExt.toLowerCase();
|
|
67
|
+
const normalized = ext.startsWith('.') ? ext : `.${ext}`;
|
|
68
|
+
if (!/^\.[^.\\/]+$/.test(normalized)) {
|
|
69
|
+
errors.push(`lspServers.${key}: invalid extension "${rawExt}"`);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (typeof languageId !== 'string' || languageId.trim() === '') {
|
|
73
|
+
errors.push(`lspServers.${key}: extension "${normalized}" maps to an empty language id`);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
// Case-variant spellings of one extension (clangd's `.c`/`.C`) collapse
|
|
77
|
+
// silently: normalization makes them the same key, and the first id wins.
|
|
78
|
+
if (mapping[normalized] !== undefined)
|
|
79
|
+
continue;
|
|
80
|
+
mapping[normalized] = languageId;
|
|
81
|
+
}
|
|
82
|
+
if (Object.keys(mapping).length === 0) {
|
|
83
|
+
errors.push(`lspServers.${key}: "extensionToLanguage" must not be empty`);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return mapping;
|
|
87
|
+
}
|
package/lib/catalog/manifests.js
CHANGED
|
@@ -73,22 +73,31 @@ export async function readManifest(root, errors, hint) {
|
|
|
73
73
|
return undefined;
|
|
74
74
|
}
|
|
75
75
|
const record = raw;
|
|
76
|
-
|
|
76
|
+
// The v1 dialect is identified by position AND declaration: the spec's
|
|
77
|
+
// schema requires `$schema`, so a root `plugin.json` without it is not a
|
|
78
|
+
// v1 manifest. Real-world single-repo marketplaces (agent-skills) ship a
|
|
79
|
+
// plain metadata object at the root alongside their Claude dialect
|
|
80
|
+
// manifests; reading that as strict v1 would fail-closed on a repo that
|
|
81
|
+
// is perfectly usable. Such a file is read leniently as the Claude
|
|
82
|
+
// layout instead — strict validation only ever applies to manifests
|
|
83
|
+
// that actually declare the v1 schema.
|
|
84
|
+
const kind = candidate.kind === 'agent-plugin-v1' && !isRecognizedSchema(record.$schema) ? 'claude-code' : candidate.kind;
|
|
85
|
+
const problems = kind === 'agent-plugin-v1' ? await validatePluginManifest(raw) : [];
|
|
77
86
|
errors.push(...problems.map(problem => `${candidate.path}: ${problem}`));
|
|
78
87
|
const name = pickString(record.name) ?? hint?.name ?? syntheticManifestName(root);
|
|
79
88
|
const version = pickString(record.version) ?? hint?.version;
|
|
80
89
|
const description = pickString(record.description) ?? hint?.description;
|
|
81
90
|
const author = record.author;
|
|
82
91
|
return {
|
|
83
|
-
layout:
|
|
92
|
+
layout: kind === 'agent-plugin-v1'
|
|
84
93
|
? 'agent-plugin-v1'
|
|
85
|
-
:
|
|
94
|
+
: kind === 'universal'
|
|
86
95
|
? 'universal'
|
|
87
|
-
:
|
|
96
|
+
: kind === 'claude-code'
|
|
88
97
|
? 'claude-code'
|
|
89
|
-
:
|
|
98
|
+
: kind === 'cursor'
|
|
90
99
|
? 'cursor'
|
|
91
|
-
:
|
|
100
|
+
: kind === 'kimi'
|
|
92
101
|
? 'kimi'
|
|
93
102
|
: 'codex',
|
|
94
103
|
path: candidate.path,
|
|
@@ -106,34 +115,59 @@ function pickString(value) {
|
|
|
106
115
|
}
|
|
107
116
|
/** Marketplace manifest locations per dialect (Claude Code, Codex). */
|
|
108
117
|
const MARKETPLACE_PATHS = ['.claude-plugin/marketplace.json', '.agents/plugins/marketplace.json'];
|
|
109
|
-
/** Read
|
|
110
|
-
|
|
118
|
+
/** Read one marketplace manifest file; malformed documents are diagnosed, not thrown. */
|
|
119
|
+
async function readOneMarketplace(path, errors) {
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
text = await readFile(path, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(text);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
errors.push(`marketplace ${path} unparsable: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
136
|
+
errors.push(`marketplace ${path}: manifest is not a JSON object`);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const record = parsed;
|
|
140
|
+
const plugins = record['plugins'];
|
|
141
|
+
if (!Array.isArray(plugins)) {
|
|
142
|
+
errors.push(`marketplace ${path}: "plugins" is not an array`);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...(typeof record['name'] === 'string' ? { name: record['name'] } : {}),
|
|
147
|
+
entries: plugins,
|
|
148
|
+
path
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read every marketplace manifest the checkout carries, in dialect
|
|
153
|
+
* precedence order (Claude Code before Codex). Both dialects can coexist
|
|
154
|
+
* (e.g. a repo shipping one marketplace per agent); callers decide which
|
|
155
|
+
* dialect's entries win, so no dialect is silently shadowed.
|
|
156
|
+
*/
|
|
157
|
+
export async function readMarketplaces(checkoutDir) {
|
|
158
|
+
const results = [];
|
|
159
|
+
const errors = [];
|
|
111
160
|
for (const relative of MARKETPLACE_PATHS) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
try {
|
|
120
|
-
const parsed = JSON.parse(text);
|
|
121
|
-
if (typeof parsed !== 'object' || parsed === null)
|
|
122
|
-
continue;
|
|
123
|
-
const record = parsed;
|
|
124
|
-
const plugins = record['plugins'];
|
|
125
|
-
if (!Array.isArray(plugins))
|
|
126
|
-
continue;
|
|
127
|
-
return {
|
|
128
|
-
...(typeof record['name'] === 'string' ? { name: record['name'] } : {}),
|
|
129
|
-
entries: plugins
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
161
|
+
const result = await readOneMarketplace(join(checkoutDir, relative), errors);
|
|
162
|
+
if (result !== undefined)
|
|
163
|
+
results.push(result);
|
|
135
164
|
}
|
|
136
|
-
return
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
/** Read the highest-precedence marketplace manifest, or undefined when absent. */
|
|
168
|
+
export async function readMarketplace(checkoutDir) {
|
|
169
|
+
const results = await readMarketplaces(checkoutDir);
|
|
170
|
+
return results[0];
|
|
137
171
|
}
|
|
138
172
|
/** Resolve one marketplace entry to a local checkout-relative directory, or
|
|
139
173
|
* `undefined` for remote-URL entries that are not present in the clone. */
|
|
@@ -220,3 +254,21 @@ export async function declaredMcpServers(root) {
|
|
|
220
254
|
}
|
|
221
255
|
return undefined;
|
|
222
256
|
}
|
|
257
|
+
/** The winning manifest's inline `lspServers`, or undefined. */
|
|
258
|
+
export async function declaredLspServers(root) {
|
|
259
|
+
const candidate = await detectManifest(root);
|
|
260
|
+
if (candidate === undefined)
|
|
261
|
+
return undefined;
|
|
262
|
+
try {
|
|
263
|
+
const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
|
|
264
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
265
|
+
const servers = raw['lspServers'];
|
|
266
|
+
if (typeof servers === 'object' && servers !== null)
|
|
267
|
+
return servers;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
package/lib/catalog/paths.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* User dimension: `~/.dsh/agent-plugins/` (or `$DSH_HOME/agent-plugins`).
|
|
5
5
|
* Project dimension: `<projectRoot>/.dsh/agent-plugins/`, where the project
|
|
6
6
|
* root is the nearest ancestor containing `.git`.
|
|
7
|
+
*
|
|
8
|
+
* Everything the plugin persists lives under one root per dimension —
|
|
9
|
+
* `.sources/` (checkouts) and `state.json` (install state) alongside `data/`
|
|
10
|
+
* (suite `${PLUGIN_DATA}` directories) and `overrides/` (MCP configuration
|
|
11
|
+
* rewrites) — so no sibling `agent-plugins-data` root exists.
|
|
7
12
|
*/
|
|
8
13
|
import { existsSync } from 'node:fs';
|
|
9
14
|
import { stat } from 'node:fs/promises';
|
|
@@ -28,9 +33,13 @@ export function resolveDshHome() {
|
|
|
28
33
|
export function resolveUserRoot(configUserRoot) {
|
|
29
34
|
return resolve(expandHome(configUserRoot ?? join(resolveDshHome(), 'agent-plugins')));
|
|
30
35
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the suite data root hosting `${PLUGIN_DATA}` directories and the
|
|
38
|
+
* MCP overrides. Defaults under the user root so the whole plugin persists
|
|
39
|
+
* into one directory; an explicit `dataRoot` config still wins.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveDataRoot(configDataRoot, configUserRoot) {
|
|
42
|
+
return resolve(expandHome(configDataRoot ?? join(configUserRoot ?? resolveUserRoot(), 'data')));
|
|
34
43
|
}
|
|
35
44
|
/** Resolve a project root from a workspace cwd: nearest ancestor with `.git`. */
|
|
36
45
|
export async function findProjectRoot(cwd) {
|
|
@@ -60,6 +69,15 @@ export function sourceCheckoutDir(dimensionRoot, sourceId) {
|
|
|
60
69
|
export function suiteDataDir(dataRoot, suiteId) {
|
|
61
70
|
return join(dataRoot, DATA_DIR_NAME, suiteId);
|
|
62
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Suite-qualified key: `${sourceId}/${suiteId}`. Suite ids are unique within a
|
|
74
|
+
* source only (two sources may both ship a suite named "utils"), so every
|
|
75
|
+
* state, override, data-directory, and mount key must be source-scoped — a
|
|
76
|
+
* bare suite id silently collides across sources.
|
|
77
|
+
*/
|
|
78
|
+
export function qualifiedSuiteId(sourceId, suiteId) {
|
|
79
|
+
return `${sourceId}/${suiteId}`;
|
|
80
|
+
}
|
|
63
81
|
/** Async existence probe that follows symlinks for a final component. */
|
|
64
82
|
export async function isDirectory(path) {
|
|
65
83
|
try {
|
|
@@ -70,6 +88,16 @@ export async function isDirectory(path) {
|
|
|
70
88
|
return false;
|
|
71
89
|
}
|
|
72
90
|
}
|
|
91
|
+
/** Async existence probe for any entry (file, directory, symlink). */
|
|
92
|
+
export async function pathExists(path) {
|
|
93
|
+
try {
|
|
94
|
+
await stat(path);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
73
101
|
/** Sanitize a plugin or server id into `[a-z0-9-]` (lowercased). */
|
|
74
102
|
export function sanitizeId(raw) {
|
|
75
103
|
const cleaned = raw
|
|
@@ -79,11 +107,41 @@ export function sanitizeId(raw) {
|
|
|
79
107
|
.replace(/-{2,}/g, '-');
|
|
80
108
|
return cleaned === '' ? 'unnamed' : cleaned;
|
|
81
109
|
}
|
|
110
|
+
/** Archive extensions stripped from a URL basename before id derivation. */
|
|
111
|
+
const ARCHIVE_SUFFIX_PATTERN = /\.(zip|tgz|tar\.gz|tar)$/i;
|
|
112
|
+
/** Strip a trailing `.git` or archive suffix (`plugin-0.1.zip` → `plugin-0-1`). */
|
|
113
|
+
function stripSourceSuffix(base) {
|
|
114
|
+
if (base.endsWith('.git'))
|
|
115
|
+
base = base.slice(0, -4);
|
|
116
|
+
return base.replace(ARCHIVE_SUFFIX_PATTERN, '');
|
|
117
|
+
}
|
|
82
118
|
/** Derive a source id from a repository URL or local path: last path segment, `.git` stripped. */
|
|
83
119
|
export function deriveSourceId(url) {
|
|
84
120
|
const trimmed = url.trim().replace(/\/+$/, '');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
121
|
+
const base = trimmed.split(/[/\\]/).at(-1) ?? '';
|
|
122
|
+
return sanitizeId(stripSourceSuffix(base));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Derive candidate source ids from a git URL or local path, most preferred
|
|
126
|
+
* first: the sanitized basename, then an owner-prefixed variant (`owner-repo`)
|
|
127
|
+
* for remote URLs so same-named repositories from different owners
|
|
128
|
+
* (`cloudflare/skills`, `mattpocock/skills`) degrade to readable ids instead
|
|
129
|
+
* of numeric suffixes. Local paths yield the basename only.
|
|
130
|
+
*/
|
|
131
|
+
export function deriveSourceIdCandidates(url) {
|
|
132
|
+
const trimmed = url.trim().replace(/\/+$/, '');
|
|
133
|
+
const base = stripSourceSuffix(trimmed.split(/[/\\]/).at(-1) ?? '');
|
|
134
|
+
const primary = sanitizeId(base);
|
|
135
|
+
const isRemote = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || /^[\w.-]+@[\w.-]+:/.test(trimmed);
|
|
136
|
+
const candidates = [primary];
|
|
137
|
+
if (isRemote) {
|
|
138
|
+
const hostPath = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[\w.-]+@([\w.-]+):/, '$1/');
|
|
139
|
+
const segments = hostPath.split(/[/\\]/).filter(Boolean);
|
|
140
|
+
if (segments.length >= 2) {
|
|
141
|
+
const owner = sanitizeId(segments[segments.length - 2]);
|
|
142
|
+
if (owner !== '' && owner !== primary)
|
|
143
|
+
candidates.push(`${owner}-${primary}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(candidates)];
|
|
89
147
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Drive one checkout through the filter chain, recording every attempt. */
|
|
2
|
+
export async function runScanChain(filters, context) {
|
|
3
|
+
const attempts = [];
|
|
4
|
+
const notes = context.notes;
|
|
5
|
+
const run = async (index) => {
|
|
6
|
+
const filter = filters[index];
|
|
7
|
+
if (filter === undefined) {
|
|
8
|
+
// Terminal: the chain must always answer. A checkout that reaches the
|
|
9
|
+
// end declares nothing this manager understands.
|
|
10
|
+
return { kind: 'resolved', suites: [] };
|
|
11
|
+
}
|
|
12
|
+
const productive = (resolution) => resolution.kind === 'resolved' && resolution.suites.length > 0;
|
|
13
|
+
// A filter that delegates has no answer of its own: recording its
|
|
14
|
+
// delegation as an abstention keeps the attempt trace complete.
|
|
15
|
+
let delegated = false;
|
|
16
|
+
const remaining = {
|
|
17
|
+
next: _ctx => {
|
|
18
|
+
delegated = true;
|
|
19
|
+
return run(index + 1);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const resolution = await filter.doScan(context, remaining);
|
|
23
|
+
// A delegated resolution was already recorded by the inner filter that
|
|
24
|
+
// produced it: mark this filter's delegation and pass the answer through.
|
|
25
|
+
if (delegated) {
|
|
26
|
+
attempts.push({ filter: filter.name, outcome: 'abstain', detail: 'delegated', suiteIds: [] });
|
|
27
|
+
return resolution;
|
|
28
|
+
}
|
|
29
|
+
const hit = productive(resolution);
|
|
30
|
+
attempts.push({
|
|
31
|
+
filter: filter.name,
|
|
32
|
+
outcome: hit ? 'resolved' : 'abstain',
|
|
33
|
+
detail: hit ? `${resolution.suites.length} suite(s)` : resolution.kind === 'abstain' ? resolution.reason : '0 suite(s)',
|
|
34
|
+
suiteIds: hit ? resolution.suites.map(suite => suite.id) : []
|
|
35
|
+
});
|
|
36
|
+
if (!hit) {
|
|
37
|
+
if (resolution.kind === 'abstain') {
|
|
38
|
+
notes.push(`filter "${filter.name}" abstained: ${resolution.reason}`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
notes.push(`filter "${filter.name}" recognized the checkout but produced no suites; falling through`);
|
|
42
|
+
}
|
|
43
|
+
// Recognized but unproductive: later strategies still get their chance.
|
|
44
|
+
return run(index + 1);
|
|
45
|
+
}
|
|
46
|
+
return resolution;
|
|
47
|
+
};
|
|
48
|
+
const resolution = await run(0);
|
|
49
|
+
return { suites: resolution.kind === 'resolved' ? resolution.suites : [], attempts, notes };
|
|
50
|
+
}
|