@mulmoclaude/core 0.2.15 → 0.2.16
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/dist/collection/registry/guards.d.ts +2 -0
- package/dist/collection/registry/index.cjs +4 -0
- package/dist/collection/registry/index.d.ts +2 -0
- package/dist/collection/registry/index.js +2 -0
- package/dist/collection/registry/registryIndex.d.ts +48 -0
- package/dist/collection/registry/server/client.d.ts +60 -0
- package/dist/collection/registry/server/collectionFiles.d.ts +51 -0
- package/dist/collection/registry/server/exportCollection.d.ts +29 -0
- package/dist/collection/registry/server/fetch.d.ts +8 -0
- package/dist/collection/registry/server/importCollection.d.ts +35 -0
- package/dist/collection/registry/server/importWriter.d.ts +30 -0
- package/dist/collection/registry/server/index.cjs +1110 -0
- package/dist/collection/registry/server/index.cjs.map +1 -0
- package/dist/collection/registry/server/index.d.ts +28 -0
- package/dist/collection/registry/server/index.js +1076 -0
- package/dist/collection/registry/server/index.js.map +1 -0
- package/dist/collection/registry/server/performExport.d.ts +6 -0
- package/dist/collection/registry/server/registriesConfig.d.ts +11 -0
- package/dist/collection/registry/server/skillDescription.d.ts +4 -0
- package/dist/collection/registry/types.d.ts +37 -0
- package/dist/collection/server/host.d.ts +7 -0
- package/dist/collection/server/index.cjs +1 -1
- package/dist/collection/server/index.js +1 -1
- package/dist/collection/server/util.d.ts +1 -0
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +1 -1
- package/dist/feeds/server/index.cjs +1 -1
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +1 -1
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/notifier-ChpY0XrY.js.map +1 -1
- package/dist/notifier-bS8IEeLA.cjs.map +1 -1
- package/dist/{server-CRlmOBVQ.js → server-DRoqc8dL.js} +8 -2
- package/dist/server-DRoqc8dL.js.map +1 -0
- package/dist/{server-BjXvqGrE.cjs → server-DoDXibCq.cjs} +31 -1
- package/dist/server-DoDXibCq.cjs.map +1 -0
- package/dist/types-BKVZrtyc.js +123 -0
- package/dist/types-BKVZrtyc.js.map +1 -0
- package/dist/types-CNqkLT4M.cjs +140 -0
- package/dist/types-CNqkLT4M.cjs.map +1 -0
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.js.map +1 -1
- package/package.json +13 -1
- package/dist/server-BjXvqGrE.cjs.map +0 -1
- package/dist/server-CRlmOBVQ.js.map +0 -1
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import { G as collectionsRegistriesConfigPath, I as writeFileAtomic, J as log, U as safeRecordId, _ as acceptParsedSchema, g as CollectionSchemaZ, m as errorMessage, p as ONE_SECOND_MS, y as loadCollection } from "../../../server-DRoqc8dL.js";
|
|
2
|
+
import { isSafeActionTemplatePath } from "../../paths.js";
|
|
3
|
+
import { n as parseRegistryIndex, r as isRecord, t as OFFICIAL_REGISTRY_NAME } from "../../../types-BKVZrtyc.js";
|
|
4
|
+
import { claudeSkillDir, dataSkillDir, mirrorSkillWrite } from "../../../skill-bridge/index.js";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
//#region src/collection/registry/server/fetch.ts
|
|
9
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;
|
|
10
|
+
/** `fetch` with a finite timeout. Rejects with a `TimeoutError` once `timeoutMs`
|
|
11
|
+
* elapses. Composes with a caller-supplied `signal` so external cancellation
|
|
12
|
+
* still works. */
|
|
13
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
14
|
+
const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;
|
|
15
|
+
if (callerSignal?.aborted) throw callerSignal.reason ?? new DOMException("Aborted", "AbortError");
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, "TimeoutError"));
|
|
19
|
+
}, timeoutMs);
|
|
20
|
+
const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);
|
|
21
|
+
try {
|
|
22
|
+
return await fetch(url, {
|
|
23
|
+
...rest,
|
|
24
|
+
signal: controller.signal
|
|
25
|
+
});
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
unsubscribeCaller?.();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function bridgeExternalSignal(external, controller) {
|
|
32
|
+
if (!external) return null;
|
|
33
|
+
const onAbort = () => controller.abort(external.reason);
|
|
34
|
+
external.addEventListener("abort", onAbort, { once: true });
|
|
35
|
+
return () => external.removeEventListener("abort", onAbort);
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/collection/registry/server/registriesConfig.ts
|
|
39
|
+
var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
40
|
+
function isValidHttpsUrl(value) {
|
|
41
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(value);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol !== "https:") return false;
|
|
49
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
function parseEntry(value, index) {
|
|
53
|
+
if (!isRecord(value)) return `entry[${index}] is not an object`;
|
|
54
|
+
const { name, indexUrl, rawBaseUrl } = value;
|
|
55
|
+
if (typeof name !== "string" || !NAME_RE.test(name)) return `entry[${index}].name must match ${NAME_RE.source}`;
|
|
56
|
+
if (name === "official") return `entry[${index}].name "${OFFICIAL_REGISTRY_NAME}" is reserved`;
|
|
57
|
+
if (!isValidHttpsUrl(indexUrl)) return `entry[${index}].indexUrl must be a valid HTTPS URL (no credentials)`;
|
|
58
|
+
if (!isValidHttpsUrl(rawBaseUrl)) return `entry[${index}].rawBaseUrl must be a valid HTTPS URL (no credentials)`;
|
|
59
|
+
if (rawBaseUrl.includes("?") || rawBaseUrl.includes("#")) return `entry[${index}].rawBaseUrl must not contain a query (?) or fragment (#) — it's joined as a path prefix`;
|
|
60
|
+
let normalizedRawBase = rawBaseUrl;
|
|
61
|
+
while (normalizedRawBase.endsWith("/")) normalizedRawBase = normalizedRawBase.slice(0, -1);
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
indexUrl,
|
|
65
|
+
rawBaseUrl: normalizedRawBase
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Parse the JSON text of `config/collections-registries.json`. Bad entries
|
|
69
|
+
* drop with a warning; valid entries are returned in source order. Exported
|
|
70
|
+
* for unit testing — the I/O wrapper `loadRegistriesConfig` below reads the
|
|
71
|
+
* workspace file and delegates here. */
|
|
72
|
+
function parseRegistriesConfig(raw) {
|
|
73
|
+
if (!Array.isArray(raw)) {
|
|
74
|
+
log.warn("collections-registry", "registries config is not an array — ignoring");
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const seen = /* @__PURE__ */ new Set();
|
|
78
|
+
const out = [];
|
|
79
|
+
for (let i = 0; i < raw.length; i++) {
|
|
80
|
+
const parsed = parseEntry(raw[i], i);
|
|
81
|
+
if (typeof parsed === "string") {
|
|
82
|
+
log.warn("collections-registry", "registry config entry rejected", { reason: parsed });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(parsed.name)) {
|
|
86
|
+
log.warn("collections-registry", "registry config duplicate name dropped", { name: parsed.name });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
seen.add(parsed.name);
|
|
90
|
+
out.push(parsed);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function readConfigTextOrNull(filePath) {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(filePath, "utf-8");
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (isRecord(err) && err.code === "ENOENT") return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Read `config/collections-registries.json` from the configured workspace.
|
|
103
|
+
* Missing file ⇒ empty list. Malformed JSON or rejected entries log but never
|
|
104
|
+
* throw. */
|
|
105
|
+
function loadRegistriesConfig() {
|
|
106
|
+
const text = readConfigTextOrNull(collectionsRegistriesConfigPath());
|
|
107
|
+
if (text === null) return [];
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(text);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log.warn("collections-registry", "registries config is not valid JSON — ignoring", { error: err instanceof Error ? err.message : String(err) });
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return parseRegistriesConfig(parsed);
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/collection/registry/server/client.ts
|
|
119
|
+
var DEFAULT_OFFICIAL_INDEX_URL = "https://receptron.github.io/mulmoclaude-collections/index.json";
|
|
120
|
+
var DEFAULT_OFFICIAL_RAW_BASE = "https://raw.githubusercontent.com/receptron/mulmoclaude-collections/main";
|
|
121
|
+
var CACHE_TTL_MS = 300 * ONE_SECOND_MS;
|
|
122
|
+
var STALE_RETRY_BACKOFF_MS = 60 * ONE_SECOND_MS;
|
|
123
|
+
var FETCH_TIMEOUT_MS$1 = 10 * ONE_SECOND_MS;
|
|
124
|
+
var STATUS_BAD_GATEWAY$2 = 502;
|
|
125
|
+
var STATUS_UNAVAILABLE$1 = 503;
|
|
126
|
+
var cache = /* @__PURE__ */ new Map();
|
|
127
|
+
var lastFailureMs = /* @__PURE__ */ new Map();
|
|
128
|
+
function officialDescriptor() {
|
|
129
|
+
return {
|
|
130
|
+
name: OFFICIAL_REGISTRY_NAME,
|
|
131
|
+
indexUrl: process.env.COLLECTIONS_REGISTRY_URL ?? DEFAULT_OFFICIAL_INDEX_URL,
|
|
132
|
+
rawBaseUrl: process.env.COLLECTIONS_REGISTRY_RAW_BASE ?? DEFAULT_OFFICIAL_RAW_BASE
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** The full ordered registry list: official first, then user-configured ones.
|
|
136
|
+
* Re-reads the config file on every call so a Discover refresh picks up edits
|
|
137
|
+
* without a server restart (the config is small + read-rarely). */
|
|
138
|
+
function listRegistries() {
|
|
139
|
+
return [officialDescriptor(), ...loadRegistriesConfig()];
|
|
140
|
+
}
|
|
141
|
+
/** Look up one registry descriptor by name. Used by the preview / import path,
|
|
142
|
+
* which has the registry label from the entry and needs the rawBase. */
|
|
143
|
+
function findRegistry(name) {
|
|
144
|
+
return listRegistries().find((reg) => reg.name === name) ?? null;
|
|
145
|
+
}
|
|
146
|
+
async function loadFromNetwork(descriptor) {
|
|
147
|
+
let res;
|
|
148
|
+
try {
|
|
149
|
+
res = await fetchWithTimeout(descriptor.indexUrl, {
|
|
150
|
+
timeoutMs: FETCH_TIMEOUT_MS$1,
|
|
151
|
+
headers: { accept: "application/json" }
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
log.warn("collections-registry", "index fetch failed", {
|
|
155
|
+
registry: descriptor.name,
|
|
156
|
+
url: descriptor.indexUrl,
|
|
157
|
+
error: errorMessage(err)
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
status: STATUS_UNAVAILABLE$1,
|
|
162
|
+
error: "registry unreachable"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (!res.ok) return {
|
|
166
|
+
ok: false,
|
|
167
|
+
status: STATUS_BAD_GATEWAY$2,
|
|
168
|
+
error: `registry responded ${res.status}`
|
|
169
|
+
};
|
|
170
|
+
const parsed = parseRegistryIndex(await res.json().catch(() => null), descriptor.name);
|
|
171
|
+
if (!parsed.ok) {
|
|
172
|
+
log.warn("collections-registry", "index invalid", {
|
|
173
|
+
registry: descriptor.name,
|
|
174
|
+
url: descriptor.indexUrl,
|
|
175
|
+
error: parsed.error
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
status: STATUS_BAD_GATEWAY$2,
|
|
180
|
+
error: `registry index invalid: ${parsed.error}`
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
index: parsed.index,
|
|
186
|
+
stale: false
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Cache key incorporates BOTH URLs so editing `indexUrl` or `rawBaseUrl` for
|
|
190
|
+
* an existing registry name invalidates the cached index automatically — same
|
|
191
|
+
* refresh cycle as if the user had renamed it (CodeRabbit review on #1837).
|
|
192
|
+
* Without this, the Discover catalog could keep serving the old upstream's
|
|
193
|
+
* entries while preview / import resolved the new rawBase, drifting until TTL
|
|
194
|
+
* expiry. Tab + key-content guarantees no collision between e.g. swapped
|
|
195
|
+
* name/url pairs. */
|
|
196
|
+
function descriptorCacheKey(descriptor) {
|
|
197
|
+
return `${descriptor.name}\t${descriptor.indexUrl}\t${descriptor.rawBaseUrl}`;
|
|
198
|
+
}
|
|
199
|
+
/** Fetch one registry's index. Same cache + stale-on-failure semantics as the
|
|
200
|
+
* original single-registry implementation — just keyed by descriptor identity
|
|
201
|
+
* (name + both URLs) so multiple registries don't fight over one slot AND a
|
|
202
|
+
* reconfigured registry doesn't serve a stale index from the prior URL. */
|
|
203
|
+
async function fetchRegistryIndex(descriptor, opts = {}) {
|
|
204
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
205
|
+
const loader = opts.loader ?? loadFromNetwork;
|
|
206
|
+
const key = descriptorCacheKey(descriptor);
|
|
207
|
+
const cached = cache.get(key);
|
|
208
|
+
if (!opts.force && cached && nowMs - cached.atMs < CACHE_TTL_MS) return {
|
|
209
|
+
ok: true,
|
|
210
|
+
index: cached.index,
|
|
211
|
+
stale: false
|
|
212
|
+
};
|
|
213
|
+
const failedAt = lastFailureMs.get(key);
|
|
214
|
+
if (!opts.force && cached && failedAt !== void 0 && nowMs - failedAt < STALE_RETRY_BACKOFF_MS) return {
|
|
215
|
+
ok: true,
|
|
216
|
+
index: cached.index,
|
|
217
|
+
stale: true
|
|
218
|
+
};
|
|
219
|
+
const fresh = await loader(descriptor);
|
|
220
|
+
if (fresh.ok) {
|
|
221
|
+
cache.set(key, {
|
|
222
|
+
index: fresh.index,
|
|
223
|
+
atMs: nowMs
|
|
224
|
+
});
|
|
225
|
+
lastFailureMs.delete(key);
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
index: fresh.index,
|
|
229
|
+
stale: false
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
lastFailureMs.set(key, nowMs);
|
|
233
|
+
if (cached) return {
|
|
234
|
+
ok: true,
|
|
235
|
+
index: cached.index,
|
|
236
|
+
stale: true
|
|
237
|
+
};
|
|
238
|
+
return fresh;
|
|
239
|
+
}
|
|
240
|
+
/** Fetch every configured registry in parallel and return per-registry
|
|
241
|
+
* outcomes. Callers (the Discover route) concatenate `entries` from each.
|
|
242
|
+
* Failure of any single registry doesn't abort the others — that's the point
|
|
243
|
+
* of supporting multiple registries. */
|
|
244
|
+
async function fetchAllRegistries(opts = {}) {
|
|
245
|
+
const descriptors = listRegistries();
|
|
246
|
+
return await Promise.all(descriptors.map(async (descriptor) => {
|
|
247
|
+
const result = await fetchRegistryIndex(descriptor, opts);
|
|
248
|
+
if (!result.ok) return {
|
|
249
|
+
name: descriptor.name,
|
|
250
|
+
status: "failed",
|
|
251
|
+
generatedAt: null,
|
|
252
|
+
error: result.error,
|
|
253
|
+
entries: []
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
name: descriptor.name,
|
|
257
|
+
status: result.stale ? "stale" : "ok",
|
|
258
|
+
generatedAt: result.index.generatedAt,
|
|
259
|
+
error: null,
|
|
260
|
+
entries: result.index.collections
|
|
261
|
+
};
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
/** Test seam: reset the module cache + failure backoff state (all registries). */
|
|
265
|
+
function resetRegistryCache() {
|
|
266
|
+
cache.clear();
|
|
267
|
+
lastFailureMs.clear();
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/collection/registry/server/collectionFiles.ts
|
|
271
|
+
var FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;
|
|
272
|
+
var STATUS_BAD_GATEWAY$1 = 502;
|
|
273
|
+
var STATUS_UNAVAILABLE = 503;
|
|
274
|
+
var STATUS_NOT_FOUND$2 = 404;
|
|
275
|
+
/** Compose the raw URL for `<dirPath>/<relFile>` under a given registry's
|
|
276
|
+
* rawBase. Empty and traversal (`.`/`..`) segments are dropped so the URL can
|
|
277
|
+
* never escape the base, even if an upstream check is bypassed (the index
|
|
278
|
+
* parser already rejects such identifiers — this is defense-in-depth). The
|
|
279
|
+
* rawBase is trailing-slash-normalized: `parseRegistriesConfig` already trims
|
|
280
|
+
* user-config trailing slashes, but the official descriptor and any test
|
|
281
|
+
* bypass parse — repeating the trim here keeps the join `${base}/${path}`
|
|
282
|
+
* from producing `//` even when the caller bypassed config validation
|
|
283
|
+
* (CodeRabbit review on #1837). */
|
|
284
|
+
function collectionFileUrl(rawBase, dirPath, relFile) {
|
|
285
|
+
let base = rawBase;
|
|
286
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
287
|
+
const segments = dirPath.split("/").filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
288
|
+
return `${base}/${segments.join("/")}/${relFile}`;
|
|
289
|
+
}
|
|
290
|
+
/** Fetch one file out of a registry collection. `rawBase` comes from the
|
|
291
|
+
* entry's source registry (`findRegistry(entry.registryName).rawBaseUrl`),
|
|
292
|
+
* not a module-level setting — that's what makes additional user-configured
|
|
293
|
+
* registries reachable. */
|
|
294
|
+
async function fetchCollectionFile(rawBase, dirPath, relFile) {
|
|
295
|
+
const url = collectionFileUrl(rawBase, dirPath, relFile);
|
|
296
|
+
let res;
|
|
297
|
+
try {
|
|
298
|
+
res = await fetchWithTimeout(url, { timeoutMs: FETCH_TIMEOUT_MS });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
status: STATUS_UNAVAILABLE,
|
|
303
|
+
error: `fetch failed: ${errorMessage(err)}`
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (!res.ok) return {
|
|
307
|
+
ok: false,
|
|
308
|
+
status: STATUS_BAD_GATEWAY$1,
|
|
309
|
+
error: `${relFile} responded ${res.status}`
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
ok: true,
|
|
313
|
+
text: await res.text()
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function parseJsonObject(text, label) {
|
|
317
|
+
let parsed;
|
|
318
|
+
try {
|
|
319
|
+
parsed = JSON.parse(text);
|
|
320
|
+
} catch {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: `${label} is not valid JSON`
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (!isRecord(parsed)) return {
|
|
327
|
+
ok: false,
|
|
328
|
+
error: `${label} is not an object`
|
|
329
|
+
};
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
value: parsed
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
async function fetchJsonObject(rawBase, dirPath, relFile, label) {
|
|
336
|
+
const file = await fetchCollectionFile(rawBase, dirPath, relFile);
|
|
337
|
+
if (!file.ok) return {
|
|
338
|
+
ok: false,
|
|
339
|
+
status: file.status,
|
|
340
|
+
error: `${label}: ${file.error}`
|
|
341
|
+
};
|
|
342
|
+
const parsed = parseJsonObject(file.text, label);
|
|
343
|
+
if (!parsed.ok) return {
|
|
344
|
+
ok: false,
|
|
345
|
+
status: STATUS_BAD_GATEWAY$1,
|
|
346
|
+
error: parsed.error
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
value: parsed.value
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** Resolve an entry's rawBase from its `registryName`. A missing match (the
|
|
354
|
+
* user removed the registry from config while a cached index still references
|
|
355
|
+
* it) returns null — the caller surfaces it as a 404 rather than crashing. */
|
|
356
|
+
function rawBaseForEntry(entry) {
|
|
357
|
+
return findRegistry(entry.registryName)?.rawBaseUrl ?? null;
|
|
358
|
+
}
|
|
359
|
+
/** Resolve an entry by author+slug across every cached registry's entries.
|
|
360
|
+
* When `registry` is passed we constrain to that registry — needed because
|
|
361
|
+
* multiple registries can publish the same author/slug, and the UI should
|
|
362
|
+
* follow the card it just clicked. */
|
|
363
|
+
function findEntryInMergedView(merged, author, slug, registry) {
|
|
364
|
+
for (const reg of merged) {
|
|
365
|
+
if (registry !== null && reg.name !== registry) continue;
|
|
366
|
+
const match = reg.entries.find((candidate) => candidate.author === author && candidate.slug === slug);
|
|
367
|
+
if (match) return match;
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
/** Preview a registry collection: confirm it's in some registry's published
|
|
372
|
+
* index, then fetch + parse its schema.json + meta.json so the Discover tab
|
|
373
|
+
* can show fields/views before import. With multi-registry support the
|
|
374
|
+
* `registry` arg disambiguates same-name collections from different sources. */
|
|
375
|
+
async function previewCollection(author, slug, registry = null) {
|
|
376
|
+
const entry = findEntryInMergedView(await fetchAllRegistries(), author, slug, registry);
|
|
377
|
+
if (!entry) return {
|
|
378
|
+
ok: false,
|
|
379
|
+
status: STATUS_NOT_FOUND$2,
|
|
380
|
+
error: `unknown collection: ${author}/${slug}`
|
|
381
|
+
};
|
|
382
|
+
const rawBase = rawBaseForEntry(entry);
|
|
383
|
+
if (!rawBase) return {
|
|
384
|
+
ok: false,
|
|
385
|
+
status: STATUS_NOT_FOUND$2,
|
|
386
|
+
error: `registry "${entry.registryName}" is no longer configured`
|
|
387
|
+
};
|
|
388
|
+
const schema = await fetchJsonObject(rawBase, entry.path, "schema.json", "schema.json");
|
|
389
|
+
if (!schema.ok) return schema;
|
|
390
|
+
const meta = await fetchJsonObject(rawBase, entry.path, "meta.json", "meta.json");
|
|
391
|
+
if (!meta.ok) return meta;
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
entry,
|
|
395
|
+
schema: schema.value,
|
|
396
|
+
meta: meta.value
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/collection/registry/server/importCollection.ts
|
|
401
|
+
var MANIFEST_FILE = "manifest.json";
|
|
402
|
+
var STATUS_BAD_GATEWAY = 502;
|
|
403
|
+
/** A manifest entry must be a relative path that stays inside the collection dir:
|
|
404
|
+
* no absolute paths, no backslashes, no empty / `.` / `..` segments. */
|
|
405
|
+
function isSafeBundlePath(rel) {
|
|
406
|
+
if (typeof rel !== "string" || rel.length === 0) return false;
|
|
407
|
+
if (rel.startsWith("/") || rel.includes("\\")) return false;
|
|
408
|
+
return !rel.split("/").some((segment) => segment === "" || segment === "." || segment === "..");
|
|
409
|
+
}
|
|
410
|
+
function parseManifest(value) {
|
|
411
|
+
if (!isRecord(value) || !Array.isArray(value.files)) return {
|
|
412
|
+
ok: false,
|
|
413
|
+
error: "manifest is missing a files[] array"
|
|
414
|
+
};
|
|
415
|
+
const unsafe = value.files.find((file) => !isSafeBundlePath(file));
|
|
416
|
+
if (unsafe !== void 0) return {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: `manifest contains an unsafe path: ${String(unsafe)}`
|
|
419
|
+
};
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
files: value.files.filter(isSafeBundlePath)
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** `data/collections/<localSlug>/items` — the host owns dataPath, never the
|
|
426
|
+
* registry's authored value, so imported collections can't collide on disk. */
|
|
427
|
+
function normalizedDataPath(localSlug) {
|
|
428
|
+
return `data/collections/${localSlug}/items`;
|
|
429
|
+
}
|
|
430
|
+
function withNormalizedDataPath(schema, localSlug) {
|
|
431
|
+
return {
|
|
432
|
+
...schema,
|
|
433
|
+
dataPath: normalizedDataPath(localSlug)
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
async function fetchManifest(entry) {
|
|
437
|
+
const rawBase = rawBaseForEntry(entry);
|
|
438
|
+
if (!rawBase) return {
|
|
439
|
+
ok: false,
|
|
440
|
+
status: STATUS_BAD_GATEWAY,
|
|
441
|
+
error: `registry "${entry.registryName}" is no longer configured`
|
|
442
|
+
};
|
|
443
|
+
const file = await fetchCollectionFile(rawBase, entry.path, MANIFEST_FILE);
|
|
444
|
+
if (!file.ok) return {
|
|
445
|
+
ok: false,
|
|
446
|
+
status: file.status,
|
|
447
|
+
error: `manifest.json: ${file.error}`
|
|
448
|
+
};
|
|
449
|
+
const obj = parseJsonObject(file.text, "manifest.json");
|
|
450
|
+
if (!obj.ok) return {
|
|
451
|
+
ok: false,
|
|
452
|
+
status: STATUS_BAD_GATEWAY,
|
|
453
|
+
error: obj.error
|
|
454
|
+
};
|
|
455
|
+
const manifest = parseManifest(obj.value);
|
|
456
|
+
if (!manifest.ok) return {
|
|
457
|
+
ok: false,
|
|
458
|
+
status: STATUS_BAD_GATEWAY,
|
|
459
|
+
error: manifest.error
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
ok: true,
|
|
463
|
+
files: manifest.files
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** Fetch every manifest file. Paths are already safety-checked by parseManifest. */
|
|
467
|
+
async function fetchBundle(entry, fileList) {
|
|
468
|
+
const rawBase = rawBaseForEntry(entry);
|
|
469
|
+
if (!rawBase) return {
|
|
470
|
+
ok: false,
|
|
471
|
+
status: STATUS_BAD_GATEWAY,
|
|
472
|
+
error: `registry "${entry.registryName}" is no longer configured`
|
|
473
|
+
};
|
|
474
|
+
const files = /* @__PURE__ */ new Map();
|
|
475
|
+
for (const rel of fileList) {
|
|
476
|
+
const file = await fetchCollectionFile(rawBase, entry.path, rel);
|
|
477
|
+
if (!file.ok) return {
|
|
478
|
+
ok: false,
|
|
479
|
+
status: file.status,
|
|
480
|
+
error: `${rel}: ${file.error}`
|
|
481
|
+
};
|
|
482
|
+
files.set(rel, file.text);
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
ok: true,
|
|
486
|
+
files
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/collection/registry/server/importWriter.ts
|
|
491
|
+
var ORIGIN_FILE = ".origin.json";
|
|
492
|
+
var SEED_PREFIX = "seed/items/";
|
|
493
|
+
var SCHEMA_FILE = "schema.json";
|
|
494
|
+
var SKILL_FILE = "SKILL.md";
|
|
495
|
+
var STATUS_NOT_FOUND$1 = 404;
|
|
496
|
+
var STATUS_CONFLICT = 409;
|
|
497
|
+
var STATUS_UNPROCESSABLE = 422;
|
|
498
|
+
async function statType(target) {
|
|
499
|
+
try {
|
|
500
|
+
return (await stat(target)).isDirectory() ? "dir" : "other";
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (isRecord(err) && err.code === "ENOENT") return "absent";
|
|
503
|
+
return "other";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function isEmptyOrAbsentDir(target) {
|
|
507
|
+
try {
|
|
508
|
+
return (await readdir(target)).length === 0;
|
|
509
|
+
} catch {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function originMatches(origin, registry, author, slug) {
|
|
514
|
+
return isRecord(origin) && origin.registry === registry && origin.author === author && origin.slug === slug;
|
|
515
|
+
}
|
|
516
|
+
async function readOrigin(targetDir) {
|
|
517
|
+
try {
|
|
518
|
+
return JSON.parse(await readFile(path.join(targetDir, ORIGIN_FILE), "utf-8"));
|
|
519
|
+
} catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
var MAX_SLUG_ATTEMPTS = 1e4;
|
|
524
|
+
var renameCandidate = (slug, attempt) => attempt === 0 ? slug : `${slug}-${attempt + 1}`;
|
|
525
|
+
function isRenameOf(name, slug) {
|
|
526
|
+
if (name === slug) return true;
|
|
527
|
+
if (!name.startsWith(`${slug}-`)) return false;
|
|
528
|
+
const suffix = name.slice(slug.length + 1);
|
|
529
|
+
return suffix.length > 0 && /^\d+$/.test(suffix);
|
|
530
|
+
}
|
|
531
|
+
async function findMatchingInstall(skillsDir, registry, entry) {
|
|
532
|
+
const names = await readdir(skillsDir).catch(() => []);
|
|
533
|
+
for (const name of names) {
|
|
534
|
+
if (!isRenameOf(name, entry.slug)) continue;
|
|
535
|
+
const dir = path.join(skillsDir, name);
|
|
536
|
+
if (await statType(dir) === "dir" && originMatches(await readOrigin(dir), registry, entry.author, entry.slug)) return name;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
async function resolveTarget(workspaceRoot, registry, entry) {
|
|
541
|
+
const existing = await findMatchingInstall(path.dirname(dataSkillDir(workspaceRoot, entry.slug)), registry, entry);
|
|
542
|
+
if (existing) return {
|
|
543
|
+
targetDir: dataSkillDir(workspaceRoot, existing),
|
|
544
|
+
localSlug: existing,
|
|
545
|
+
updated: true
|
|
546
|
+
};
|
|
547
|
+
for (let attempt = 0; attempt < MAX_SLUG_ATTEMPTS; attempt += 1) {
|
|
548
|
+
const localSlug = renameCandidate(entry.slug, attempt);
|
|
549
|
+
const sourceAbsent = await statType(dataSkillDir(workspaceRoot, localSlug)) === "absent";
|
|
550
|
+
const mirrorAbsent = await statType(claudeSkillDir(workspaceRoot, localSlug)) === "absent";
|
|
551
|
+
if (sourceAbsent && mirrorAbsent) return {
|
|
552
|
+
targetDir: dataSkillDir(workspaceRoot, localSlug),
|
|
553
|
+
localSlug,
|
|
554
|
+
updated: false
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return { conflict: `couldn't find an available slug for '${entry.slug}'` };
|
|
558
|
+
}
|
|
559
|
+
function validateAndNormalize(bundle, localSlug, workspaceRoot) {
|
|
560
|
+
if (bundle.get(SKILL_FILE) === void 0) return { error: "bundle is missing SKILL.md" };
|
|
561
|
+
const schemaText = bundle.get(SCHEMA_FILE);
|
|
562
|
+
if (schemaText === void 0) return { error: "bundle is missing schema.json" };
|
|
563
|
+
let parsedJson;
|
|
564
|
+
try {
|
|
565
|
+
parsedJson = JSON.parse(schemaText);
|
|
566
|
+
} catch {
|
|
567
|
+
return { error: "schema.json is not valid JSON" };
|
|
568
|
+
}
|
|
569
|
+
const parsed = CollectionSchemaZ.safeParse(parsedJson);
|
|
570
|
+
if (!parsed.success) return { error: `schema.json failed validation: ${parsed.error.issues[0]?.message ?? "invalid"}` };
|
|
571
|
+
const schema = {
|
|
572
|
+
...parsed.data,
|
|
573
|
+
dataPath: normalizedDataPath(localSlug)
|
|
574
|
+
};
|
|
575
|
+
const acceptance = acceptParsedSchema(schema, {
|
|
576
|
+
source: "project",
|
|
577
|
+
workspaceRoot
|
|
578
|
+
});
|
|
579
|
+
if (!acceptance.ok) return { error: `schema.json rejected: ${acceptance.reason}` };
|
|
580
|
+
return { schema };
|
|
581
|
+
}
|
|
582
|
+
async function writeBundleFiles(targetDir, bundle, schema) {
|
|
583
|
+
for (const [rel, content] of bundle) {
|
|
584
|
+
if (rel.startsWith(SEED_PREFIX)) continue;
|
|
585
|
+
const dest = path.join(targetDir, ...rel.split("/"));
|
|
586
|
+
if (dest !== targetDir && !dest.startsWith(targetDir + path.sep)) continue;
|
|
587
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
588
|
+
await writeFile(dest, rel === SCHEMA_FILE ? `${JSON.stringify(schema, null, 2)}\n` : content, "utf-8");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async function materializeSeed(dataDir, bundle) {
|
|
592
|
+
const seedEntries = [...bundle].filter(([rel]) => rel.startsWith(SEED_PREFIX));
|
|
593
|
+
if (seedEntries.length === 0) return {
|
|
594
|
+
written: 0,
|
|
595
|
+
skipped: false
|
|
596
|
+
};
|
|
597
|
+
if (!await isEmptyOrAbsentDir(dataDir)) return {
|
|
598
|
+
written: 0,
|
|
599
|
+
skipped: true
|
|
600
|
+
};
|
|
601
|
+
await mkdir(dataDir, { recursive: true });
|
|
602
|
+
let written = 0;
|
|
603
|
+
for (const [rel, content] of seedEntries) {
|
|
604
|
+
const fileName = rel.slice(11);
|
|
605
|
+
if (fileName.includes("/") || safeRecordId(fileName.replace(/\.json$/, "")) === null) {
|
|
606
|
+
log.warn("collections-registry", "skipped unsafe seed record", { rel });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
await writeFile(path.join(dataDir, fileName), content, "utf-8");
|
|
610
|
+
written += 1;
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
written,
|
|
614
|
+
skipped: false
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/** Compute the set of bundle-relative paths that should land in `.claude/skills/<slug>/`.
|
|
618
|
+
* Matches the bridge allowlist exactly — SKILL.md, schema.json,
|
|
619
|
+
* `templates/<safe>` — so anything else (seed, meta.json, views, README,
|
|
620
|
+
* assets) stays source-side. */
|
|
621
|
+
function bridgeAllowlistFiles(bundle) {
|
|
622
|
+
const wanted = /* @__PURE__ */ new Set([SKILL_FILE, SCHEMA_FILE]);
|
|
623
|
+
for (const rel of bundle.keys()) {
|
|
624
|
+
if (rel === SKILL_FILE || rel === SCHEMA_FILE) continue;
|
|
625
|
+
if (rel.startsWith(SEED_PREFIX)) continue;
|
|
626
|
+
if (!isSafeActionTemplatePath(rel)) continue;
|
|
627
|
+
wanted.add(rel);
|
|
628
|
+
}
|
|
629
|
+
return wanted;
|
|
630
|
+
}
|
|
631
|
+
/** Recursively list every file under `root`, returned as forward-slash paths
|
|
632
|
+
* relative to `root`. Empty list when `root` doesn't exist. Used to spot
|
|
633
|
+
* mirror files left over from a previous install so we can prune them after
|
|
634
|
+
* writing the new ones. */
|
|
635
|
+
async function listFilesRecursive(root, prefix = "") {
|
|
636
|
+
const entries = await readdir(prefix ? path.join(root, ...prefix.split("/")) : root, { withFileTypes: true }).catch(() => []);
|
|
637
|
+
const out = [];
|
|
638
|
+
for (const entry of entries) {
|
|
639
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
640
|
+
if (entry.isFile()) out.push(rel);
|
|
641
|
+
else if (entry.isDirectory()) out.push(...await listFilesRecursive(root, rel));
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
async function mirrorToClaudeSkills(workspaceRoot, localSlug, bundle) {
|
|
646
|
+
const wanted = bridgeAllowlistFiles(bundle);
|
|
647
|
+
for (const rel of wanted) mirrorSkillWrite(workspaceRoot, {
|
|
648
|
+
slug: localSlug,
|
|
649
|
+
relSegments: rel.split("/")
|
|
650
|
+
});
|
|
651
|
+
const mirrorRoot = claudeSkillDir(workspaceRoot, localSlug);
|
|
652
|
+
const existing = await listFilesRecursive(mirrorRoot);
|
|
653
|
+
for (const rel of existing) {
|
|
654
|
+
if (wanted.has(rel)) continue;
|
|
655
|
+
await rm(path.join(mirrorRoot, ...rel.split("/")), { force: true }).catch(() => void 0);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async function writeImportedCollection(params) {
|
|
659
|
+
const { registry, entry, bundle, workspaceRoot, nowIso } = params;
|
|
660
|
+
const target = await resolveTarget(workspaceRoot, registry, entry);
|
|
661
|
+
if ("conflict" in target) return {
|
|
662
|
+
ok: false,
|
|
663
|
+
status: STATUS_CONFLICT,
|
|
664
|
+
error: target.conflict
|
|
665
|
+
};
|
|
666
|
+
const { localSlug } = target;
|
|
667
|
+
const dataDir = path.join(workspaceRoot, ...normalizedDataPath(localSlug).split("/"));
|
|
668
|
+
if (await statType(dataDir) === "other") return {
|
|
669
|
+
ok: false,
|
|
670
|
+
status: STATUS_CONFLICT,
|
|
671
|
+
error: `data path for slug '${localSlug}' exists and is not a directory`
|
|
672
|
+
};
|
|
673
|
+
const validated = validateAndNormalize(bundle, localSlug, workspaceRoot);
|
|
674
|
+
if ("error" in validated) return {
|
|
675
|
+
ok: false,
|
|
676
|
+
status: STATUS_UNPROCESSABLE,
|
|
677
|
+
error: validated.error
|
|
678
|
+
};
|
|
679
|
+
const skillsParent = path.dirname(target.targetDir);
|
|
680
|
+
await mkdir(skillsParent, { recursive: true });
|
|
681
|
+
const staging = path.join(skillsParent, `.importing-${localSlug}`);
|
|
682
|
+
const backup = path.join(skillsParent, `.backup-${localSlug}`);
|
|
683
|
+
await rm(staging, {
|
|
684
|
+
recursive: true,
|
|
685
|
+
force: true
|
|
686
|
+
});
|
|
687
|
+
await rm(backup, {
|
|
688
|
+
recursive: true,
|
|
689
|
+
force: true
|
|
690
|
+
});
|
|
691
|
+
await mkdir(staging, { recursive: true });
|
|
692
|
+
await writeBundleFiles(staging, bundle, validated.schema);
|
|
693
|
+
const origin = {
|
|
694
|
+
registry,
|
|
695
|
+
author: entry.author,
|
|
696
|
+
slug: entry.slug,
|
|
697
|
+
version: entry.version,
|
|
698
|
+
contentSha: entry.contentSha,
|
|
699
|
+
importedAt: nowIso
|
|
700
|
+
};
|
|
701
|
+
await writeFileAtomic(path.join(staging, ORIGIN_FILE), `${JSON.stringify(origin, null, 2)}\n`);
|
|
702
|
+
if (target.updated) await rename(target.targetDir, backup);
|
|
703
|
+
try {
|
|
704
|
+
await rename(staging, target.targetDir);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
if (target.updated) await rename(backup, target.targetDir).catch(() => void 0);
|
|
707
|
+
throw err;
|
|
708
|
+
}
|
|
709
|
+
await rm(backup, {
|
|
710
|
+
recursive: true,
|
|
711
|
+
force: true
|
|
712
|
+
});
|
|
713
|
+
try {
|
|
714
|
+
await mirrorToClaudeSkills(workspaceRoot, localSlug, bundle);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
log.warn("collections-registry", "mirror to .claude/skills/ failed (data/skills write succeeded; prior mirror left intact)", {
|
|
717
|
+
localSlug,
|
|
718
|
+
error: errorMessage(err)
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
const seed = await materializeSeed(dataDir, bundle);
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
localSlug,
|
|
725
|
+
updated: target.updated,
|
|
726
|
+
seedWritten: seed.written,
|
|
727
|
+
seedSkipped: seed.skipped
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
async function performImport(author, slug, workspaceRoot, registry = null) {
|
|
731
|
+
const merged = await fetchAllRegistries();
|
|
732
|
+
let entry;
|
|
733
|
+
for (const reg of merged) {
|
|
734
|
+
if (registry !== null && reg.name !== registry) continue;
|
|
735
|
+
entry = reg.entries.find((candidate) => candidate.author === author && candidate.slug === slug);
|
|
736
|
+
if (entry) break;
|
|
737
|
+
}
|
|
738
|
+
if (!entry) return {
|
|
739
|
+
ok: false,
|
|
740
|
+
status: STATUS_NOT_FOUND$1,
|
|
741
|
+
error: `unknown collection: ${author}/${slug}`
|
|
742
|
+
};
|
|
743
|
+
const manifest = await fetchManifest(entry);
|
|
744
|
+
if (!manifest.ok) return {
|
|
745
|
+
ok: false,
|
|
746
|
+
status: manifest.status,
|
|
747
|
+
error: manifest.error
|
|
748
|
+
};
|
|
749
|
+
const bundle = await fetchBundle(entry, manifest.files);
|
|
750
|
+
if (!bundle.ok) return {
|
|
751
|
+
ok: false,
|
|
752
|
+
status: bundle.status,
|
|
753
|
+
error: bundle.error
|
|
754
|
+
};
|
|
755
|
+
try {
|
|
756
|
+
return await writeImportedCollection({
|
|
757
|
+
registry: entry.registryName,
|
|
758
|
+
entry,
|
|
759
|
+
bundle: bundle.files,
|
|
760
|
+
workspaceRoot,
|
|
761
|
+
nowIso: (/* @__PURE__ */ new Date()).toISOString()
|
|
762
|
+
});
|
|
763
|
+
} catch (err) {
|
|
764
|
+
log.warn("collections-registry", "import write failed", {
|
|
765
|
+
author,
|
|
766
|
+
slug,
|
|
767
|
+
registry: entry.registryName,
|
|
768
|
+
error: errorMessage(err)
|
|
769
|
+
});
|
|
770
|
+
return {
|
|
771
|
+
ok: false,
|
|
772
|
+
status: 500,
|
|
773
|
+
error: `import failed: ${errorMessage(err)}`
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/collection/registry/server/exportCollection.ts
|
|
779
|
+
var EXPORT_BASE = "data/registry-export";
|
|
780
|
+
var BUNDLE_FILES = ["SKILL.md", "schema.json"];
|
|
781
|
+
var OPTIONAL_FILES = ["screenshot.png"];
|
|
782
|
+
var BUNDLE_DIRS = ["views", "templates"];
|
|
783
|
+
var AUTHOR_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;
|
|
784
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
785
|
+
var STATUS_BAD_REQUEST = 400;
|
|
786
|
+
var SECRET_PATTERNS = [
|
|
787
|
+
/AKIA[0-9A-Z]{16}/,
|
|
788
|
+
/gh[pousr]_[A-Za-z0-9]{20,}/,
|
|
789
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
790
|
+
/sk-[A-Za-z0-9]{20,}/,
|
|
791
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/
|
|
792
|
+
];
|
|
793
|
+
var hasSecret = (text) => SECRET_PATTERNS.some((pattern) => pattern.test(text));
|
|
794
|
+
var EMAIL_RE = /[\w.%+-]{1,64}@[\w-]{1,255}\.[a-z]{2,24}/i;
|
|
795
|
+
async function pathKind(target) {
|
|
796
|
+
try {
|
|
797
|
+
return (await stat(target)).isDirectory() ? "dir" : "file";
|
|
798
|
+
} catch {
|
|
799
|
+
return "absent";
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
async function copyBundle(skillDir, outDir) {
|
|
803
|
+
let count = 0;
|
|
804
|
+
for (const file of [...BUNDLE_FILES, ...OPTIONAL_FILES]) {
|
|
805
|
+
const src = path.join(skillDir, file);
|
|
806
|
+
if (await pathKind(src) !== "file") continue;
|
|
807
|
+
await cp(src, path.join(outDir, file));
|
|
808
|
+
count += 1;
|
|
809
|
+
}
|
|
810
|
+
for (const dir of BUNDLE_DIRS) {
|
|
811
|
+
const src = path.join(skillDir, dir);
|
|
812
|
+
if (await pathKind(src) !== "dir") continue;
|
|
813
|
+
await cp(src, path.join(outDir, dir), { recursive: true });
|
|
814
|
+
count += (await readdir(src)).filter((name) => !name.startsWith(".")).length;
|
|
815
|
+
}
|
|
816
|
+
return count;
|
|
817
|
+
}
|
|
818
|
+
async function exportSeed(dataDir, outDir) {
|
|
819
|
+
let names;
|
|
820
|
+
try {
|
|
821
|
+
names = (await readdir(dataDir)).filter((name) => name.endsWith(".json"));
|
|
822
|
+
} catch {
|
|
823
|
+
return {
|
|
824
|
+
count: 0,
|
|
825
|
+
skipped: 0,
|
|
826
|
+
warnings: []
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (names.length === 0) return {
|
|
830
|
+
count: 0,
|
|
831
|
+
skipped: 0,
|
|
832
|
+
warnings: []
|
|
833
|
+
};
|
|
834
|
+
const seedDir = path.join(outDir, "seed", "items");
|
|
835
|
+
await mkdir(seedDir, { recursive: true });
|
|
836
|
+
const warnings = [];
|
|
837
|
+
let count = 0;
|
|
838
|
+
let skipped = 0;
|
|
839
|
+
for (const name of names) {
|
|
840
|
+
const base = path.basename(name);
|
|
841
|
+
const text = await readFile(path.join(dataDir, base), "utf-8");
|
|
842
|
+
if (hasSecret(text)) {
|
|
843
|
+
warnings.push(`skipped ${base}: contains a possible credential`);
|
|
844
|
+
skipped += 1;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (EMAIL_RE.test(text)) warnings.push(`${base}: contains a possible email/PII (kept — your responsibility)`);
|
|
848
|
+
await writeFile(path.join(seedDir, base), text, "utf-8");
|
|
849
|
+
count += 1;
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
count,
|
|
853
|
+
skipped,
|
|
854
|
+
warnings
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
function resolveWithin(root, ...segments) {
|
|
858
|
+
const resolvedRoot = path.resolve(root);
|
|
859
|
+
const target = path.resolve(resolvedRoot, ...segments);
|
|
860
|
+
return target === resolvedRoot || target.startsWith(resolvedRoot + path.sep) ? target : null;
|
|
861
|
+
}
|
|
862
|
+
async function findMissingRequired(skillDir) {
|
|
863
|
+
for (const file of BUNDLE_FILES) if (await pathKind(path.join(skillDir, file)) !== "file") return file;
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
async function writeCollectionExport(params) {
|
|
867
|
+
const { workspaceRoot, skillDir, dataDir, meta, includeSeed } = params;
|
|
868
|
+
if (!AUTHOR_RE.test(meta.author)) return {
|
|
869
|
+
ok: false,
|
|
870
|
+
status: STATUS_BAD_REQUEST,
|
|
871
|
+
error: `author '${meta.author}' is not a valid GitHub login`
|
|
872
|
+
};
|
|
873
|
+
if (!SLUG_RE.test(meta.slug)) return {
|
|
874
|
+
ok: false,
|
|
875
|
+
status: STATUS_BAD_REQUEST,
|
|
876
|
+
error: `slug '${meta.slug}' is invalid`
|
|
877
|
+
};
|
|
878
|
+
const wsRoot = path.resolve(workspaceRoot);
|
|
879
|
+
const outDir = resolveWithin(path.join(wsRoot, ...EXPORT_BASE.split("/")), meta.author, meta.slug);
|
|
880
|
+
const safeSkillDir = resolveWithin(wsRoot, skillDir);
|
|
881
|
+
const safeDataDir = resolveWithin(wsRoot, dataDir);
|
|
882
|
+
if (!outDir || !safeSkillDir || !safeDataDir) return {
|
|
883
|
+
ok: false,
|
|
884
|
+
status: STATUS_BAD_REQUEST,
|
|
885
|
+
error: "resolved path escapes the workspace"
|
|
886
|
+
};
|
|
887
|
+
const missing = await findMissingRequired(safeSkillDir);
|
|
888
|
+
if (missing) return {
|
|
889
|
+
ok: false,
|
|
890
|
+
status: STATUS_BAD_REQUEST,
|
|
891
|
+
error: `required bundle file missing: ${missing}`
|
|
892
|
+
};
|
|
893
|
+
await rm(outDir, {
|
|
894
|
+
recursive: true,
|
|
895
|
+
force: true
|
|
896
|
+
});
|
|
897
|
+
await mkdir(outDir, { recursive: true });
|
|
898
|
+
const bundleCount = await copyBundle(safeSkillDir, outDir);
|
|
899
|
+
const seed = includeSeed ? await exportSeed(safeDataDir, outDir) : {
|
|
900
|
+
count: 0,
|
|
901
|
+
skipped: 0,
|
|
902
|
+
warnings: []
|
|
903
|
+
};
|
|
904
|
+
const metaOut = {
|
|
905
|
+
...meta,
|
|
906
|
+
...seed.count > 0 ? { dataConsent: true } : {}
|
|
907
|
+
};
|
|
908
|
+
await writeFile(path.join(outDir, "meta.json"), `${JSON.stringify(metaOut, null, 2)}\n`, "utf-8");
|
|
909
|
+
log.info("collections-registry", "exported collection", {
|
|
910
|
+
slug: meta.slug,
|
|
911
|
+
author: meta.author,
|
|
912
|
+
seedCount: seed.count
|
|
913
|
+
});
|
|
914
|
+
return {
|
|
915
|
+
ok: true,
|
|
916
|
+
outputPath: path.relative(wsRoot, outDir).split(path.sep).join("/"),
|
|
917
|
+
fileCount: bundleCount + 1,
|
|
918
|
+
seedCount: seed.count,
|
|
919
|
+
seedSkipped: seed.skipped,
|
|
920
|
+
warnings: seed.warnings
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/collection/registry/server/skillDescription.ts
|
|
925
|
+
var FENCE = "---";
|
|
926
|
+
var KEY = "description:";
|
|
927
|
+
var BLOCK_SCALAR_INDICATORS = /* @__PURE__ */ new Set([
|
|
928
|
+
"|",
|
|
929
|
+
">",
|
|
930
|
+
"|-",
|
|
931
|
+
">-",
|
|
932
|
+
"|+",
|
|
933
|
+
">+"
|
|
934
|
+
]);
|
|
935
|
+
var isYamlSpace = (char) => char === " " || char === " ";
|
|
936
|
+
var isOnlyTrailingComment = (rest) => {
|
|
937
|
+
const trimmed = rest.trimStart();
|
|
938
|
+
return trimmed === "" || trimmed.startsWith("#");
|
|
939
|
+
};
|
|
940
|
+
var DOUBLE_QUOTE_ESCAPES = {
|
|
941
|
+
n: "\n",
|
|
942
|
+
t: " "
|
|
943
|
+
};
|
|
944
|
+
var unescapeDoubleQuote = (next) => DOUBLE_QUOTE_ESCAPES[next] ?? next;
|
|
945
|
+
function parseDoubleQuoted(value) {
|
|
946
|
+
const out = [];
|
|
947
|
+
for (let i = 1; i < value.length; i += 1) {
|
|
948
|
+
const char = value[i];
|
|
949
|
+
if (char === "\\" && i + 1 < value.length) {
|
|
950
|
+
out.push(unescapeDoubleQuote(value[i + 1]));
|
|
951
|
+
i += 1;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (char === "\"") return isOnlyTrailingComment(value.slice(i + 1)) ? out.join("") : null;
|
|
955
|
+
out.push(char);
|
|
956
|
+
}
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
function parseSingleQuoted(value) {
|
|
960
|
+
const out = [];
|
|
961
|
+
for (let i = 1; i < value.length; i += 1) {
|
|
962
|
+
const char = value[i];
|
|
963
|
+
if (char === "'") {
|
|
964
|
+
if (value[i + 1] === "'") {
|
|
965
|
+
out.push("'");
|
|
966
|
+
i += 1;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
return isOnlyTrailingComment(value.slice(i + 1)) ? out.join("") : null;
|
|
970
|
+
}
|
|
971
|
+
out.push(char);
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
function stripPlainComment(value) {
|
|
976
|
+
for (let i = 0; i < value.length; i += 1) if (value[i] === "#" && (i === 0 || isYamlSpace(value[i - 1]))) return value.slice(0, i);
|
|
977
|
+
return value;
|
|
978
|
+
}
|
|
979
|
+
/** Extract the frontmatter `description` from raw SKILL.md text. Returns "" when
|
|
980
|
+
* there's no `---` envelope, no `description:` key, or the value is a block
|
|
981
|
+
* scalar indicator. */
|
|
982
|
+
function parseSkillDescription(raw) {
|
|
983
|
+
const lines = raw.split(/\r?\n/);
|
|
984
|
+
if (lines[0]?.trim() !== FENCE) return "";
|
|
985
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
986
|
+
const line = lines[i];
|
|
987
|
+
if (line.trim() === FENCE) return "";
|
|
988
|
+
if (!line.startsWith(KEY)) continue;
|
|
989
|
+
const value = line.slice(12).trim();
|
|
990
|
+
if (value === "" || BLOCK_SCALAR_INDICATORS.has(value)) return "";
|
|
991
|
+
if (value.startsWith("\"")) return parseDoubleQuoted(value) ?? "";
|
|
992
|
+
if (value.startsWith("'")) return parseSingleQuoted(value) ?? "";
|
|
993
|
+
return stripPlainComment(value).trim();
|
|
994
|
+
}
|
|
995
|
+
return "";
|
|
996
|
+
}
|
|
997
|
+
//#endregion
|
|
998
|
+
//#region src/collection/registry/server/performExport.ts
|
|
999
|
+
var STATUS_NOT_FOUND = 404;
|
|
1000
|
+
async function readJsonObject(file) {
|
|
1001
|
+
try {
|
|
1002
|
+
const parsed = JSON.parse(await readFile(file, "utf-8"));
|
|
1003
|
+
return isRecord(parsed) ? parsed : null;
|
|
1004
|
+
} catch {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async function performExport(slug, opts, workspaceRoot) {
|
|
1009
|
+
const collection = await loadCollection(slug);
|
|
1010
|
+
if (!collection) return {
|
|
1011
|
+
ok: false,
|
|
1012
|
+
status: STATUS_NOT_FOUND,
|
|
1013
|
+
error: `collection not found: ${slug}`
|
|
1014
|
+
};
|
|
1015
|
+
const skillMd = await readFile(path.join(collection.skillDir, "SKILL.md"), "utf-8").catch(() => "");
|
|
1016
|
+
const existingMeta = await readJsonObject(path.join(collection.skillDir, "meta.json"));
|
|
1017
|
+
const meta = {
|
|
1018
|
+
author: opts.author,
|
|
1019
|
+
slug,
|
|
1020
|
+
version: typeof existingMeta?.version === "string" ? existingMeta.version : "1.0.0",
|
|
1021
|
+
title: collection.schema.title,
|
|
1022
|
+
description: parseSkillDescription(skillMd),
|
|
1023
|
+
tags: [],
|
|
1024
|
+
license: opts.license
|
|
1025
|
+
};
|
|
1026
|
+
return writeCollectionExport({
|
|
1027
|
+
workspaceRoot,
|
|
1028
|
+
skillDir: collection.skillDir,
|
|
1029
|
+
dataDir: collection.dataDir,
|
|
1030
|
+
meta,
|
|
1031
|
+
includeSeed: opts.includeSeed
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/collection/registry/server/index.ts
|
|
1036
|
+
/** Build the merged Discover catalog response (`GET …collectionsRegistry.list`).
|
|
1037
|
+
* Wraps `fetchAllRegistries` with the per-registry summary + stale-flag shaping
|
|
1038
|
+
* every host needs, so a downstream host doesn't re-implement the mapping. */
|
|
1039
|
+
async function listRegistry() {
|
|
1040
|
+
const merged = await fetchAllRegistries();
|
|
1041
|
+
return {
|
|
1042
|
+
registries: merged.map((reg) => ({
|
|
1043
|
+
name: reg.name,
|
|
1044
|
+
status: reg.status,
|
|
1045
|
+
generatedAt: reg.generatedAt,
|
|
1046
|
+
error: reg.error,
|
|
1047
|
+
entryCount: reg.entries.length
|
|
1048
|
+
})),
|
|
1049
|
+
stale: merged.some((reg) => reg.status === "stale"),
|
|
1050
|
+
collections: merged.flatMap((reg) => reg.entries)
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/** Import a registry collection and return the host-facing response shape
|
|
1054
|
+
* (`POST …collectionsRegistry.import`). On failure carries the HTTP status so
|
|
1055
|
+
* the host route can pass it straight through. */
|
|
1056
|
+
async function importRegistry(author, slug, workspaceRoot, registry = null) {
|
|
1057
|
+
const result = await performImport(author, slug, workspaceRoot, registry);
|
|
1058
|
+
if (!result.ok) return {
|
|
1059
|
+
ok: false,
|
|
1060
|
+
status: result.status,
|
|
1061
|
+
error: result.error
|
|
1062
|
+
};
|
|
1063
|
+
return {
|
|
1064
|
+
ok: true,
|
|
1065
|
+
response: {
|
|
1066
|
+
localSlug: result.localSlug,
|
|
1067
|
+
updated: result.updated,
|
|
1068
|
+
seedWritten: result.seedWritten,
|
|
1069
|
+
seedSkipped: result.seedSkipped
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
//#endregion
|
|
1074
|
+
export { CACHE_TTL_MS, EXPORT_BASE, OFFICIAL_REGISTRY_NAME, STALE_RETRY_BACKOFF_MS, claudeSkillDir, collectionFileUrl, dataSkillDir, fetchAllRegistries, fetchBundle, fetchCollectionFile, fetchManifest, fetchRegistryIndex, findRegistry, importRegistry, isSafeBundlePath, listRegistries, listRegistry, loadRegistriesConfig, normalizedDataPath, parseJsonObject, parseManifest, parseRegistriesConfig, parseRegistryIndex, parseSkillDescription, performExport, performImport, previewCollection, rawBaseForEntry, resetRegistryCache, withNormalizedDataPath, writeCollectionExport, writeImportedCollection };
|
|
1075
|
+
|
|
1076
|
+
//# sourceMappingURL=index.js.map
|