@webskill/sdk 0.1.4 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  import { parse } from "yaml";
2
- import { zipSync } from "fflate";
2
+ import { Unzip, UnzipInflate, zipSync } from "fflate";
3
3
 
4
4
  //#region ../core/dist/index.js
5
5
  /**
@@ -39,16 +39,22 @@ async function buildManifest(input) {
39
39
  files: input.files
40
40
  };
41
41
  }
42
- /** 按 manifest.files 比对实际 hash(调用方负责计算 actualHashes) */
43
- function verifyManifest(manifest, actualHashes) {
42
+ /** 按 manifest.files 比对实际 hash 与文件集(mismatches/extras/missing 三类全空才 ok) */
43
+ function verifyManifest(manifest, actualHashes, actualFiles = [...actualHashes.keys()]) {
44
44
  const mismatches = [];
45
+ const missing = [];
46
+ const listed = new Set(manifest.files.map((f) => f.path));
45
47
  for (const file of manifest.files) {
46
48
  const actual = actualHashes.get(file.path);
47
- if (actual === void 0 || actual !== file.sha256) mismatches.push(file.path);
49
+ if (actual === void 0) missing.push(file.path);
50
+ else if (actual !== file.sha256) mismatches.push(file.path);
48
51
  }
52
+ const extras = actualFiles.filter((p) => p !== "webskill.skill-manifest.json" && p !== "skills.lock.json" && !listed.has(p)).sort();
49
53
  return {
50
- ok: mismatches.length === 0,
51
- mismatches
54
+ ok: mismatches.length === 0 && missing.length === 0 && extras.length === 0,
55
+ mismatches,
56
+ extras,
57
+ missing
52
58
  };
53
59
  }
54
60
  const ROOT = "/";
@@ -168,7 +174,26 @@ var MemoryFS = class {
168
174
  for (const k of descendants) this.#entries.delete(k);
169
175
  this.#entries.delete(key);
170
176
  }
177
+ async rename(from, to) {
178
+ const src = this.#normalize(from);
179
+ const dst = this.#normalize(to);
180
+ const entry = this.#entries.get(src);
181
+ if (!entry) throw new WebSkillError("FS_NOT_FOUND", `Path not found: ${src}`);
182
+ const prefix = src + "/";
183
+ const descendants = [...this.#entries.entries()].filter(([k]) => k.startsWith(prefix));
184
+ this.#ensureParents(dst);
185
+ this.#entries.set(dst, entry);
186
+ for (const [k, v] of descendants) this.#entries.set(dst + k.slice(src.length), v);
187
+ for (const [k] of descendants) this.#entries.delete(k);
188
+ this.#entries.delete(src);
189
+ }
171
190
  };
191
+ /** 原子写文本:先写临时文件再 rename(进程崩溃不留下半写文件;lockfile 等账本场景) */
192
+ async function atomicWriteText(fs, path, content) {
193
+ const tmp = `${path}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
194
+ await fs.writeText(tmp, content);
195
+ await fs.rename(tmp, path);
196
+ }
172
197
  /** 统一分隔符为 `/`,去除 `.` 段与重复分隔符(不解析 `..`) */
173
198
  function normalizePath(path) {
174
199
  return path.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".").join("/");
@@ -407,6 +432,107 @@ function parseSkillPackManifest(text) {
407
432
  }
408
433
  return raw;
409
434
  }
435
+ const DEFAULT_ARCHIVE_LIMITS = {
436
+ maxDownloadBytes: 64 * 1024 * 1024,
437
+ maxEntryBytes: 64 * 1024 * 1024,
438
+ maxTotalBytes: 256 * 1024 * 1024
439
+ };
440
+ function resolveArchiveLimits(limits) {
441
+ return {
442
+ ...DEFAULT_ARCHIVE_LIMITS,
443
+ ...limits
444
+ };
445
+ }
446
+ function concat(chunks, size) {
447
+ const out = new Uint8Array(size);
448
+ let offset = 0;
449
+ for (const chunk of chunks) {
450
+ out.set(chunk, offset);
451
+ offset += chunk.length;
452
+ }
453
+ return out;
454
+ }
455
+ /** 流式解 zip(单条目/总双重上限);返回 [path, content][](目录条目以 / 结尾、内容为空) */
456
+ async function unzipWithLimits(data, limits) {
457
+ const { maxEntryBytes, maxTotalBytes } = resolveArchiveLimits(limits);
458
+ let eocd = false;
459
+ const scanFrom = Math.max(0, data.length - 65557);
460
+ for (let i = data.length - 22; i >= scanFrom; i--) if (data[i] === 80 && data[i + 1] === 75 && data[i + 2] === 5 && data[i + 3] === 6) {
461
+ eocd = true;
462
+ break;
463
+ }
464
+ if (!eocd) throw new WebSkillError("INSTALL_FAILED", "Failed to read zip archive: missing end-of-central-directory record");
465
+ const entries = [];
466
+ let total = 0;
467
+ let failure;
468
+ const unzip = new Unzip();
469
+ unzip.register(UnzipInflate);
470
+ unzip.onfile = (file) => {
471
+ if (file.name.endsWith("/")) {
472
+ entries.push([file.name, /* @__PURE__ */ new Uint8Array(0)]);
473
+ return;
474
+ }
475
+ const chunks = [];
476
+ let size = 0;
477
+ file.ondata = (err, chunk, final) => {
478
+ if (err) {
479
+ failure = err;
480
+ return;
481
+ }
482
+ size += chunk.length;
483
+ if (size > maxEntryBytes) {
484
+ failure = new WebSkillError("INSTALL_FAILED", `Archive entry exceeds the ${maxEntryBytes}-byte limit: ${file.name}`);
485
+ return;
486
+ }
487
+ total += chunk.length;
488
+ if (total > maxTotalBytes) {
489
+ failure = new WebSkillError("INSTALL_FAILED", `Archive contents exceed the ${maxTotalBytes}-byte total limit`);
490
+ return;
491
+ }
492
+ chunks.push(chunk);
493
+ if (final) entries.push([file.name, concat(chunks, size)]);
494
+ };
495
+ file.start();
496
+ };
497
+ try {
498
+ unzip.push(data, true);
499
+ } catch (e) {
500
+ throw new WebSkillError("INSTALL_FAILED", `Failed to read zip archive: ${e instanceof Error ? e.message : String(e)}`, e);
501
+ }
502
+ await new Promise((resolve) => {
503
+ setTimeout(resolve, 0);
504
+ });
505
+ if (failure) {
506
+ if (failure instanceof WebSkillError) throw failure;
507
+ throw new WebSkillError("INSTALL_FAILED", `Failed to read zip archive: ${failure instanceof Error ? failure.message : String(failure)}`, failure);
508
+ }
509
+ return entries;
510
+ }
511
+ /** 下载响应体:Content-Length 预检 + 流式累计上限 */
512
+ async function readResponseWithLimit(res, limits) {
513
+ const { maxDownloadBytes } = resolveArchiveLimits(limits);
514
+ const declared = res.headers.get("content-length");
515
+ if (declared !== null && Number(declared) > maxDownloadBytes) throw new WebSkillError("INSTALL_FAILED", `Download exceeds the ${maxDownloadBytes}-byte limit (Content-Length: ${declared})`);
516
+ if (!res.body) {
517
+ const buf = new Uint8Array(await res.arrayBuffer());
518
+ if (buf.length > maxDownloadBytes) throw new WebSkillError("INSTALL_FAILED", `Download exceeds the ${maxDownloadBytes}-byte limit`);
519
+ return buf;
520
+ }
521
+ const reader = res.body.getReader();
522
+ const chunks = [];
523
+ let size = 0;
524
+ for (;;) {
525
+ const { value, done } = await reader.read();
526
+ if (done) break;
527
+ size += value.length;
528
+ if (size > maxDownloadBytes) {
529
+ await reader.cancel().catch(() => void 0);
530
+ throw new WebSkillError("INSTALL_FAILED", `Download exceeds the ${maxDownloadBytes}-byte limit`);
531
+ }
532
+ chunks.push(value);
533
+ }
534
+ return concat(chunks, size);
535
+ }
410
536
  /** 由条目列表构建 Catalog,保证按名称排序 */
411
537
  function buildCatalog(entries) {
412
538
  return { entries: [...entries].sort((a, b) => a.name.localeCompare(b.name)) };
@@ -463,8 +589,7 @@ var SkillDiscovery = class {
463
589
  });
464
590
  continue;
465
591
  }
466
- for (const child of await this.#fs.list(root)) {
467
- if (child.type !== "directory") continue;
592
+ await Promise.all((await this.#fs.list(root)).filter((child) => child.type === "directory").map(async (child) => {
468
593
  const dirName = baseName(child.path);
469
594
  const skillRoot = `${root}/${dirName}`;
470
595
  const skillMdPath = `${skillRoot}/SKILL.md`;
@@ -489,7 +614,7 @@ var SkillDiscovery = class {
489
614
  const dependencies = metadata["dependencies"];
490
615
  adjacency.set(metadata.name, Array.isArray(dependencies) ? dependencies.filter((d) => typeof d === "string") : []);
491
616
  }
492
- }
617
+ }));
493
618
  }
494
619
  const cycles = checkDependencyCycles(adjacency);
495
620
  const claimedNames = /* @__PURE__ */ new Set();
@@ -523,6 +648,25 @@ var SkillDiscovery = class {
523
648
  });
524
649
  }
525
650
  issues.push(...cycles.issues);
651
+ const admitted = new Set(entries.map((e) => e.name));
652
+ let changed = true;
653
+ while (changed) {
654
+ changed = false;
655
+ for (const entry of [...entries]) {
656
+ const missing = (adjacency.get(entry.name) ?? []).filter((d) => !admitted.has(d));
657
+ if (missing.length === 0) continue;
658
+ entries.splice(entries.indexOf(entry), 1);
659
+ admitted.delete(entry.name);
660
+ this.#index.delete(entry.name);
661
+ issues.push({
662
+ code: "SKILL_UNKNOWN_DEPENDENCY",
663
+ severity: "error",
664
+ message: `Skill "${entry.name}" was excluded: dependency ${JSON.stringify(missing[0])} is not in the final catalog`,
665
+ path: entry.root
666
+ });
667
+ changed = true;
668
+ }
669
+ }
526
670
  entries.sort((a, b) => a.name.localeCompare(b.name));
527
671
  return {
528
672
  entries,
@@ -599,4 +743,4 @@ const xmlRenderer = {
599
743
  };
600
744
 
601
745
  //#endregion
602
- export { renderAvailableSkillsXml as C, verifyManifest as D, validateSkills as E, xmlRenderer as O, parseSkillPackManifest as S, resolveInsideRoot as T, exportSkills as _, SKILL_NAME_PATTERN as a, normalizePath as b, SkillReader as c, buildCatalog as d, buildManifest as f, escapeXml as g, computeDigest as h, SKILL_NAME_MAX_LENGTH as i, WebSkillError as l, checkSkillRules as m, SKILLS_LOCKFILE as n, SKILL_PACK_FILE as o, checkDependencyCycles as p, SKILL_MANIFEST_FILE as r, SkillDiscovery as s, MemoryFS as t, assertSafePathSegment as u, isValidSkillName as v, renderCatalogJson as w, parseSkillMarkdown as x, jsonRenderer as y };
746
+ export { unzipWithLimits as A, parseSkillMarkdown as C, renderCatalogJson as D, renderAvailableSkillsXml as E, verifyManifest as M, xmlRenderer as N, resolveArchiveLimits as O, normalizePath as S, readResponseWithLimit as T, computeDigest as _, SKILL_NAME_MAX_LENGTH as a, isValidSkillName as b, SkillDiscovery as c, assertSafePathSegment as d, atomicWriteText as f, checkSkillRules as g, checkDependencyCycles as h, SKILL_MANIFEST_FILE as i, validateSkills as j, resolveInsideRoot as k, SkillReader as l, buildManifest as m, MemoryFS as n, SKILL_NAME_PATTERN as o, buildCatalog as p, SKILLS_LOCKFILE as r, SKILL_PACK_FILE as s, DEFAULT_ARCHIVE_LIMITS as t, WebSkillError as u, escapeXml as v, parseSkillPackManifest as w, jsonRenderer as x, exportSkills as y };