@spzhongwin/skill-logger-plugin 1.0.9 → 1.0.11
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/index.js +73 -5
- package/openclaw.plugin.json +50 -50
- package/package.json +37 -37
- package/src/active-skills.test.ts +32 -32
- package/src/active-skills.ts +77 -77
- package/src/config-sync.test.ts +165 -165
- package/src/config-sync.ts +544 -544
- package/src/hooks.test.ts +251 -251
- package/src/hooks.ts +517 -517
- package/src/http.ts +61 -61
- package/src/identity.ts +64 -64
- package/src/index.test.ts +53 -53
- package/src/index.ts +226 -226
- package/src/integration.test.ts +119 -119
- package/src/matcher.test.ts +170 -170
- package/src/matcher.ts +393 -393
- package/src/paths.test.ts +57 -57
- package/src/paths.ts +84 -84
- package/src/reporter.test.ts +139 -139
- package/src/reporter.ts +298 -298
- package/src/sample-config.json +72 -72
- package/src/semver.test.ts +23 -23
- package/src/semver.ts +60 -60
- package/src/skill-version.ts +53 -53
- package/src/types.ts +198 -198
- package/src/updater.test.ts +237 -237
- package/src/updater.ts +433 -433
- package/src/ws-client.test.ts +37 -37
- package/src/ws-client.ts +642 -641
- package/test-ws.ts +17 -17
- package/tsconfig.json +14 -14
- package/dist/active-skills.js +0 -67
- package/dist/active-skills.test.js +0 -29
- package/dist/config-sync.js +0 -439
- package/dist/config-sync.test.js +0 -145
- package/dist/hooks.js +0 -337
- package/dist/hooks.test.js +0 -123
- package/dist/http.js +0 -54
- package/dist/identity.js +0 -56
- package/dist/index.test.js +0 -39
- package/dist/integration.test.js +0 -102
- package/dist/matcher.js +0 -362
- package/dist/matcher.test.js +0 -139
- package/dist/paths.js +0 -62
- package/dist/paths.test.js +0 -49
- package/dist/reporter.js +0 -267
- package/dist/reporter.test.js +0 -128
- package/dist/semver.js +0 -64
- package/dist/semver.test.js +0 -21
- package/dist/skill-version.js +0 -23
- package/dist/types.js +0 -9
- package/dist/updater.js +0 -352
- package/dist/updater.test.js +0 -212
- package/dist/ws-client.js +0 -484
package/dist/semver.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 轻量语义化版本比较(纯函数,无依赖)。
|
|
3
|
-
*
|
|
4
|
-
* 仅用于「本地 skill 版本 vs 平台最新版本」的过期判断,不追求完整 semver 规范:
|
|
5
|
-
* - 容忍前缀 `v`/`V`(如 `v1.2.3`);
|
|
6
|
-
* - 比较 `主.次.修订…` 的数值段,缺省段按 0 补齐(`1.2` == `1.2.0`);
|
|
7
|
-
* - 预发布/构建元数据(`-beta`、`+build`)只取核心版本比较,忽略其后缀;
|
|
8
|
-
* - 任一侧无法解析为「点分数字」时,退化为字符串相等判断。
|
|
9
|
-
*/
|
|
10
|
-
/** 解析核心数字段;非「点分数字」返回 null(触发字符串回退)。 */
|
|
11
|
-
function parseCore(v) {
|
|
12
|
-
const core = v.trim().replace(/^[vV]/, "").split(/[-+]/, 1)[0];
|
|
13
|
-
if (!core)
|
|
14
|
-
return null;
|
|
15
|
-
const parts = core.split(".");
|
|
16
|
-
const nums = [];
|
|
17
|
-
for (const p of parts) {
|
|
18
|
-
if (!/^\d+$/.test(p))
|
|
19
|
-
return null;
|
|
20
|
-
nums.push(Number(p));
|
|
21
|
-
}
|
|
22
|
-
return nums.length > 0 ? nums : null;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* 比较 a 与 b:a<b 返回 -1,a>b 返回 1,相等返回 0。
|
|
26
|
-
* 两侧都能解析为点分数字时按数值逐段比较;否则退化为字符串比较。
|
|
27
|
-
*/
|
|
28
|
-
export function compareVersions(a, b) {
|
|
29
|
-
const na = parseCore(a);
|
|
30
|
-
const nb = parseCore(b);
|
|
31
|
-
if (na && nb) {
|
|
32
|
-
const len = Math.max(na.length, nb.length);
|
|
33
|
-
for (let i = 0; i < len; i++) {
|
|
34
|
-
const x = na[i] ?? 0;
|
|
35
|
-
const y = nb[i] ?? 0;
|
|
36
|
-
if (x < y)
|
|
37
|
-
return -1;
|
|
38
|
-
if (x > y)
|
|
39
|
-
return 1;
|
|
40
|
-
}
|
|
41
|
-
return 0;
|
|
42
|
-
}
|
|
43
|
-
// 字符串回退:仅区分相等/不等(不臆测大小关系)。
|
|
44
|
-
const sa = a.trim();
|
|
45
|
-
const sb = b.trim();
|
|
46
|
-
if (sa === sb)
|
|
47
|
-
return 0;
|
|
48
|
-
return sa < sb ? -1 : 1;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* 本地版本是否「落后于」最新版本(即需要更新)。
|
|
52
|
-
* - 两侧都是合法 semver:latest 数值更大才算过期;
|
|
53
|
-
* - 无法数值比较时:字符串不相等即视为过期(保守,宁可多更新一次)。
|
|
54
|
-
* 任一侧为空 → 不判过期(缺版本号则不参与更新)。
|
|
55
|
-
*/
|
|
56
|
-
export function isOutdated(local, latest) {
|
|
57
|
-
if (!local || !latest)
|
|
58
|
-
return false;
|
|
59
|
-
const na = parseCore(local);
|
|
60
|
-
const nb = parseCore(latest);
|
|
61
|
-
if (na && nb)
|
|
62
|
-
return compareVersions(local, latest) < 0;
|
|
63
|
-
return local.trim() !== latest.trim();
|
|
64
|
-
}
|
package/dist/semver.test.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { compareVersions, isOutdated } from "./semver.ts";
|
|
4
|
-
describe("compareVersions", () => {
|
|
5
|
-
it("数值逐段比较:1.10 > 1.9", () => assert.equal(compareVersions("1.10.0", "1.9.0"), 1));
|
|
6
|
-
it("缺省段按 0 补齐:1.2 == 1.2.0", () => assert.equal(compareVersions("1.2", "1.2.0"), 0));
|
|
7
|
-
it("容忍 v 前缀", () => assert.equal(compareVersions("v2.0.0", "2.0.0"), 0));
|
|
8
|
-
it("a < b", () => assert.equal(compareVersions("1.0.0", "1.0.1"), -1));
|
|
9
|
-
it("忽略预发布后缀(核心相等)", () => assert.equal(compareVersions("1.0.0-beta", "1.0.0"), 0));
|
|
10
|
-
it("非数字版本退化为字符串比较", () => assert.equal(compareVersions("abc", "abc"), 0));
|
|
11
|
-
});
|
|
12
|
-
describe("isOutdated", () => {
|
|
13
|
-
it("本地落后 → true", () => assert.equal(isOutdated("1.0.0", "1.1.0"), true));
|
|
14
|
-
it("本地更新 → false", () => assert.equal(isOutdated("2.0.0", "1.9.0"), false));
|
|
15
|
-
it("相等 → false", () => assert.equal(isOutdated("1.2.3", "1.2.3"), false));
|
|
16
|
-
it("1.9 vs 1.10:应判过期(数值语义)", () => assert.equal(isOutdated("1.9.0", "1.10.0"), true));
|
|
17
|
-
it("缺本地版本 → 不判过期", () => assert.equal(isOutdated(undefined, "1.0.0"), false));
|
|
18
|
-
it("缺最新版本 → 不判过期", () => assert.equal(isOutdated("1.0.0", undefined), false));
|
|
19
|
-
it("非 semver 且不同 → 过期(保守)", () => assert.equal(isOutdated("alpha", "beta"), true));
|
|
20
|
-
it("带预发布后缀核心相同 → 不过期", () => assert.equal(isOutdated("2026-01", "2026-02"), false));
|
|
21
|
-
});
|
package/dist/skill-version.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 从 SKILL.md 文本中提取版本号(纯正则、不借助任何模型)。
|
|
3
|
-
*
|
|
4
|
-
* 兼容点:
|
|
5
|
-
* - 键名:英文 `version` / `Version`(大小写不敏感)、中文 `版本号` / `版本`;
|
|
6
|
-
* - 冒号:半角 `:` 或全角 `:`;
|
|
7
|
-
* - 取值:去除包裹引号、行尾 ` # 注释`、首尾空白。
|
|
8
|
-
*
|
|
9
|
-
* 范围:在整篇 SKILL.md 内按「单独一行」匹配(frontmatter 在最前,故天然优先命中)。
|
|
10
|
-
* 因要求行首即键名(前面只允许空白),Markdown 标题 `## Version: x`(带 `#` 前缀)不会被误命中。
|
|
11
|
-
* 取不到返回 undefined(该 skill 不参与版本更新)。
|
|
12
|
-
*/
|
|
13
|
-
export function parseSkillVersion(content) {
|
|
14
|
-
const m = /(?:^|\r?\n)[ \t]*(?:version|版本号|版本)[ \t]*[::][ \t]*(.+)/i.exec(content);
|
|
15
|
-
if (!m)
|
|
16
|
-
return undefined;
|
|
17
|
-
const v = m[1]
|
|
18
|
-
.replace(/\s+#.*$/, "") // 行尾注释
|
|
19
|
-
.trim()
|
|
20
|
-
.replace(/^["']|["']$/g, "") // 包裹引号
|
|
21
|
-
.trim();
|
|
22
|
-
return v || undefined;
|
|
23
|
-
}
|
package/dist/types.js
DELETED
package/dist/updater.js
DELETED
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* skill 版本自动更新器。
|
|
3
|
-
*
|
|
4
|
-
* 触发:reconcile 检测到本地某 skill 版本落后于平台最新版本(config.version)后调用。
|
|
5
|
-
* 流程(每个 skill@version 仅下载一次,覆盖其全部 workspace 副本):
|
|
6
|
-
* 1. POST {platformBaseUrl}/skill_package/pull {skillName, version} → { url }
|
|
7
|
-
* 2. GET url → zip 字节 → 写入临时文件
|
|
8
|
-
* 3. 解压到临时 staging 目录(默认调用系统 unzip)
|
|
9
|
-
* 4. 在 staging 内定位含 SKILL.md 的目录作为源根(兼容包内带或不带顶层目录)
|
|
10
|
-
* 5. 逐个目标目录「直接覆盖、不备份」:先复制到同级临时目录,再删旧、原子 rename 换入,
|
|
11
|
-
* 避免中途失败留下损坏的半成品;不保留 .bak。
|
|
12
|
-
*
|
|
13
|
-
* 安全约束:
|
|
14
|
-
* - 仅在 autoUpdateSkills===true 且配置了 platformBaseUrl 时执行,默认关闭。
|
|
15
|
-
* - 任一步失败只 warn 并跳过该 skill,绝不抛、绝不阻塞 reconcile。
|
|
16
|
-
* - 下载包内必须含 SKILL.md 才会覆盖,否则放弃(防止把目标目录清空成坏内容)。
|
|
17
|
-
*/
|
|
18
|
-
import fs from "node:fs/promises";
|
|
19
|
-
import path from "node:path";
|
|
20
|
-
import os from "node:os";
|
|
21
|
-
import { execFile } from "node:child_process";
|
|
22
|
-
import { promisify } from "node:util";
|
|
23
|
-
import { randomUUID, createHash } from "node:crypto";
|
|
24
|
-
import { parseSkillVersion } from "./skill-version.ts";
|
|
25
|
-
const execFileAsync = promisify(execFile);
|
|
26
|
-
/**
|
|
27
|
-
* 同一 skill@version 的更新尝试冷却期。
|
|
28
|
-
* 防止「下载包版本号与平台 latestVersion 不一致 / 非 semver 版本永不相等」等情况下,
|
|
29
|
-
* 每 3 分钟一轮 reconcile 反复下载覆盖。成功更新会改变本地版本从而不再判过期,故冷却只影响失败/不一致场景。
|
|
30
|
-
*/
|
|
31
|
-
const ATTEMPT_COOLDOWN_MS = 30 * 60 * 1000;
|
|
32
|
-
/**
|
|
33
|
-
* 轻量「身份哈希」:name + code + version。约定 name==code(SKILL.md 的 name 即 skillCode),
|
|
34
|
-
* 故按 `code code version`(空格分隔)计算 sha256。与服务端 skillIdentityHash 必须保持一致。
|
|
35
|
-
* 仅做身份/版本一致性校验,不做整包字节哈希。
|
|
36
|
-
*/
|
|
37
|
-
function skillIdentityHash(code, version) {
|
|
38
|
-
return createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
|
|
39
|
-
}
|
|
40
|
-
/** 默认解压:系统 unzip -o -q(覆盖、静默)。 */
|
|
41
|
-
async function systemUnzip(zipPath, destDir) {
|
|
42
|
-
await fs.mkdir(destDir, { recursive: true });
|
|
43
|
-
await execFileAsync("unzip", ["-o", "-q", zipPath, "-d", destDir]);
|
|
44
|
-
}
|
|
45
|
-
export class SkillUpdater {
|
|
46
|
-
getConfig;
|
|
47
|
-
fetchImpl;
|
|
48
|
-
unzip;
|
|
49
|
-
tmpDir;
|
|
50
|
-
/** 冷却期状态持久化文件路径。 */
|
|
51
|
-
cooldownStatePath;
|
|
52
|
-
/** skill@version → 上次尝试时间戳,用于冷却,避免重复下载覆盖。 */
|
|
53
|
-
lastAttempt = new Map();
|
|
54
|
-
constructor(opts) {
|
|
55
|
-
this.getConfig = opts.getConfig;
|
|
56
|
-
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
57
|
-
this.unzip = opts.unzip ?? systemUnzip;
|
|
58
|
-
this.tmpDir = opts.tmpDir ?? os.tmpdir();
|
|
59
|
-
this.cooldownStatePath = opts.cooldownStatePath;
|
|
60
|
-
this.loadCooldown(); // 同步读取,启动早期就需要用到冷却期
|
|
61
|
-
}
|
|
62
|
-
get isDebug() {
|
|
63
|
-
return this.getConfig().debugLogging !== false;
|
|
64
|
-
}
|
|
65
|
-
debug(...args) {
|
|
66
|
-
if (this.isDebug)
|
|
67
|
-
console.log("[skill-logger-plugin/updater]", ...args);
|
|
68
|
-
}
|
|
69
|
-
/** 从文件同步加载冷却期状态到内存Map。文件不存在或解析失败时静默跳过。 */
|
|
70
|
-
loadCooldown() {
|
|
71
|
-
if (!this.cooldownStatePath)
|
|
72
|
-
return;
|
|
73
|
-
try {
|
|
74
|
-
const raw = fs.readFileSync(this.cooldownStatePath, "utf-8");
|
|
75
|
-
const obj = JSON.parse(raw);
|
|
76
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
77
|
-
if (typeof v === "number" && Number.isFinite(v)) {
|
|
78
|
-
this.lastAttempt.set(k, v);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
this.debug(`冷却期状态已从文件恢复,共 ${this.lastAttempt.size} 条`);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// 文件不存在或解析失败 → 用空Map,插件继续正常工作
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/** 将内存中的冷却期状态写入文件。写入失败时静默跳过,不影响主流程。 */
|
|
88
|
-
async persistCooldown() {
|
|
89
|
-
if (!this.cooldownStatePath)
|
|
90
|
-
return;
|
|
91
|
-
try {
|
|
92
|
-
const obj = {};
|
|
93
|
-
for (const [k, v] of this.lastAttempt) {
|
|
94
|
-
obj[k] = v;
|
|
95
|
-
}
|
|
96
|
-
await fs.writeFile(this.cooldownStatePath, JSON.stringify(obj), "utf-8");
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// 写入失败不影响主流程,下次会继续尝试
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
/** 对一批落后副本执行更新。按 skill@version 去重下载,覆盖各自的全部目标目录。 */
|
|
103
|
-
async applyUpdates(outdated) {
|
|
104
|
-
const cfg = this.getConfig();
|
|
105
|
-
if (cfg.autoUpdateSkills === false)
|
|
106
|
-
return; // 默认开启,仅显式 false 关闭
|
|
107
|
-
if (!cfg.platformBaseUrl)
|
|
108
|
-
return; // 无平台地址无法下载
|
|
109
|
-
if (outdated.length === 0)
|
|
110
|
-
return;
|
|
111
|
-
const groups = new Map();
|
|
112
|
-
for (const o of outdated) {
|
|
113
|
-
const key = `${o.skillName}@${o.latestVersion}`;
|
|
114
|
-
let g = groups.get(key);
|
|
115
|
-
if (!g) {
|
|
116
|
-
g = { skillName: o.skillName, version: o.latestVersion, targets: [] };
|
|
117
|
-
groups.set(key, g);
|
|
118
|
-
}
|
|
119
|
-
if (!g.targets.includes(o.rootDir))
|
|
120
|
-
g.targets.push(o.rootDir);
|
|
121
|
-
}
|
|
122
|
-
for (const g of groups.values()) {
|
|
123
|
-
const key = `${g.skillName}@${g.version}`;
|
|
124
|
-
const last = this.lastAttempt.get(key);
|
|
125
|
-
if (last !== undefined && Date.now() - last < ATTEMPT_COOLDOWN_MS) {
|
|
126
|
-
this.debug(`跳过 ${key}:距上次尝试不足冷却期(防重复下载覆盖)`);
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
this.lastAttempt.set(key, Date.now()); // 不论成败都记,失败也按冷却退避,避免每 3 分钟重试
|
|
130
|
-
await this.persistCooldown(); // 持久化到文件,重启后冷却期不丢失
|
|
131
|
-
try {
|
|
132
|
-
await this.updateOne(g.skillName, g.version, g.targets);
|
|
133
|
-
}
|
|
134
|
-
catch (err) {
|
|
135
|
-
console.warn(`[skill-logger-plugin] 自动更新 ${key} 异常`, err);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
async updateOne(skillName, version, targets) {
|
|
140
|
-
const dl = await this.fetchDownloadUrl(skillName, version);
|
|
141
|
-
if (!dl?.url)
|
|
142
|
-
return;
|
|
143
|
-
const work = path.join(this.tmpDir, `slp-update-${randomUUID()}`);
|
|
144
|
-
await fs.mkdir(work, { recursive: true });
|
|
145
|
-
try {
|
|
146
|
-
const zipPath = path.join(work, "pkg.zip");
|
|
147
|
-
const res = await this.fetchImpl(dl.url);
|
|
148
|
-
if (!res.ok) {
|
|
149
|
-
console.warn(`[skill-logger-plugin] 下载 ${skillName}@${version} 失败 HTTP`, res.status);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
153
|
-
await fs.writeFile(zipPath, buf);
|
|
154
|
-
const staging = path.join(work, "staging");
|
|
155
|
-
await this.unzip(zipPath, staging);
|
|
156
|
-
const srcRoot = await this.locateSkillRoot(staging, 0);
|
|
157
|
-
if (!srcRoot) {
|
|
158
|
-
console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包内未找到 SKILL.md,放弃覆盖`);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
// [兼容处理] 缺少 .meta.json 时主动补齐
|
|
162
|
-
const metaPath = path.join(srcRoot, ".meta.json");
|
|
163
|
-
if (!(await this.exists(metaPath))) {
|
|
164
|
-
this.debug(`[skill-logger-plugin] 为 ${skillName}@${version} 自动生成 .meta.json`);
|
|
165
|
-
await fs.writeFile(metaPath, JSON.stringify({
|
|
166
|
-
ownerId: 'CMS_COMPAT',
|
|
167
|
-
slug: skillName,
|
|
168
|
-
version: version,
|
|
169
|
-
publishedAt: Date.now()
|
|
170
|
-
}, null, 2));
|
|
171
|
-
}
|
|
172
|
-
// 守卫:下载包的 SKILL.md 必须能解析出版本号,否则不做覆盖(服务端要求:无版本号不更新)。
|
|
173
|
-
let packageVersion;
|
|
174
|
-
try {
|
|
175
|
-
const newSkillMd = await fs.readFile(path.join(srcRoot, "SKILL.md"), "utf-8");
|
|
176
|
-
packageVersion = parseSkillVersion(newSkillMd);
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
console.warn(`[skill-logger-plugin] ${skillName}@${version} 读取下载包 SKILL.md 失败,放弃覆盖`);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (!packageVersion) {
|
|
183
|
-
console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包 SKILL.md 无版本号,放弃覆盖`);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
for (const target of targets) {
|
|
187
|
-
try {
|
|
188
|
-
await this.replaceDir(srcRoot, target);
|
|
189
|
-
this.debug(`已更新 ${skillName} → ${version} @ ${target}`);
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
console.warn(`[skill-logger-plugin] 覆盖 ${skillName} 到 ${target} 失败`, err);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
finally {
|
|
197
|
-
await fs.rm(work, { recursive: true, force: true });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
/** POST 下载接口取包 URL。返回 undefined 表示拿不到(调用方放弃本次更新)。 */
|
|
201
|
-
async fetchDownloadUrl(skillName, version) {
|
|
202
|
-
const cfg = this.getConfig();
|
|
203
|
-
const endpoint = cfg.platformBaseUrl.replace(/\/$/, "") + "/skill_package/pull";
|
|
204
|
-
const headers = { "Content-Type": "application/json" };
|
|
205
|
-
if (cfg.authToken)
|
|
206
|
-
headers.Authorization = cfg.authToken;
|
|
207
|
-
const res = await this.fetchImpl(endpoint, {
|
|
208
|
-
method: "POST",
|
|
209
|
-
headers,
|
|
210
|
-
body: JSON.stringify({ skillName, version }),
|
|
211
|
-
});
|
|
212
|
-
if (!res.ok) {
|
|
213
|
-
console.warn(`[skill-logger-plugin] 取下载地址 ${skillName}@${version} 失败 HTTP`, res.status);
|
|
214
|
-
return undefined;
|
|
215
|
-
}
|
|
216
|
-
const data = (await res.json());
|
|
217
|
-
const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : undefined;
|
|
218
|
-
const resolvedVersion = typeof data?.version === "string" ? data.version : undefined;
|
|
219
|
-
if (!url)
|
|
220
|
-
return undefined;
|
|
221
|
-
return { url, version: resolvedVersion };
|
|
222
|
-
}
|
|
223
|
-
/** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
|
|
224
|
-
async locateSkillRoot(dir, depth) {
|
|
225
|
-
if (depth > 2)
|
|
226
|
-
return undefined;
|
|
227
|
-
let entries;
|
|
228
|
-
try {
|
|
229
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
return undefined;
|
|
233
|
-
}
|
|
234
|
-
if (entries.some((e) => e.isFile() && e.name === "SKILL.md"))
|
|
235
|
-
return dir;
|
|
236
|
-
for (const e of entries) {
|
|
237
|
-
if (e.isDirectory()) {
|
|
238
|
-
const found = await this.locateSkillRoot(path.join(dir, e.name), depth + 1);
|
|
239
|
-
if (found)
|
|
240
|
-
return found;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* 直接覆盖、不备份,但保证「不丢数据」:
|
|
247
|
-
* 先把新内容暂存到同级临时目录 → 旧目录改名挪开 → 新内容 rename 换入 → 删掉挪开的旧目录。
|
|
248
|
-
* 换入失败时把旧目录还原,避免目标被清空。过程中的临时/旧目录都是隐藏且即时清理,不保留 .bak。
|
|
249
|
-
*/
|
|
250
|
-
async replaceDir(src, target) {
|
|
251
|
-
const parent = path.dirname(target);
|
|
252
|
-
await fs.mkdir(parent, { recursive: true });
|
|
253
|
-
const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
254
|
-
const stage = path.join(parent, `.${path.basename(target)}.new-${tag}`);
|
|
255
|
-
const old = path.join(parent, `.${path.basename(target)}.old-${tag}`);
|
|
256
|
-
await fs.rm(stage, { recursive: true, force: true });
|
|
257
|
-
await fs.cp(src, stage, { recursive: true });
|
|
258
|
-
const hadTarget = await this.exists(target);
|
|
259
|
-
if (hadTarget)
|
|
260
|
-
await fs.rename(target, old); // 旧目录先挪开(仍完整保留,便于回滚)
|
|
261
|
-
try {
|
|
262
|
-
await fs.rename(stage, target); // 新内容换入
|
|
263
|
-
}
|
|
264
|
-
catch (err) {
|
|
265
|
-
// 换入失败:还原旧目录,清理暂存,确保目标不丢
|
|
266
|
-
if (hadTarget)
|
|
267
|
-
await fs.rename(old, target).catch(() => { });
|
|
268
|
-
await fs.rm(stage, { recursive: true, force: true }).catch(() => { });
|
|
269
|
-
throw err;
|
|
270
|
-
}
|
|
271
|
-
await fs.rm(old, { recursive: true, force: true }).catch(() => { }); // 成功后清理,不留备份
|
|
272
|
-
}
|
|
273
|
-
async exists(p) {
|
|
274
|
-
try {
|
|
275
|
-
await fs.access(p);
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
catch {
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* 手动下发安装/更新指令 (供沙盒 HTTP Proxy 与星型架构中控使用)
|
|
284
|
-
*/
|
|
285
|
-
async manualInstall(options) {
|
|
286
|
-
const { code, force, targetDir } = options;
|
|
287
|
-
let { url, version } = options;
|
|
288
|
-
if (!url) {
|
|
289
|
-
// 若没有直接传入 ZIP 的 URL,向平台索取该 skill 的 latest 下载地址
|
|
290
|
-
const dl = await this.fetchDownloadUrl(code, version || "latest");
|
|
291
|
-
if (!dl?.url) {
|
|
292
|
-
return { success: false, message: `无法获取技能 ${code} 的下载地址` };
|
|
293
|
-
}
|
|
294
|
-
url = dl.url;
|
|
295
|
-
version = dl.version || version;
|
|
296
|
-
}
|
|
297
|
-
const targetSkillPath = path.join(targetDir, code);
|
|
298
|
-
// 如果非强制覆盖,且目标已经存在,则直接返回
|
|
299
|
-
if (!force && await this.exists(targetSkillPath)) {
|
|
300
|
-
return { success: false, message: `技能 ${code} 已存在于目标目录` };
|
|
301
|
-
}
|
|
302
|
-
const work = path.join(this.tmpDir, `slp-manual-${randomUUID()}`);
|
|
303
|
-
await fs.mkdir(work, { recursive: true });
|
|
304
|
-
try {
|
|
305
|
-
// 1. 下载 ZIP
|
|
306
|
-
const zipPath = path.join(work, "pkg.zip");
|
|
307
|
-
const res = await this.fetchImpl(url);
|
|
308
|
-
if (!res.ok) {
|
|
309
|
-
return { success: false, message: `下载失败: HTTP ${res.status}` };
|
|
310
|
-
}
|
|
311
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
312
|
-
await fs.writeFile(zipPath, buf);
|
|
313
|
-
// 2. 调用系统解压工具 unzip
|
|
314
|
-
const staging = path.join(work, "staging");
|
|
315
|
-
await this.unzip(zipPath, staging);
|
|
316
|
-
// 3. 递归寻找真正的技能根目录(包含 SKILL.md 的一层)
|
|
317
|
-
const srcRoot = await this.locateSkillRoot(staging, 0);
|
|
318
|
-
if (!srcRoot) {
|
|
319
|
-
return { success: false, message: `下载包内未找到 SKILL.md,非法的技能包结构` };
|
|
320
|
-
}
|
|
321
|
-
// [兼容处理] 缺少 .meta.json 时主动补齐
|
|
322
|
-
const metaPath = path.join(srcRoot, ".meta.json");
|
|
323
|
-
if (!(await this.exists(metaPath))) {
|
|
324
|
-
let parsedVersion = version || "unknown";
|
|
325
|
-
if (!version) {
|
|
326
|
-
try {
|
|
327
|
-
const newSkillMd = await fs.readFile(path.join(srcRoot, "SKILL.md"), "utf-8");
|
|
328
|
-
parsedVersion = parseSkillVersion(newSkillMd) || "unknown";
|
|
329
|
-
}
|
|
330
|
-
catch { }
|
|
331
|
-
}
|
|
332
|
-
this.debug(`[skill-logger-plugin] 为手动安装的 ${code}@${parsedVersion} 自动生成 .meta.json`);
|
|
333
|
-
await fs.writeFile(metaPath, JSON.stringify({
|
|
334
|
-
ownerId: 'CMS_COMPAT',
|
|
335
|
-
slug: code,
|
|
336
|
-
version: parsedVersion,
|
|
337
|
-
publishedAt: Date.now()
|
|
338
|
-
}, null, 2));
|
|
339
|
-
}
|
|
340
|
-
// 4. 原子级无缝替换目录 (先暂存,再 rename)
|
|
341
|
-
await this.replaceDir(srcRoot, targetSkillPath);
|
|
342
|
-
return { success: true, message: `技能 ${code} 安装/更新成功` };
|
|
343
|
-
}
|
|
344
|
-
catch (err) {
|
|
345
|
-
return { success: false, message: `执行出错: ${err.message}` };
|
|
346
|
-
}
|
|
347
|
-
finally {
|
|
348
|
-
// 清理临时工作区
|
|
349
|
-
await fs.rm(work, { recursive: true, force: true }).catch(() => { });
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|