@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.12

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.
Files changed (54) hide show
  1. package/dist/active-skills.js +67 -0
  2. package/dist/active-skills.test.js +29 -0
  3. package/dist/config-sync.js +439 -0
  4. package/dist/config-sync.test.js +145 -0
  5. package/dist/hooks.js +337 -0
  6. package/dist/hooks.test.js +123 -0
  7. package/dist/http.js +54 -0
  8. package/dist/identity.js +56 -0
  9. package/dist/index.js +212 -72
  10. package/dist/index.test.js +39 -0
  11. package/dist/integration.test.js +102 -0
  12. package/dist/matcher.js +362 -0
  13. package/dist/matcher.test.js +139 -0
  14. package/dist/paths.js +62 -0
  15. package/dist/paths.test.js +49 -0
  16. package/dist/reporter.js +267 -0
  17. package/dist/reporter.test.js +128 -0
  18. package/dist/semver.js +64 -0
  19. package/dist/semver.test.js +21 -0
  20. package/dist/skill-version.js +23 -0
  21. package/dist/types.js +9 -0
  22. package/dist/updater.js +352 -0
  23. package/dist/updater.test.js +212 -0
  24. package/dist/ws-client.js +484 -0
  25. package/openclaw.plugin.json +50 -50
  26. package/package.json +37 -37
  27. package/src/active-skills.test.ts +32 -32
  28. package/src/active-skills.ts +77 -77
  29. package/src/config-sync.test.ts +165 -165
  30. package/src/config-sync.ts +544 -544
  31. package/src/hooks.test.ts +251 -251
  32. package/src/hooks.ts +517 -517
  33. package/src/http.ts +61 -61
  34. package/src/identity.ts +64 -64
  35. package/src/index.test.ts +53 -53
  36. package/src/index.ts +226 -226
  37. package/src/integration.test.ts +119 -119
  38. package/src/matcher.test.ts +170 -170
  39. package/src/matcher.ts +393 -393
  40. package/src/paths.test.ts +57 -57
  41. package/src/paths.ts +84 -84
  42. package/src/reporter.test.ts +139 -139
  43. package/src/reporter.ts +298 -298
  44. package/src/sample-config.json +72 -72
  45. package/src/semver.test.ts +23 -23
  46. package/src/semver.ts +60 -60
  47. package/src/skill-version.ts +53 -53
  48. package/src/types.ts +198 -198
  49. package/src/updater.test.ts +314 -237
  50. package/src/updater.ts +518 -433
  51. package/src/ws-client.test.ts +48 -37
  52. package/src/ws-client.ts +717 -642
  53. package/test-ws.ts +17 -17
  54. package/tsconfig.json +14 -14
package/dist/index.js CHANGED
@@ -395,36 +395,85 @@ var SkillUpdater = class {
395
395
  }
396
396
  }
