@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.13
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/active-skills.js +67 -0
- package/dist/active-skills.test.js +29 -0
- package/dist/config-sync.js +439 -0
- package/dist/config-sync.test.js +145 -0
- package/dist/hooks.js +337 -0
- package/dist/hooks.test.js +123 -0
- package/dist/http.js +54 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +240 -78
- package/dist/index.test.js +39 -0
- package/dist/integration.test.js +102 -0
- package/dist/matcher.js +362 -0
- package/dist/matcher.test.js +139 -0
- package/dist/paths.js +62 -0
- package/dist/paths.test.js +49 -0
- package/dist/reporter.js +267 -0
- package/dist/reporter.test.js +128 -0
- package/dist/semver.js +64 -0
- package/dist/semver.test.js +21 -0
- package/dist/skill-version.js +23 -0
- package/dist/types.js +9 -0
- package/dist/updater.js +352 -0
- package/dist/updater.test.js +212 -0
- package/dist/ws-client.js +484 -0
- 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 +325 -237
- package/src/updater.ts +549 -433
- package/src/ws-client.test.ts +48 -37
- package/src/ws-client.ts +717 -642
- package/test-ws.ts +17 -17
- package/tsconfig.json +14 -14
package/src/updater.test.ts
CHANGED
|
@@ -1,237 +1,325 @@
|
|
|
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 { SkillUpdater, type OutdatedCopy } from "./updater.ts";
|
|
8
|
-
import type { PluginConfig } from "./types.ts";
|
|
9
|
-
|
|
10
|
-
/** 与 updater/服务端一致的身份哈希:code code version(空格分隔)。 */
|
|
11
|
-
const idHash = (code: string, version: string) =>
|
|
12
|
-
createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
|
|
13
|
-
|
|
14
|
-
let dir: string;
|
|
15
|
-
|
|
16
|
-
beforeEach(async () => {
|
|
17
|
-
dir = path.join(os.tmpdir(), `slp-updater-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
18
|
-
await fs.mkdir(dir, { recursive: true });
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(async () => {
|
|
22
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
/** 注入的假 fetch:POST 取下载地址返回 {url},GET 下载返回固定 zip 字节。 */
|
|
26
|
-
function makeFetch(opts: { urlOk?: boolean; downloadOk?: boolean; calls?: string[] } = {}) {
|
|
27
|
-
const calls = opts.calls ?? [];
|
|
28
|
-
return async (url: string, init?: RequestInit) => {
|
|
29
|
-
calls.push(url);
|
|
30
|
-
if (init?.method === "POST") {
|
|
31
|
-
return {
|
|
32
|
-
ok: opts.urlOk !== false,
|
|
33
|
-
status: opts.urlOk !== false ? 200 : 500,
|
|
34
|
-
json: async () => ({ url: "https://pkg.example/skill.zip" }),
|
|
35
|
-
arrayBuffer: async () => new ArrayBuffer(0),
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
// GET 下载
|
|
39
|
-
return {
|
|
40
|
-
ok: opts.downloadOk !== false,
|
|
41
|
-
status: opts.downloadOk !== false ? 200 : 500,
|
|
42
|
-
json: async () => ({}),
|
|
43
|
-
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
44
|
-
};
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 注入的假 unzip:把「新版本」内容写进 staging(含 SKILL.md),模拟解压结果。 */
|
|
49
|
-
function makeUnzip(newVersion: string, withSkillMd = true) {
|
|
50
|
-
return async (_zipPath: string, destDir: string) => {
|
|
51
|
-
const root = path.join(destDir, "skill-root");
|
|
52
|
-
await fs.mkdir(root, { recursive: true });
|
|
53
|
-
if (withSkillMd) {
|
|
54
|
-
await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${newVersion}\n---\nNEW\n`);
|
|
55
|
-
await fs.writeFile(path.join(root, "new-file.txt"), "added by update");
|
|
56
|
-
} else {
|
|
57
|
-
await fs.writeFile(path.join(root, "note.txt"), "no skill md here");
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function makeTarget(name: string): Promise<string> {
|
|
63
|
-
const target = path.join(dir, "workspace", "skills", name);
|
|
64
|
-
await fs.mkdir(target, { recursive: true });
|
|
65
|
-
await fs.writeFile(path.join(target, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
|
|
66
|
-
await fs.writeFile(path.join(target, "old-file.txt"), "should be gone after update");
|
|
67
|
-
return target;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const outdatedFor = (target: string): OutdatedCopy[] => [
|
|
71
|
-
{ skillName: "demo", rootDir: target, localVersion: "1.0.0", latestVersion: "2.0.0" },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
describe("SkillUpdater", () => {
|
|
75
|
-
it("
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assert.
|
|
110
|
-
assert.
|
|
111
|
-
assert.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
assert.equal(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
await
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
assert.ok(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const calls: string[] = [];
|
|
212
|
-
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
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 { extractorCommandForPlatform, SkillUpdater, type OutdatedCopy } from "./updater.ts";
|
|
8
|
+
import type { PluginConfig } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** 与 updater/服务端一致的身份哈希:code code version(空格分隔)。 */
|
|
11
|
+
const idHash = (code: string, version: string) =>
|
|
12
|
+
createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
|
|
13
|
+
|
|
14
|
+
let dir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
dir = path.join(os.tmpdir(), `slp-updater-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
18
|
+
await fs.mkdir(dir, { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** 注入的假 fetch:POST 取下载地址返回 {url},GET 下载返回固定 zip 字节。 */
|
|
26
|
+
function makeFetch(opts: { urlOk?: boolean; downloadOk?: boolean; calls?: string[] } = {}) {
|
|
27
|
+
const calls = opts.calls ?? [];
|
|
28
|
+
return async (url: string, init?: RequestInit) => {
|
|
29
|
+
calls.push(url);
|
|
30
|
+
if (init?.method === "POST") {
|
|
31
|
+
return {
|
|
32
|
+
ok: opts.urlOk !== false,
|
|
33
|
+
status: opts.urlOk !== false ? 200 : 500,
|
|
34
|
+
json: async () => ({ url: "https://pkg.example/skill.zip" }),
|
|
35
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// GET 下载
|
|
39
|
+
return {
|
|
40
|
+
ok: opts.downloadOk !== false,
|
|
41
|
+
status: opts.downloadOk !== false ? 200 : 500,
|
|
42
|
+
json: async () => ({}),
|
|
43
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 注入的假 unzip:把「新版本」内容写进 staging(含 SKILL.md),模拟解压结果。 */
|
|
49
|
+
function makeUnzip(newVersion: string, withSkillMd = true) {
|
|
50
|
+
return async (_zipPath: string, destDir: string) => {
|
|
51
|
+
const root = path.join(destDir, "skill-root");
|
|
52
|
+
await fs.mkdir(root, { recursive: true });
|
|
53
|
+
if (withSkillMd) {
|
|
54
|
+
await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${newVersion}\n---\nNEW\n`);
|
|
55
|
+
await fs.writeFile(path.join(root, "new-file.txt"), "added by update");
|
|
56
|
+
} else {
|
|
57
|
+
await fs.writeFile(path.join(root, "note.txt"), "no skill md here");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function makeTarget(name: string): Promise<string> {
|
|
63
|
+
const target = path.join(dir, "workspace", "skills", name);
|
|
64
|
+
await fs.mkdir(target, { recursive: true });
|
|
65
|
+
await fs.writeFile(path.join(target, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
|
|
66
|
+
await fs.writeFile(path.join(target, "old-file.txt"), "should be gone after update");
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const outdatedFor = (target: string): OutdatedCopy[] => [
|
|
71
|
+
{ skillName: "demo", rootDir: target, localVersion: "1.0.0", latestVersion: "2.0.0" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
describe("SkillUpdater", () => {
|
|
75
|
+
it("macOS 使用 ditto 解压,其他平台保持 unzip", () => {
|
|
76
|
+
assert.deepEqual(extractorCommandForPlatform("darwin", "/tmp/a.zip", "/tmp/out"), {
|
|
77
|
+
command: "ditto",
|
|
78
|
+
args: ["-x", "-k", "/tmp/a.zip", "/tmp/out"],
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(extractorCommandForPlatform("linux", "/tmp/a.zip", "/tmp/out"), {
|
|
81
|
+
command: "unzip",
|
|
82
|
+
args: ["-o", "-q", "/tmp/a.zip", "-d", "/tmp/out"],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("手动更新可复用同一次下载,同时覆盖用户与内置模板目录并记录关键阶段", async () => {
|
|
87
|
+
const calls: string[] = [];
|
|
88
|
+
const traces: Array<{ stage: string; data?: Record<string, unknown> }> = [];
|
|
89
|
+
const userSkillsDir = path.join(dir, "workspace-assistant-12345", "skills");
|
|
90
|
+
const templateSkillsDir = path.join(dir, "workspace-xgjk-assistant-template", "skills");
|
|
91
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api" };
|
|
92
|
+
const u = new SkillUpdater({
|
|
93
|
+
getConfig: () => cfg,
|
|
94
|
+
fetchImpl: makeFetch({ calls }),
|
|
95
|
+
unzip: makeUnzip("2.0.0"),
|
|
96
|
+
tmpDir: dir,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await u.manualInstall({
|
|
100
|
+
code: "demo",
|
|
101
|
+
url: "https://pkg.example/skill.zip?secret=hidden",
|
|
102
|
+
version: "2.0.0",
|
|
103
|
+
force: true,
|
|
104
|
+
targetDir: userSkillsDir,
|
|
105
|
+
additionalTargetDirs: [templateSkillsDir],
|
|
106
|
+
trace: (stage, data) => traces.push({ stage, data }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
assert.equal(result.success, true);
|
|
110
|
+
assert.equal(calls.length, 1, "用户目录与模板目录应复用同一次下载");
|
|
111
|
+
assert.ok((await fs.readFile(path.join(userSkillsDir, "demo", "SKILL.md"), "utf-8")).includes("NEW"));
|
|
112
|
+
assert.ok((await fs.readFile(path.join(templateSkillsDir, "demo", "SKILL.md"), "utf-8")).includes("NEW"));
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
traces.map((item) => item.stage),
|
|
115
|
+
[
|
|
116
|
+
"install.start",
|
|
117
|
+
"download.request.start",
|
|
118
|
+
"download.headers.received",
|
|
119
|
+
"download.body.completed",
|
|
120
|
+
"download.file.written",
|
|
121
|
+
"unzip.start",
|
|
122
|
+
"unzip.completed",
|
|
123
|
+
"skill_root.located",
|
|
124
|
+
"target.replace.start",
|
|
125
|
+
"target.replace.completed",
|
|
126
|
+
"target.replace.start",
|
|
127
|
+
"target.replace.completed",
|
|
128
|
+
"install.completed",
|
|
129
|
+
"cleanup.completed",
|
|
130
|
+
],
|
|
131
|
+
);
|
|
132
|
+
assert.equal(
|
|
133
|
+
traces[1]?.data?.url,
|
|
134
|
+
"https://pkg.example/skill.zip?secret=hidden",
|
|
135
|
+
"诊断日志应记录实际请求的完整下载 URL",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("普通手动安装未传附加目录时只覆盖用户目录", async () => {
|
|
140
|
+
const userSkillsDir = path.join(dir, "workspace-assistant-12345", "skills");
|
|
141
|
+
const templateSkillsDir = path.join(dir, "workspace-xgjk-assistant-template", "skills");
|
|
142
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api" };
|
|
143
|
+
const u = new SkillUpdater({
|
|
144
|
+
getConfig: () => cfg,
|
|
145
|
+
fetchImpl: makeFetch(),
|
|
146
|
+
unzip: makeUnzip("2.0.0"),
|
|
147
|
+
tmpDir: dir,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await u.manualInstall({
|
|
151
|
+
code: "demo",
|
|
152
|
+
url: "https://pkg.example/skill.zip",
|
|
153
|
+
version: "2.0.0",
|
|
154
|
+
force: true,
|
|
155
|
+
targetDir: userSkillsDir,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
assert.equal(result.success, true);
|
|
159
|
+
assert.equal(await fileExists(path.join(userSkillsDir, "demo", "SKILL.md")), true);
|
|
160
|
+
assert.equal(await fileExists(templateSkillsDir), false, "普通安装不应创建或修改内置模板目录");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("autoUpdateSkills 显式 false:不发请求、不动文件", async () => {
|
|
164
|
+
const target = await makeTarget("demo");
|
|
165
|
+
const calls: string[] = [];
|
|
166
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: false };
|
|
167
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
168
|
+
await u.applyUpdates(outdatedFor(target));
|
|
169
|
+
assert.equal(calls.length, 0);
|
|
170
|
+
assert.equal((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("autoUpdateSkills 未设置(默认开启):执行更新", async () => {
|
|
174
|
+
const target = await makeTarget("demo");
|
|
175
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api" }; // 不设 autoUpdateSkills → 默认开
|
|
176
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
177
|
+
await u.applyUpdates(outdatedFor(target));
|
|
178
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "默认应执行更新");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("未配置 platformBaseUrl:不更新", async () => {
|
|
182
|
+
const target = await makeTarget("demo");
|
|
183
|
+
const cfg: PluginConfig = {}; // 无平台地址
|
|
184
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
185
|
+
await u.applyUpdates(outdatedFor(target));
|
|
186
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("开启后:下载→解压→覆盖目标目录(直接覆盖,旧文件消失,无 .bak)", async () => {
|
|
190
|
+
const target = await makeTarget("demo");
|
|
191
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
192
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
193
|
+
await u.applyUpdates(outdatedFor(target));
|
|
194
|
+
|
|
195
|
+
const skillMd = await fs.readFile(path.join(target, "SKILL.md"), "utf-8");
|
|
196
|
+
assert.ok(skillMd.includes("NEW"), "SKILL.md 应被更新为新内容");
|
|
197
|
+
assert.ok(skillMd.includes("version: 2.0.0"), "版本应升级");
|
|
198
|
+
assert.ok(await fileExists(path.join(target, "new-file.txt")), "新增文件应出现");
|
|
199
|
+
assert.equal(await fileExists(path.join(target, "old-file.txt")), false, "旧文件应被覆盖移除");
|
|
200
|
+
// 不留备份
|
|
201
|
+
const siblings = await fs.readdir(path.join(dir, "workspace", "skills"));
|
|
202
|
+
assert.ok(!siblings.some((f) => f.includes(".bak")), "不应留下 .bak 备份");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("一个 skill 装在多个 workspace:仅下载一次,覆盖全部副本", async () => {
|
|
206
|
+
const t1 = await makeTarget("demo");
|
|
207
|
+
const t2Dir = path.join(dir, "workspace-coder", "skills", "demo");
|
|
208
|
+
await fs.mkdir(t2Dir, { recursive: true });
|
|
209
|
+
await fs.writeFile(path.join(t2Dir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD2\n");
|
|
210
|
+
|
|
211
|
+
const calls: string[] = [];
|
|
212
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
213
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
214
|
+
await u.applyUpdates([
|
|
215
|
+
{ skillName: "demo", rootDir: t1, localVersion: "1.0.0", latestVersion: "2.0.0" },
|
|
216
|
+
{ skillName: "demo", rootDir: t2Dir, localVersion: "1.0.0", latestVersion: "2.0.0" },
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
|
|
220
|
+
assert.equal(postCalls.length, 1, "同 skill@version 只取一次下载地址");
|
|
221
|
+
assert.ok((await fs.readFile(path.join(t1, "SKILL.md"), "utf-8")).includes("NEW"));
|
|
222
|
+
assert.ok((await fs.readFile(path.join(t2Dir, "SKILL.md"), "utf-8")).includes("NEW"));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("下载包 SKILL.md 无版本号:放弃覆盖(服务端要求无版本不更新)", async () => {
|
|
226
|
+
const target = await makeTarget("demo");
|
|
227
|
+
// 解压出的 SKILL.md 不含版本号
|
|
228
|
+
const unzipNoVersion = async (_z: string, destDir: string) => {
|
|
229
|
+
const root = path.join(destDir, "skill-root");
|
|
230
|
+
await fs.mkdir(root, { recursive: true });
|
|
231
|
+
await fs.writeFile(path.join(root, "SKILL.md"), "---\nname: demo\n---\nNEW but no version\n");
|
|
232
|
+
};
|
|
233
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
234
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: unzipNoVersion, tmpDir: dir });
|
|
235
|
+
await u.applyUpdates(outdatedFor(target));
|
|
236
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "无版本号时应保持原样");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/** 构造一个 POST 返回指定 sha256 的 fetch。 */
|
|
240
|
+
const makeFetchWithHash = (sha256: string) => async (url: string, init?: RequestInit) => {
|
|
241
|
+
if (init?.method === "POST") {
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
status: 200,
|
|
245
|
+
json: async () => ({ url: "https://pkg.example/skill.zip", sha256 }),
|
|
246
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it("身份哈希一致:正常覆盖", async () => {
|
|
253
|
+
const target = await makeTarget("demo");
|
|
254
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
255
|
+
// 服务端按 demo + 包内版本 2.0.0 计算哈希;包内 SKILL.md 也是 2.0.0 → 一致
|
|
256
|
+
const u = new SkillUpdater({
|
|
257
|
+
getConfig: () => cfg,
|
|
258
|
+
fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
|
|
259
|
+
unzip: makeUnzip("2.0.0"),
|
|
260
|
+
tmpDir: dir,
|
|
261
|
+
});
|
|
262
|
+
await u.applyUpdates(outdatedFor(target));
|
|
263
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "哈希一致应覆盖");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("身份哈希不一致:放弃覆盖", async () => {
|
|
267
|
+
const target = await makeTarget("demo");
|
|
268
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
269
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetchWithHash("deadbeef"), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
270
|
+
await u.applyUpdates(outdatedFor(target));
|
|
271
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "校验失败应保持原样");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("包内版本与服务端声称不符(哈希对不上):放弃覆盖", async () => {
|
|
275
|
+
const target = await makeTarget("demo");
|
|
276
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
277
|
+
// 服务端按声称的最新版本 2.0.0 算哈希,但下载包实际是 9.9.9 → 包内版本算出的哈希对不上
|
|
278
|
+
const u = new SkillUpdater({
|
|
279
|
+
getConfig: () => cfg,
|
|
280
|
+
fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
|
|
281
|
+
unzip: makeUnzip("9.9.9"),
|
|
282
|
+
tmpDir: dir,
|
|
283
|
+
});
|
|
284
|
+
await u.applyUpdates(outdatedFor(target));
|
|
285
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "版本错配应放弃覆盖");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("下载包内无 SKILL.md:放弃覆盖,目标目录保持原样", async () => {
|
|
289
|
+
const target = await makeTarget("demo");
|
|
290
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
291
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0", false), tmpDir: dir });
|
|
292
|
+
await u.applyUpdates(outdatedFor(target));
|
|
293
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "目标应保持旧内容");
|
|
294
|
+
assert.ok(await fileExists(path.join(target, "old-file.txt")), "目标旧文件应仍在");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("冷却:同 skill@version 失败后短期内不重复尝试", async () => {
|
|
298
|
+
const target = await makeTarget("demo");
|
|
299
|
+
const calls: string[] = [];
|
|
300
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
301
|
+
// 取下载地址一直失败 → 每次尝试都不会成功
|
|
302
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false, calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
303
|
+
await u.applyUpdates(outdatedFor(target));
|
|
304
|
+
await u.applyUpdates(outdatedFor(target)); // 第二轮应被冷却拦下
|
|
305
|
+
const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
|
|
306
|
+
assert.equal(postCalls.length, 1, "冷却期内不应重复发起下载");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("取下载地址失败:不动目标目录", async () => {
|
|
310
|
+
const target = await makeTarget("demo");
|
|
311
|
+
const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
|
|
312
|
+
const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
|
|
313
|
+
await u.applyUpdates(outdatedFor(target));
|
|
314
|
+
assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
319
|
+
try {
|
|
320
|
+
await fs.access(p);
|
|
321
|
+
return true;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|