397
397
  async manualInstall(options) {
398
- const { code, force, targetDir } = options;
398
+ const { code, force, targetDir, additionalTargetDirs = [], trace } = options;
399
399
  let { url, version } = options;
400
- if (!url) {
401
- const dl = await this.fetchDownloadUrl(code, version || "latest");
402
- if (!dl?.url) {
403
- return { success: false, message: `\u65E0\u6CD5\u83B7\u53D6\u6280\u80FD ${code} \u7684\u4E0B\u8F7D\u5730\u5740` };
400
+ const startedAt = Date.now();
401
+ let currentStage = "install.start";
402
+ let work;
403
+ const emit = (stage, data) => {
404
+ currentStage = stage;
405
+ try {
406
+ trace?.(stage, data);
407
+ } catch {
404
408
  }
405
- url = dl.url;
406
- version = dl.version || version;
407
- }
408
- const targetSkillPath = path3.join(targetDir, code);
409
- if (!force && await this.exists(targetSkillPath)) {
410
- return { success: false, message: `\u6280\u80FD ${code} \u5DF2\u5B58\u5728\u4E8E\u76EE\u6807\u76EE\u5F55` };
411
- }
412
- const work = path3.join(this.tmpDir, `slp-manual-${randomUUID()}`);
413
- await fs3.mkdir(work, { recursive: true });
409
+ };
410
+ emit("install.start", {
411
+ code,
412
+ version: version || "latest",
413
+ force,
414
+ hasDirectUrl: Boolean(url),
415
+ targetCount: 1 + additionalTargetDirs.length
416
+ });
414
417
  try {
418
+ if (!url) {
419
+ const lookupStartedAt = Date.now();
420
+ emit("download_url.lookup.start", { code, version: version || "latest" });
421
+ const dl = await this.fetchDownloadUrl(code, version || "latest");
422
+ if (!dl?.url) {
423
+ const message = `\u65E0\u6CD5\u83B7\u53D6\u6280\u80FD ${code} \u7684\u4E0B\u8F7D\u5730\u5740`;
424
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
425
+ return { success: false, message };
426
+ }
427
+ url = dl.url;
428
+ version = dl.version || version;
429
+ emit("download_url.lookup.completed", {
430
+ version: version || "latest",
431
+ url,
432
+ elapsedMs: Date.now() - lookupStartedAt
433
+ });
434
+ }
435
+ const targetSkillPath = path3.join(targetDir, code);
436
+ if (!force && await this.exists(targetSkillPath)) {
437
+ const message = `\u6280\u80FD ${code} \u5DF2\u5B58\u5728\u4E8E\u76EE\u6807\u76EE\u5F55`;
438
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
439
+ return { success: false, message };
440
+ }
441
+ work = path3.join(this.tmpDir, `slp-manual-${randomUUID()}`);
442
+ await fs3.mkdir(work, { recursive: true });
415
443
  const zipPath = path3.join(work, "pkg.zip");
444
+ const downloadStartedAt = Date.now();
445
+ emit("download.request.start", { url });
416
446
  const res = await this.fetchImpl(url);
447
+ emit("download.headers.received", {
448
+ status: res.status,
449
+ contentLength: res.headers?.get("content-length") || void 0,
450
+ contentType: res.headers?.get("content-type") || void 0,
451
+ elapsedMs: Date.now() - downloadStartedAt
452
+ });
417
453
  if (!res.ok) {
418
- return { success: false, message: `\u4E0B\u8F7D\u5931\u8D25: HTTP ${res.status}` };
454
+ const message = `\u4E0B\u8F7D\u5931\u8D25: HTTP ${res.status}`;
455
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
456
+ return { success: false, message };
419
457
  }
420
458
  const buf = Buffer.from(await res.arrayBuffer());
459
+ emit("download.body.completed", {
460
+ bytes: buf.byteLength,
461
+ elapsedMs: Date.now() - downloadStartedAt
462
+ });
421
463
  await fs3.writeFile(zipPath, buf);
464
+ emit("download.file.written", { bytes: buf.byteLength });
422
465
  const staging = path3.join(work, "staging");
466
+ const unzipStartedAt = Date.now();
467
+ emit("unzip.start");
423
468
  await this.unzip(zipPath, staging);
469
+ emit("unzip.completed", { elapsedMs: Date.now() - unzipStartedAt });
424
470
  const srcRoot = await this.locateSkillRoot(staging, 0);
425
471
  if (!srcRoot) {
426
- return { success: false, message: `\u4E0B\u8F7D\u5305\u5185\u672A\u627E\u5230 SKILL.md\uFF0C\u975E\u6CD5\u7684\u6280\u80FD\u5305\u7ED3\u6784` };
472
+ const message = `\u4E0B\u8F7D\u5305\u5185\u672A\u627E\u5230 SKILL.md\uFF0C\u975E\u6CD5\u7684\u6280\u80FD\u5305\u7ED3\u6784`;
473
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
474
+ return { success: false, message };
427
475
  }
476
+ emit("skill_root.located");
428
477
  const metaPath = path3.join(srcRoot, ".meta.json");
429
478
  if (!await this.exists(metaPath)) {
430
479
  let parsedVersion = version || "unknown";
@@ -442,13 +491,37 @@ var SkillUpdater = class {
442
491
  publishedAt: Date.now()
443
492
  }, null, 2));
444
493
  }
445
- await this.replaceDir(srcRoot, targetSkillPath);
494
+ const targets = [targetDir, ...additionalTargetDirs];
495
+ for (let index = 0; index < targets.length; index += 1) {
496
+ const rootDir = targets[index];
497
+ const targetPath = path3.join(rootDir, code);
498
+ const replaceStartedAt = Date.now();
499
+ emit("target.replace.start", { targetDir: rootDir, targetIndex: index, targetCount: targets.length });
500
+ await this.replaceDir(srcRoot, targetPath);
501
+ emit("target.replace.completed", {
502
+ targetDir: rootDir,
503
+ targetIndex: index,
504
+ targetCount: targets.length,
505
+ elapsedMs: Date.now() - replaceStartedAt
506
+ });
507
+ }
508
+ emit("install.completed", { elapsedMs: Date.now() - startedAt, targetCount: targets.length });
446
509
  return { success: true, message: `\u6280\u80FD ${code} \u5B89\u88C5/\u66F4\u65B0\u6210\u529F` };
447
510
  } catch (err) {
511
+ emit("install.failed", {
512
+ stage: currentStage,
513
+ errorName: err?.name,
514
+ message: err?.message || String(err),
515
+ stack: err?.stack,
516
+ elapsedMs: Date.now() - startedAt
517
+ });
448
518
  return { success: false, message: `\u6267\u884C\u51FA\u9519: ${err.message}` };
449
519
  } finally {
450
- await fs3.rm(work, { recursive: true, force: true }).catch(() => {
451
- });
520
+ if (work) {
521
+ await fs3.rm(work, { recursive: true, force: true }).catch(() => {
522
+ });
523
+ emit("cleanup.completed", { elapsedMs: Date.now() - startedAt });
524
+ }
452
525
  }
453
526
  }
454
527
  };
@@ -1562,6 +1635,9 @@ function normalizeAssistantUserId(userId) {
1562
1635
  if (!ASSISTANT_WORKSPACE_ID_RE.test(pureId)) return void 0;
1563
1636
  return pureId;
1564
1637
  }
1638
+ function shouldSyncBuiltInTemplate(action, isBuiltIn) {
1639
+ return action === "UPDATE_SKILL" && isBuiltIn === true;
1640
+ }
1565
1641
  var GatewayWsClient = class {
1566
1642
  ws = null;
1567
1643
  options;
@@ -1598,6 +1674,14 @@ var GatewayWsClient = class {
1598
1674
  } catch (e) {
1599
1675
  }
1600
1676
  }
1677
+ createInstallTrace(context) {
1678
+ return (stage, data) => {
1679
+ this.appendLogToFile(stage === "install.failed" ? "ERROR" : "INFO", "Install", stage, {
1680
+ ...context,
1681
+ ...data
1682
+ });
1683
+ };
1684
+ }
1601
1685
  constructor(options) {
1602
1686
  this.options = options;
1603
1687
  }
@@ -1849,8 +1933,16 @@ var GatewayWsClient = class {
1849
1933
  * 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
1850
1934
  */
1851
1935
  async handleMessage(msg) {
1852
- const { action, userId, code, url, force, version, replyId } = msg;
1853
- this.appendLogToFile("INFO", "Command", `Received WS message`, { action, userId, code, version, replyId });
1936
+ const { action, userId, code, url, force, version, replyId, isBuiltIn } = msg;
1937
+ this.appendLogToFile("INFO", "Command", `Received WS message`, {
1938
+ action,
1939
+ userId,
1940
+ code,
1941
+ version,
1942
+ replyId,
1943
+ isBuiltIn,
1944
+ hasDirectUrl: Boolean(url)
1945
+ });
1854
1946
  if (!action || !userId) {
1855
1947
  this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
1856
1948
  return;
@@ -1872,7 +1964,8 @@ var GatewayWsClient = class {
1872
1964
  url,
1873
1965
  version,
1874
1966
  force: force !== false,
1875
- targetDir
1967
+ targetDir,
1968
+ trace: this.createInstallTrace({ action, replyId, userId, code: safeCode })
1876
1969
  });
1877
1970
  this.reply(replyId, { success: result.success, message: result.message, action });
1878
1971
  } else if (action === "UNINSTALL_SKILL") {
@@ -1907,7 +2000,7 @@ var GatewayWsClient = class {
1907
2000
  }
1908
2001
  const metaPath = path7.join(skillDir, ".meta.json");
1909
2002
  let isPlatform = false;
1910
- let isBuiltIn = e.isSymbolicLink();
2003
+ let isBuiltIn2 = e.isSymbolicLink();
1911
2004
  let metaData = null;
1912
2005
  let name = e.name;
1913
2006
  let description = "";
@@ -1928,7 +2021,7 @@ var GatewayWsClient = class {
1928
2021
  const parsed = JSON.parse(metaContent);
1929
2022
  if (parsed) {
1930
2023
  if (parsed.ownerId === "CMS" || parsed.ownerId === "CMS_COMPAT") isPlatform = true;
1931
- if (parsed.isBuiltIn === true || parsed.ownerId === "built-in") isBuiltIn = true;
2024
+ if (parsed.isBuiltIn === true || parsed.ownerId === "built-in") isBuiltIn2 = true;
1932
2025
  metaData = parsed;
1933
2026
  }
1934
2027
  } catch (err) {
@@ -1939,7 +2032,7 @@ var GatewayWsClient = class {
1939
2032
  list.push({
1940
2033
  code: e.name,
1941
2034
  isPlatform: true,
1942
- isBuiltIn,
2035
+ isBuiltIn: isBuiltIn2,
1943
2036
  version: skillVersion,
1944
2037
  name,
1945
2038
  description,
@@ -1949,7 +2042,7 @@ var GatewayWsClient = class {
1949
2042
  list.push({
1950
2043
  code: e.name,
1951
2044
  isPlatform: false,
1952
- isBuiltIn,
2045
+ isBuiltIn: isBuiltIn2,
1953
2046
  version: skillVersion,
1954
2047
  name,
1955
2048
  description
@@ -1960,21 +2053,54 @@ var GatewayWsClient = class {
1960
2053
  } else if (action === "UPDATE_SKILL") {
1961
2054
  if (!safeCode) throw new Error("Missing code parameter");
1962
2055
  const delayMs = Math.random() * 5e3;
2056
+ const syncBuiltInTemplate = shouldSyncBuiltInTemplate(action, isBuiltIn);
1963
2057
  console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
1964
- this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, { userId, code: safeCode, version, delayMs: Math.round(delayMs) });
2058
+ this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, {
2059
+ userId,
2060
+ code: safeCode,
2061
+ version,
2062
+ isBuiltIn,
2063
+ syncBuiltInTemplate,
2064
+ delayMs: Math.round(delayMs)
2065
+ });
1965
2066
  setTimeout(async () => {
1966
2067
  try {
1967
- await this.options.updater.manualInstall({
2068
+ const additionalTargetDirs = syncBuiltInTemplate ? [path7.join(openclawHome(), "workspace-xgjk-assistant-template", "skills")] : [];
2069
+ const result = await this.options.updater.manualInstall({
1968
2070
  code: safeCode,
1969
2071
  url,
1970
2072
  version,
1971
2073
  force: true,
1972
- targetDir
2074
+ targetDir,
2075
+ additionalTargetDirs,
2076
+ trace: this.createInstallTrace({
2077
+ action,
2078
+ replyId,
2079
+ userId,
2080
+ code: safeCode,
2081
+ isBuiltIn,
2082
+ syncBuiltInTemplate
2083
+ })
2084
+ });
2085
+ this.appendLogToFile(result.success ? "INFO" : "ERROR", "Command", `UPDATE_SKILL completed`, {
2086
+ userId,
2087
+ code: safeCode,
2088
+ replyId,
2089
+ success: result.success,
2090
+ message: result.message,
2091
+ syncBuiltInTemplate
1973
2092
  });
1974
2093
  if (replyId) {
1975
- this.reply(replyId, { success: true, message: `Skill ${safeCode} updated successfully`, action });
2094
+ this.reply(replyId, { success: result.success, message: result.message, action });
1976
2095
  }
1977
2096
  } catch (e) {
2097
+ this.appendLogToFile("ERROR", "Command", `UPDATE_SKILL threw`, {
2098
+ userId,
2099
+ code: safeCode,
2100
+ replyId,
2101
+ message: e?.message || String(e),
2102
+ stack: e?.stack
2103
+ });
1978
2104
  if (replyId) this.reply(replyId, { success: false, message: e.message, action });
1979
2105
  }
1980
2106
  }, delayMs);
@@ -1985,11 +2111,25 @@ var GatewayWsClient = class {
1985
2111
  this.appendLogToFile("INFO", "Command", `INSTALL_EXPERT received`, { userId, code: safeCode, version: version2, downloadUrl });
1986
2112
  const userSkillRoot = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user");
1987
2113
  const expertTarget = path7.join(userSkillRoot, "experts", safeCode);
1988
- await fs6.mkdir(path7.dirname(expertTarget), { recursive: true });
1989
- const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
1990
- if (!expertResult.success) {
1991
- throw new Error(`\u4E13\u5BB6\u5B89\u88C5\u5931\u8D25: ${expertResult.message}`);
2114
+ let skipInstall = false;
2115
+ const metaPath = path7.join(expertTarget, ".meta.json");
2116
+ try {
2117
+ const raw = await fs6.readFile(metaPath, "utf-8");
2118
+ const existing = JSON.parse(raw);
2119
+ if (existing.version && existing.version === (version2 || "1.0.0")) {
2120
+ skipInstall = true;
2121
+ }
2122
+ } catch {
1992
2123
  }
2124
+ if (!skipInstall) {
2125
+ await fs6.mkdir(path7.dirname(expertTarget), { recursive: true });
2126
+ const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
2127
+ if (!expertResult.success) {
2128
+ throw new Error(`\u4E13\u5BB6\u5B89\u88C5\u5931\u8D25: ${expertResult.message}`);
2129
+ }
2130
+ }
2131
+ const meta = { code: safeCode, name, version: version2 || "1.0.0", installedAt: Date.now() };
2132
+ await fs6.writeFile(metaPath, JSON.stringify(meta, null, 2));
1993
2133
  const skillTargetRoot = path7.join(userSkillRoot, "skills");
1994
2134
  await fs6.mkdir(skillTargetRoot, { recursive: true });
1995
2135
  const skillResults = [];
@@ -2014,48 +2154,42 @@ var GatewayWsClient = class {
2014
2154
  action,
2015
2155
  data: { expertCode: safeCode, skills: skillResults }
2016
2156
  });
2017
- } else if (action === "GET_EXPERT_REGISTRY") {
2018
- console.log(`[skill-logger-plugin][WS] Executing GET_EXPERT_REGISTRY for user ${userId}`);
2019
- this.appendLogToFile("INFO", "Command", `GET_EXPERT_REGISTRY received`, { userId });
2020
- const registryFilePath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "expert-registry.yaml");
2021
- let content = "";
2022
- let fileExists = false;
2157
+ } else if (action === "UNINSTALL_EXPERT") {
2158
+ if (!safeCode) throw new Error("Missing code parameter");
2159
+ console.log(`[skill-logger-plugin][WS] Executing UNINSTALL_EXPERT for user ${userId}, code: ${safeCode}`);
2160
+ this.appendLogToFile("INFO", "Command", `UNINSTALL_EXPERT received`, { userId, code: safeCode });
2161
+ const expertPath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "experts", safeCode);
2162
+ await fs6.rm(expertPath, { recursive: true, force: true });
2163
+ this.reply(replyId, { success: true, message: `\u4E13\u5BB6 ${safeCode} \u5DF2\u5378\u8F7D`, action });
2164
+ } else if (action === "LIST_EXPERTS") {
2165
+ console.log(`[skill-logger-plugin][WS] Executing LIST_EXPERTS for user ${userId}`);
2166
+ this.appendLogToFile("INFO", "Command", `LIST_EXPERTS received`, { userId });
2167
+ const expertsDir = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "experts");
2168
+ const list = [];
2023
2169
  try {
2024
- const stat = await fs6.stat(registryFilePath);
2025
- fileExists = stat.isFile();
2026
- } catch (err) {
2027
- if (err?.code !== "ENOENT") throw err;
2028
- }
2029
- if (fileExists) {
2030
- content = await fs6.readFile(registryFilePath, "utf8");
2031
- const expertsList = [];
2032
- const lines = content.split("\n");
2033
- let currentExpert = null;
2034
- for (const line of lines) {
2035
- const trimmed = line.trim();
2036
- if (trimmed.startsWith("#")) continue;
2037
- const idMatch = line.match(/^\s*-\s*id:\s*(.+)$/);
2038
- if (idMatch) {
2039
- if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
2040
- currentExpert = { id: idMatch[1].trim() };
2041
- continue;
2042
- }
2043
- if (currentExpert) {
2044
- const nameMatch = line.match(/^\s*name:\s*(.+)$/);
2045
- if (nameMatch) {
2046
- currentExpert.name = nameMatch[1].trim();
2047
- }
2048
- const descMatch = line.match(/^\s*description:\s*(.+)$/);
2049
- if (descMatch) {
2050
- currentExpert.description = descMatch[1].trim();
2170
+ const stat = await fs6.stat(expertsDir);
2171
+ if (stat.isDirectory()) {
2172
+ const entries = await fs6.readdir(expertsDir, { withFileTypes: true });
2173
+ for (const e of entries) {
2174
+ if (!e.isDirectory()) continue;
2175
+ const metaPath = path7.join(expertsDir, e.name, ".meta.json");
2176
+ try {
2177
+ const raw = await fs6.readFile(metaPath, "utf-8");
2178
+ const meta = JSON.parse(raw);
2179
+ list.push({
2180
+ code: meta.code || e.name,
2181
+ name: meta.name || e.name,
2182
+ version: meta.version || "",
2183
+ installedAt: meta.installedAt
2184
+ });
2185
+ } catch {
2186
+ list.push({ code: e.name, name: e.name, version: "" });
2051
2187
  }
2052
2188
  }
2053
2189
  }
2054
- if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
2055
- this.reply(replyId, { success: true, data: expertsList, action });
2056
- } else {
2057
- this.reply(replyId, { success: false, message: `\u672A\u627E\u5230\u7528\u6237\u6280\u80FD\u914D\u7F6E\u6587\u4EF6\uFF0C\u53EF\u80FD\u5C1A\u672A\u6CE8\u518C\u6216\u6587\u4EF6\u5DF2\u4E22\u5931`, action });
2190
+ } catch {
2058
2191
  }
2192
+ this.reply(replyId, { success: true, data: list, action });
2059
2193
  } else {
2060
2194
  console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
2061
2195
  this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
@@ -2072,7 +2206,13 @@ var GatewayWsClient = class {
2072
2206
  this.appendLogToFile("WARN", "Command", `Cannot reply: WS closed or missing replyId`, { replyId });
2073
2207
  return;
2074
2208
  }
2075
- this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }));
2209
+ this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }), (err) => {
2210
+ if (err) {
2211
+ this.appendLogToFile("ERROR", "Command", `Reply send failed`, { replyId, message: err.message });
2212
+ return;
2213
+ }
2214
+ this.appendLogToFile("INFO", "Command", `Reply sent`, { replyId });
2215
+ });
2076
2216
  }
2077
2217
  destroy() {
2078
2218
  this.isDestroyed = true;
@@ -0,0 +1,39 @@
1
+ import { describe, it, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ let isSkillMdReadPath;
4
+ let extractApiPluginConfig;
5
+ describe("isSkillMdReadPath", () => {
6
+ before(async () => {
7
+ ({ isSkillMdReadPath, extractApiPluginConfig } = await import("./index.ts"));
8
+ });
9
+ it("POSIX 路径 basename 为 SKILL.md 时为 true", () => {
10
+ assert.equal(isSkillMdReadPath("/x/y/my-skill/SKILL.md"), true);
11
+ });
12
+ it("Windows 风格路径", { skip: process.platform !== "win32" }, () => {
13
+ assert.equal(isSkillMdReadPath(String.raw `C:\x\y\my-skill\SKILL.md`), true);
14
+ });
15
+ it("fooSKILL.md / my-SKILL.md 不误判", () => {
16
+ assert.equal(isSkillMdReadPath("/tmp/fooSKILL.md"), false);
17
+ assert.equal(isSkillMdReadPath("/tmp/my-SKILL.md"), false);
18
+ });
19
+ });
20
+ describe("extractApiPluginConfig", () => {
21
+ it("从 OpenClaw 注入的 api.pluginConfig 初始化运行期配置", () => {
22
+ assert.deepEqual(extractApiPluginConfig({
23
+ pluginConfig: {
24
+ platformBaseUrl: "https://platform.example/api",
25
+ reportBaseUrl: "https://report.example/api",
26
+ authToken: "Bearer token",
27
+ recordUnattributed: true,
28
+ },
29
+ }), {
30
+ platformBaseUrl: "https://platform.example/api",
31
+ reportBaseUrl: "https://report.example/api",
32
+ authToken: "Bearer token",
33
+ recordUnattributed: true,
34
+ });
35
+ });
36
+ it("未配置时返回空对象", () => {
37
+ assert.deepEqual(extractApiPluginConfig({}), {});
38
+ });
39
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { ConfigSync } from "./config-sync.ts";
8
+ import { SkillUpdater } from "./updater.ts";
9
+ const idHash = (code, version) => createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
10
+ let dir;
11
+ beforeEach(async () => {
12
+ dir = path.join(os.tmpdir(), `slp-int-${Date.now()}-${Math.random().toString(36).slice(2)}`);
13
+ await fs.mkdir(dir, { recursive: true });
14
+ });
15
+ afterEach(async () => {
16
+ await fs.rm(dir, { recursive: true, force: true });
17
+ });
18
+ describe("版本更新闭环(端到端)", () => {
19
+ it("config-pull 报最新版 → 检测落后 → 下载覆盖 → 再查收敛", async () => {
20
+ // 本地装一个 demo@1.0.0
21
+ const ws = path.join(dir, "workspace");
22
+ const skillDir = path.join(ws, "skills", "demo");
23
+ await fs.mkdir(skillDir, { recursive: true });
24
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
25
+ const config = { platformBaseUrl: "https://api", autoUpdateSkills: true };
26
+ // 服务端最新版本(随测试推进而变化),模拟「更新后平台版本=本地版本」的收敛。
27
+ let platformLatest = "2.0.0";
28
+ // ConfigSync 的 fetch:/skill_config/pull → 带 latestVersion
29
+ const csFetch = async (_url, _init) => ({
30
+ ok: true,
31
+ status: 200,
32
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: platformLatest, functions: [] }] }),
33
+ });
34
+ // Updater 的 fetch:/skill_package/pull → {url, sha256};GET → zip 字节
35
+ const upFetch = async (_url, init) => {
36
+ if (init?.method === "POST") {
37
+ return {
38
+ ok: true,
39
+ status: 200,
40
+ json: async () => ({ url: "https://pkg/skill.zip", version: platformLatest, sha256: idHash("demo", platformLatest) }),
41
+ arrayBuffer: async () => new ArrayBuffer(0),
42
+ };
43
+ }
44
+ return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
45
+ };
46
+ // 解压:写出新版本的 SKILL.md(版本号与 platformLatest 一致)
47
+ const unzip = async (_zip, destDir) => {
48
+ const root = path.join(destDir, "demo");
49
+ await fs.mkdir(root, { recursive: true });
50
+ await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${platformLatest}\n---\nNEW\n`);
51
+ };
52
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip, tmpDir: dir });
53
+ const cs = new ConfigSync({
54
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
55
+ getConfig: () => config,
56
+ fetchImpl: csFetch,
57
+ resolveSkillDirs: () => [path.join(ws, "skills")],
58
+ updater,
59
+ });
60
+ // 第一轮:检测落后并覆盖到 2.0.0
61
+ await cs.checkVersionsAndUpdate();
62
+ const afterMd = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8");
63
+ assert.ok(afterMd.includes("version: 2.0.0"), "应被更新到 2.0.0");
64
+ assert.ok(afterMd.includes("NEW"), "应为新内容");
65
+ // 第二轮:平台版本仍 2.0.0、本地已 2.0.0 → 收敛,无落后副本
66
+ platformLatest = "2.0.0";
67
+ await cs.checkVersionsAndUpdate();
68
+ assert.equal(cs.detectOutdated().length, 0, "更新后应收敛");
69
+ // sync.json 落了 installations 映射,且版本已是 2.0.0
70
+ const state = JSON.parse(await fs.readFile(path.join(dir, "sync.json"), "utf-8"));
71
+ assert.equal(state.installations.demo[0].version, "2.0.0");
72
+ });
73
+ it("autoUpdateSkills=false:检测到落后也不覆盖", async () => {
74
+ const ws = path.join(dir, "workspace");
75
+ const skillDir = path.join(ws, "skills", "demo");
76
+ await fs.mkdir(skillDir, { recursive: true });
77
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
78
+ const config = { platformBaseUrl: "https://api", autoUpdateSkills: false };
79
+ const csFetch = async () => ({
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: "2.0.0", functions: [] }] }),
83
+ });
84
+ let upCalled = 0;
85
+ const upFetch = async () => {
86
+ upCalled++;
87
+ return { ok: true, status: 200, json: async () => ({ url: "" }), arrayBuffer: async () => new ArrayBuffer(0) };
88
+ };
89
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip: async () => { }, tmpDir: dir });
90
+ const cs = new ConfigSync({
91
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
92
+ getConfig: () => config,
93
+ fetchImpl: csFetch,
94
+ resolveSkillDirs: () => [path.join(ws, "skills")],
95
+ updater,
96
+ });
97
+ await cs.checkVersionsAndUpdate();
98
+ assert.equal(cs.detectOutdated().length, 1, "仍检测到落后");
99
+ assert.equal(upCalled, 0, "关闭时不应发起下载");
100
+ assert.ok((await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8")).includes("OLD"), "文件保持原样");
101
+ });
102
+ });