@zhuoyuezs/ml-platform 0.1.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.
- package/DEVELOPMENT.md +189 -0
- package/README.md +103 -0
- package/checksums.json +110 -0
- package/package.json +29 -0
- package/release-policy.json +31 -0
- package/release.json +42 -0
- package/runtime/business-client/README.md +14 -0
- package/runtime/business-client/package-lock.json +19 -0
- package/runtime/business-client/package.json +21 -0
- package/runtime/business-client/src/catalog.js +184 -0
- package/runtime/business-client/src/cli.js +225 -0
- package/runtime/business-client/src/config.js +52 -0
- package/runtime/business-client/src/http.js +137 -0
- package/scripts/lib.js +819 -0
- package/scripts/main.js +92 -0
- package/skills/feature-management/SKILL.md +265 -0
- package/skills/feature-management/agents/openai.yaml +4 -0
- package/skills/feature-management/assets/catalog-template/catalog.json +23 -0
- package/skills/feature-management/assets/catalog-template/datasets/example_temperature_training.v1.json +24 -0
- package/skills/feature-management/assets/catalog-template/feature_sets/example_temperature_core.v1.json +13 -0
- package/skills/feature-management/assets/catalog-template/features/example_temperature_mean_5m.v1.json +21 -0
- package/skills/feature-management/assets/catalog-template/operator_package/pyproject.toml +12 -0
- package/skills/feature-management/assets/catalog-template/operator_package/src/business_feature_operator_template/__init__.py +39 -0
- package/skills/feature-management/assets/catalog-template/operator_package/tests/test_operator.py +56 -0
- package/skills/feature-management/assets/catalog-template/operators/example_temperature_features.v1.json +43 -0
- package/skills/feature-management/assets/catalog-template/parameters/example_temperature.v1.json +57 -0
- package/skills/feature-management/references/commands.md +244 -0
- package/skills/feature-management/references/contracts.md +682 -0
- package/skills/feature-management/references/operator-authoring.md +167 -0
package/scripts/lib.js
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { spawnSync } = require("child_process");
|
|
8
|
+
const RELEASE_POLICY = require("../release-policy.json");
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = "@zhuoyuezs/ml-platform";
|
|
11
|
+
const CLI_NAME = "ml-platform";
|
|
12
|
+
const SKILL_NAME = "feature-management";
|
|
13
|
+
const RELEASE_SCHEMA = "data_platform.ml_platform_release/v2";
|
|
14
|
+
const CHECKSUM_SCHEMA = "data_platform.ml_platform_checksums/v1";
|
|
15
|
+
const RECORD_SCHEMA = "data_platform.ml_platform_installation/v2";
|
|
16
|
+
const RUNTIME_RECORD_SCHEMA = "data_platform.ml_platform_runtime_installation/v1";
|
|
17
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
18
|
+
const SKILL_TOP_LEVEL = new Set(["SKILL.md", "agents", "assets", "references"]);
|
|
19
|
+
const CLIENT_TOP_LEVEL = new Set(["package.json", "package-lock.json", "README.md", "src"]);
|
|
20
|
+
if (RELEASE_POLICY.schema_version !== "data_platform.ml_platform_release_policy/v1") {
|
|
21
|
+
throw new Error("不支持的 release policy schema");
|
|
22
|
+
}
|
|
23
|
+
const FORBIDDEN_DIRECTORIES = new Set(RELEASE_POLICY.forbidden_directories);
|
|
24
|
+
const FORBIDDEN_FILES = new Set(RELEASE_POLICY.forbidden_files);
|
|
25
|
+
const FORBIDDEN_SUFFIXES = new Set(RELEASE_POLICY.forbidden_suffixes);
|
|
26
|
+
|
|
27
|
+
function parseOptions(args) {
|
|
28
|
+
const options = {
|
|
29
|
+
agent: "codex",
|
|
30
|
+
scope: "user",
|
|
31
|
+
upgrade: false,
|
|
32
|
+
allowDowngrade: false,
|
|
33
|
+
prewarm: true,
|
|
34
|
+
};
|
|
35
|
+
const valueFlags = new Map([
|
|
36
|
+
["--agent", "agent"],
|
|
37
|
+
["--scope", "scope"],
|
|
38
|
+
["--skills-dir", "skillsDir"],
|
|
39
|
+
["--project-dir", "projectDir"],
|
|
40
|
+
["--state-dir", "stateDir"],
|
|
41
|
+
["--bin-dir", "binDir"],
|
|
42
|
+
["--api-url", "apiUrl"],
|
|
43
|
+
]);
|
|
44
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
45
|
+
const arg = args[index];
|
|
46
|
+
if (valueFlags.has(arg)) {
|
|
47
|
+
const value = args[index + 1];
|
|
48
|
+
if (!value || value.startsWith("--")) throw new Error(`${arg} 缺少值`);
|
|
49
|
+
options[valueFlags.get(arg)] = value;
|
|
50
|
+
index += 1;
|
|
51
|
+
} else if (arg === "--upgrade") {
|
|
52
|
+
options.upgrade = true;
|
|
53
|
+
} else if (arg === "--allow-downgrade") {
|
|
54
|
+
options.allowDowngrade = true;
|
|
55
|
+
} else if (arg === "--no-prewarm") {
|
|
56
|
+
options.prewarm = false;
|
|
57
|
+
} else {
|
|
58
|
+
throw new Error(`未知参数: ${arg}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveInstallPaths(options = {}) {
|
|
65
|
+
const agent = options.agent || "codex";
|
|
66
|
+
const scope = options.scope || "user";
|
|
67
|
+
if (!["codex", "pi", "custom"].includes(agent)) throw new Error(`不支持的 Agent: ${agent}`);
|
|
68
|
+
if (!["user", "project"].includes(scope)) throw new Error(`不支持的安装作用域: ${scope}`);
|
|
69
|
+
|
|
70
|
+
let skillsDir;
|
|
71
|
+
if (options.skillsDir) {
|
|
72
|
+
skillsDir = path.resolve(expandHome(options.skillsDir));
|
|
73
|
+
} else if (agent === "codex" && scope === "user") {
|
|
74
|
+
const codexHome = process.env.CODEX_HOME
|
|
75
|
+
? path.resolve(expandHome(process.env.CODEX_HOME))
|
|
76
|
+
: path.join(os.homedir(), ".codex");
|
|
77
|
+
skillsDir = path.join(codexHome, "skills");
|
|
78
|
+
} else if (agent === "codex" && scope === "project") {
|
|
79
|
+
const projectDir = path.resolve(expandHome(options.projectDir || process.cwd()));
|
|
80
|
+
if (!fs.existsSync(projectDir)) throw new Error(`Codex 项目目录不存在: ${projectDir}`);
|
|
81
|
+
if (!fs.statSync(projectDir).isDirectory()) throw new Error(`Codex 项目路径不是目录: ${projectDir}`);
|
|
82
|
+
skillsDir = path.join(projectDir, ".agents", "skills");
|
|
83
|
+
} else {
|
|
84
|
+
throw new Error(`${agent} 安装必须显式提供 --skills-dir`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const defaultDataRoot = process.env.XDG_DATA_HOME
|
|
88
|
+
? path.join(path.resolve(expandHome(process.env.XDG_DATA_HOME)), "ml-platform")
|
|
89
|
+
: path.join(os.homedir(), ".local", "share", "ml-platform");
|
|
90
|
+
const stateDir = path.resolve(expandHome(
|
|
91
|
+
options.stateDir || process.env.ML_PLATFORM_HOME || defaultDataRoot,
|
|
92
|
+
));
|
|
93
|
+
const binDir = path.resolve(expandHome(
|
|
94
|
+
options.binDir || process.env.ML_PLATFORM_BIN_DIR || path.join(os.homedir(), ".local", "bin"),
|
|
95
|
+
));
|
|
96
|
+
const installRoot = path.join(stateDir, "current");
|
|
97
|
+
const target = path.join(skillsDir, SKILL_NAME);
|
|
98
|
+
return {
|
|
99
|
+
agent,
|
|
100
|
+
scope,
|
|
101
|
+
skillsDir,
|
|
102
|
+
target,
|
|
103
|
+
record: path.join(skillsDir, `.${SKILL_NAME}.ml-platform.json`),
|
|
104
|
+
stateDir,
|
|
105
|
+
installRoot,
|
|
106
|
+
clientRoot: path.join(installRoot, "runtime", "business-client"),
|
|
107
|
+
dispatcher: path.join(installRoot, "scripts", "main.js"),
|
|
108
|
+
runtimeRecord: path.join(installRoot, "ownership.json"),
|
|
109
|
+
lock: path.join(stateDir, ".install.lock"),
|
|
110
|
+
binDir,
|
|
111
|
+
shim: path.join(binDir, CLI_NAME),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function loadRelease(packageRoot = PACKAGE_ROOT) {
|
|
116
|
+
const release = readJson(path.join(packageRoot, "release.json"), "release manifest");
|
|
117
|
+
if (release.schema_version !== RELEASE_SCHEMA) {
|
|
118
|
+
throw new Error(`不支持的 release schema: ${release.schema_version}`);
|
|
119
|
+
}
|
|
120
|
+
const policyPath = path.join(packageRoot, "release-policy.json");
|
|
121
|
+
if (!isSha256(release.policy_sha256) || !fs.existsSync(policyPath)
|
|
122
|
+
|| sha256File(policyPath) !== release.policy_sha256) {
|
|
123
|
+
throw new Error("release policy 摘要与 release manifest 不匹配");
|
|
124
|
+
}
|
|
125
|
+
if (!isSemver(release.release_version)) throw new Error("release_version 不是有效 SemVer");
|
|
126
|
+
const skill = release.skills && release.skills[SKILL_NAME];
|
|
127
|
+
if (!skill || skill.path !== `skills/${SKILL_NAME}` || !isSha256(skill.sha256)
|
|
128
|
+
|| !isSemver(skill.revision) || typeof skill.requires_cli !== "string") {
|
|
129
|
+
throw new Error("release manifest 缺少有效 Skill 信息");
|
|
130
|
+
}
|
|
131
|
+
const cli = release.cli;
|
|
132
|
+
if (!cli || cli.name !== CLI_NAME || cli.path !== "runtime/business-client"
|
|
133
|
+
|| !isSemver(cli.version) || !isSha256(cli.sha256) || cli.entrypoint !== "src/cli.js") {
|
|
134
|
+
throw new Error("release manifest 缺少有效 JavaScript CLI 信息");
|
|
135
|
+
}
|
|
136
|
+
if (!versionSatisfies(cli.version, skill.requires_cli)) {
|
|
137
|
+
throw new Error("Skill requires_cli 与 release CLI version 不兼容");
|
|
138
|
+
}
|
|
139
|
+
return release;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function verifyPackage(packageRoot = PACKAGE_ROOT) {
|
|
143
|
+
packageRoot = path.resolve(packageRoot);
|
|
144
|
+
const release = loadRelease(packageRoot);
|
|
145
|
+
const manifest = readJson(path.join(packageRoot, "checksums.json"), "checksums manifest");
|
|
146
|
+
if (manifest.schema_version !== CHECKSUM_SCHEMA || !Array.isArray(manifest.files)) {
|
|
147
|
+
throw new Error("checksums manifest 格式无效");
|
|
148
|
+
}
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
for (const item of manifest.files) {
|
|
151
|
+
if (!item || typeof item.path !== "string" || !isSha256(item.sha256)
|
|
152
|
+
|| !Number.isSafeInteger(item.size_bytes) || item.size_bytes < 0) {
|
|
153
|
+
throw new Error("checksums manifest 包含无效条目");
|
|
154
|
+
}
|
|
155
|
+
const relative = normalizeRelative(item.path);
|
|
156
|
+
if (!relative.startsWith("runtime/") && !relative.startsWith("skills/")) {
|
|
157
|
+
throw new Error(`checksum 路径不属于 runtime 或 skills: ${relative}`);
|
|
158
|
+
}
|
|
159
|
+
if (seen.has(relative)) throw new Error(`checksums manifest 路径重复: ${relative}`);
|
|
160
|
+
seen.add(relative);
|
|
161
|
+
const file = resolveInside(packageRoot, relative);
|
|
162
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`发布文件不是普通文件: ${relative}`);
|
|
163
|
+
if (fs.statSync(file).size !== item.size_bytes) throw new Error(`发布文件大小不匹配: ${relative}`);
|
|
164
|
+
if (sha256File(file) !== item.sha256) throw new Error(`发布文件 SHA-256 不匹配: ${relative}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const releaseRoots = [path.join(packageRoot, "runtime"), path.join(packageRoot, "skills")];
|
|
168
|
+
const actualFiles = releaseRoots.flatMap((root) => listFiles(root))
|
|
169
|
+
.map((file) => path.relative(packageRoot, file).split(path.sep).join("/"));
|
|
170
|
+
if (actualFiles.length !== seen.size || actualFiles.some((file) => !seen.has(file))) {
|
|
171
|
+
throw new Error("runtime/skills 文件集合与 checksums manifest 不一致");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
assertTopLevel(path.join(packageRoot, "runtime"), new Set(["business-client"]), "runtime");
|
|
175
|
+
assertTopLevel(path.join(packageRoot, "skills"), new Set([SKILL_NAME]), "skills");
|
|
176
|
+
const skillRoot = resolveInside(packageRoot, release.skills[SKILL_NAME].path);
|
|
177
|
+
const clientRoot = resolveInside(packageRoot, release.cli.path);
|
|
178
|
+
assertTopLevel(skillRoot, SKILL_TOP_LEVEL, "Skill");
|
|
179
|
+
assertAllowedClient(clientRoot);
|
|
180
|
+
rejectForbiddenTree(skillRoot, "Skill");
|
|
181
|
+
rejectForbiddenTree(clientRoot, "business client");
|
|
182
|
+
if (fs.existsSync(path.join(skillRoot, "runtime")) || fs.existsSync(path.join(skillRoot, "scripts"))) {
|
|
183
|
+
throw new Error("Skill 不得包含 CLI runtime 或 wrapper");
|
|
184
|
+
}
|
|
185
|
+
const cliEntries = releaseRoots.flatMap((root) => listFiles(root))
|
|
186
|
+
.filter((file) => path.basename(file) === "cli.js");
|
|
187
|
+
const expectedEntry = path.join(clientRoot, release.cli.entrypoint);
|
|
188
|
+
if (cliEntries.length !== 1 || cliEntries[0] !== expectedEntry) {
|
|
189
|
+
throw new Error("release 必须且只能包含一份 JavaScript business CLI");
|
|
190
|
+
}
|
|
191
|
+
const packageFiles = releaseRoots.flatMap((root) => listFiles(root))
|
|
192
|
+
.filter((file) => path.basename(file) === "package.json");
|
|
193
|
+
if (packageFiles.length !== 1 || packageFiles[0] !== path.join(clientRoot, "package.json")) {
|
|
194
|
+
throw new Error("release 包含第二份 CLI 或未知 npm package");
|
|
195
|
+
}
|
|
196
|
+
if (treeDigest(skillRoot) !== release.skills[SKILL_NAME].sha256) {
|
|
197
|
+
throw new Error("Skill 目录摘要与 release manifest 不一致");
|
|
198
|
+
}
|
|
199
|
+
if (treeDigest(clientRoot) !== release.cli.sha256) {
|
|
200
|
+
throw new Error("JavaScript CLI 摘要与 release manifest 不一致");
|
|
201
|
+
}
|
|
202
|
+
if (!fs.existsSync(expectedEntry)) throw new Error("JavaScript CLI 入口不存在");
|
|
203
|
+
return { release, skillRoot, clientRoot };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function status(options = {}, packageRoot = PACKAGE_ROOT) {
|
|
207
|
+
const paths = resolveInstallPaths(options);
|
|
208
|
+
let record = null;
|
|
209
|
+
let recordError = null;
|
|
210
|
+
if (fs.existsSync(paths.record)) {
|
|
211
|
+
try {
|
|
212
|
+
record = readInstallationRecord(paths.record, paths);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
recordError = error.message;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let packageRelease = null;
|
|
218
|
+
try {
|
|
219
|
+
packageRelease = loadRelease(packageRoot).release_version;
|
|
220
|
+
} catch (_) {
|
|
221
|
+
// A source checkout has no generated release until the builder runs.
|
|
222
|
+
}
|
|
223
|
+
const installed = Boolean(record)
|
|
224
|
+
&& fs.existsSync(paths.target)
|
|
225
|
+
&& fs.existsSync(paths.clientRoot)
|
|
226
|
+
&& fs.existsSync(paths.dispatcher)
|
|
227
|
+
&& fs.existsSync(paths.shim);
|
|
228
|
+
return {
|
|
229
|
+
ok: installed && !recordError,
|
|
230
|
+
installed,
|
|
231
|
+
owner: record ? record.owner.package : null,
|
|
232
|
+
agent: paths.agent,
|
|
233
|
+
scope: paths.scope,
|
|
234
|
+
installation_record: paths.record,
|
|
235
|
+
installed_release: record ? record.release_version : null,
|
|
236
|
+
package_release: packageRelease,
|
|
237
|
+
cli: record ? record.cli : { name: CLI_NAME, path: paths.clientRoot, version: null, sha256: null },
|
|
238
|
+
skill: record ? record.skill : { name: SKILL_NAME, path: paths.target, revision: null, sha256: null },
|
|
239
|
+
shim: record ? record.shim : { path: paths.shim, sha256: null },
|
|
240
|
+
record_error: recordError,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function doctor(options = {}, packageRoot = PACKAGE_ROOT) {
|
|
245
|
+
const paths = resolveInstallPaths(options);
|
|
246
|
+
const checks = [];
|
|
247
|
+
let expectedRelease = null;
|
|
248
|
+
const hasCompletePackage = fs.existsSync(path.join(packageRoot, "checksums.json"))
|
|
249
|
+
&& fs.existsSync(path.join(packageRoot, "skills", SKILL_NAME));
|
|
250
|
+
try {
|
|
251
|
+
if (hasCompletePackage) {
|
|
252
|
+
expectedRelease = verifyPackage(packageRoot).release;
|
|
253
|
+
checks.push({ name: "package_payload", ok: true });
|
|
254
|
+
} else {
|
|
255
|
+
expectedRelease = loadRelease(paths.installRoot);
|
|
256
|
+
checks.push({ name: "installed_release_metadata", ok: true });
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
checks.push({
|
|
260
|
+
name: hasCompletePackage ? "package_payload" : "installed_release_metadata",
|
|
261
|
+
ok: false,
|
|
262
|
+
error: error.message,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let record = null;
|
|
267
|
+
try {
|
|
268
|
+
record = readInstallationRecord(paths.record, paths);
|
|
269
|
+
const expectedOk = !expectedRelease
|
|
270
|
+
|| (record.release_version === expectedRelease.release_version
|
|
271
|
+
&& record.cli.sha256 === expectedRelease.cli.sha256
|
|
272
|
+
&& record.skill.sha256 === expectedRelease.skills[SKILL_NAME].sha256);
|
|
273
|
+
checks.push({ name: "installation_record", ok: expectedOk });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
checks.push({ name: "installation_record", ok: false, error: error.message });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
checkTree(checks, "installed_cli", paths.clientRoot, record && record.cli.sha256);
|
|
279
|
+
checkTree(checks, "installed_skill", paths.target, record && record.skill.sha256);
|
|
280
|
+
checkFile(checks, "installed_shim", paths.shim, record && record.shim.sha256);
|
|
281
|
+
if (record) {
|
|
282
|
+
checkTree(checks, "installed_manager", path.join(paths.installRoot, "scripts"), record.manager_sha256);
|
|
283
|
+
checkFile(checks, "installed_release_manifest", path.join(paths.installRoot, "release.json"), record.release_sha256);
|
|
284
|
+
checkFile(checks, "installed_release_policy", path.join(paths.installRoot, "release-policy.json"), record.policy_sha256);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const runtime = runCliRuntime(paths.dispatcher, ["version"], false);
|
|
288
|
+
checks.push({ name: "cli_runtime", ok: runtime.ok, detail: runtime.detail });
|
|
289
|
+
const pathMatches = findAllOnPath(CLI_NAME);
|
|
290
|
+
const matchingShim = pathMatches.find((candidate) => sameFileOrContent(candidate, paths.shim));
|
|
291
|
+
const pathOk = matchingShim !== undefined;
|
|
292
|
+
checks.push({
|
|
293
|
+
name: "path_visibility",
|
|
294
|
+
ok: pathOk,
|
|
295
|
+
expected: paths.shim,
|
|
296
|
+
actual: matchingShim ?? pathMatches[0] ?? null,
|
|
297
|
+
remediation: pathOk ? null : `将 ${paths.binDir} 加入 PATH,然后重启 Agent 会话`,
|
|
298
|
+
});
|
|
299
|
+
if (options.apiUrl) {
|
|
300
|
+
const apiHealth = runApiHealth(paths.dispatcher, options.apiUrl);
|
|
301
|
+
checks.push({ name: "platform_api", ok: apiHealth.ok, detail: apiHealth.detail });
|
|
302
|
+
} else {
|
|
303
|
+
checks.push({ name: "platform_api", ok: null, skipped: true, reason: "未提供 --api-url" });
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
ok: checks.every((check) => check.ok !== false),
|
|
307
|
+
release_version: record ? record.release_version : expectedRelease && expectedRelease.release_version,
|
|
308
|
+
cli_version: record ? record.cli.version : expectedRelease && expectedRelease.cli.version,
|
|
309
|
+
skill_root: paths.target,
|
|
310
|
+
checks,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function install(options = {}, packageRoot = PACKAGE_ROOT) {
|
|
315
|
+
const verified = verifyPackage(packageRoot);
|
|
316
|
+
const paths = resolveInstallPaths(options);
|
|
317
|
+
fs.mkdirSync(paths.skillsDir, { recursive: true, mode: 0o700 });
|
|
318
|
+
fs.mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
|
|
319
|
+
fs.mkdirSync(paths.binDir, { recursive: true, mode: 0o755 });
|
|
320
|
+
return withInstallLock(paths.lock, () => installLocked(options, packageRoot, verified, paths));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function installLocked(options, packageRoot, verified, paths) {
|
|
324
|
+
const previousRecord = fs.existsSync(paths.record) ? fs.readFileSync(paths.record) : null;
|
|
325
|
+
const current = safeReadInstallationRecord(paths.record, paths);
|
|
326
|
+
const targetExists = fs.existsSync(paths.target);
|
|
327
|
+
if (targetExists && !current) {
|
|
328
|
+
throw new Error(`目标 Skill 不受 ${PACKAGE_NAME} 管理,拒绝覆盖: ${paths.target}`);
|
|
329
|
+
}
|
|
330
|
+
const sharedRuntime = safeReadRuntimeRecord(paths.runtimeRecord);
|
|
331
|
+
if (fs.existsSync(paths.installRoot) && !sharedRuntime) {
|
|
332
|
+
throw new Error(`持久 CLI 目录不受 ${PACKAGE_NAME} 管理,拒绝覆盖: ${paths.installRoot}`);
|
|
333
|
+
}
|
|
334
|
+
if (fs.existsSync(paths.shim) && (!current || sha256File(paths.shim) !== current.shim.sha256)
|
|
335
|
+
&& (!sharedRuntime || !shimTargetsDispatcher(paths.shim, paths.dispatcher))) {
|
|
336
|
+
throw new Error(`CLI shim 不受 ${PACKAGE_NAME} 管理,拒绝覆盖: ${paths.shim}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const sharedRuntimeMatches = sharedRuntime
|
|
340
|
+
&& sharedRuntime.release_version === verified.release.release_version
|
|
341
|
+
&& sharedRuntime.cli_version === verified.release.cli.version
|
|
342
|
+
&& sharedRuntime.cli_sha256 === verified.release.cli.sha256
|
|
343
|
+
&& digestEquals(paths.clientRoot, verified.release.cli.sha256)
|
|
344
|
+
&& fs.existsSync(paths.shim)
|
|
345
|
+
&& shimTargetsDispatcher(paths.shim, paths.dispatcher);
|
|
346
|
+
|
|
347
|
+
const currentComplete = current
|
|
348
|
+
&& current.release_version === verified.release.release_version
|
|
349
|
+
&& current.cli.sha256 === verified.release.cli.sha256
|
|
350
|
+
&& current.skill.sha256 === verified.release.skills[SKILL_NAME].sha256
|
|
351
|
+
&& digestEquals(paths.clientRoot, current.cli.sha256)
|
|
352
|
+
&& digestEquals(paths.target, current.skill.sha256)
|
|
353
|
+
&& fs.existsSync(paths.shim)
|
|
354
|
+
&& sha256File(paths.shim) === current.shim.sha256
|
|
355
|
+
&& digestEquals(path.join(paths.installRoot, "scripts"), current.manager_sha256)
|
|
356
|
+
&& fs.existsSync(path.join(paths.installRoot, "release.json"))
|
|
357
|
+
&& sha256File(path.join(paths.installRoot, "release.json")) === current.release_sha256;
|
|
358
|
+
if (currentComplete && !options.upgrade) {
|
|
359
|
+
if (options.prewarm !== false) assertRuntime(paths.dispatcher);
|
|
360
|
+
return installationResult("unchanged", paths, verified.release, current);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if ((targetExists || (sharedRuntime && !sharedRuntimeMatches)) && !options.upgrade) {
|
|
364
|
+
throw new Error("已存在不同版本或摘要;升级时请使用 upgrade 或 install --upgrade");
|
|
365
|
+
}
|
|
366
|
+
const installedVersion = current ? current.release_version : sharedRuntime && sharedRuntime.release_version;
|
|
367
|
+
if (installedVersion && !options.allowDowngrade
|
|
368
|
+
&& compareSemver(verified.release.release_version, installedVersion) < 0) {
|
|
369
|
+
throw new Error("目标版本低于已安装版本;确认回滚时请添加 --allow-downgrade");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const stateStaging = fs.mkdtempSync(path.join(paths.stateDir, ".install."));
|
|
373
|
+
const stagedInstall = path.join(stateStaging, "current");
|
|
374
|
+
const skillStaging = fs.mkdtempSync(path.join(paths.skillsDir, `.${SKILL_NAME}.install.`));
|
|
375
|
+
const stagedSkill = path.join(skillStaging, SKILL_NAME);
|
|
376
|
+
const shimTemporary = path.join(paths.binDir, `.${CLI_NAME}.${process.pid}.${Date.now()}.tmp`);
|
|
377
|
+
const stateBackup = path.join(paths.stateDir, `.backup.${process.pid}.${Date.now()}`);
|
|
378
|
+
const skillBackup = path.join(paths.skillsDir, `.${SKILL_NAME}.backup.${process.pid}.${Date.now()}`);
|
|
379
|
+
const shimBackup = path.join(paths.binDir, `.${CLI_NAME}.backup.${process.pid}.${Date.now()}`);
|
|
380
|
+
let stateMoved = false;
|
|
381
|
+
let skillMoved = false;
|
|
382
|
+
let shimMoved = false;
|
|
383
|
+
let hadState = false;
|
|
384
|
+
let hadSkill = false;
|
|
385
|
+
let hadShim = false;
|
|
386
|
+
try {
|
|
387
|
+
stagePersistentInstall(stagedInstall, packageRoot, verified);
|
|
388
|
+
fs.cpSync(verified.skillRoot, stagedSkill, { recursive: true, errorOnExist: true });
|
|
389
|
+
if (treeDigest(stagedSkill) !== verified.release.skills[SKILL_NAME].sha256) {
|
|
390
|
+
throw new Error("staged Skill 摘要不匹配");
|
|
391
|
+
}
|
|
392
|
+
writeShim(shimTemporary, paths.dispatcher);
|
|
393
|
+
|
|
394
|
+
if (fs.existsSync(paths.installRoot)) {
|
|
395
|
+
fs.renameSync(paths.installRoot, stateBackup);
|
|
396
|
+
hadState = true;
|
|
397
|
+
}
|
|
398
|
+
fs.renameSync(stagedInstall, paths.installRoot);
|
|
399
|
+
stateMoved = true;
|
|
400
|
+
if (fs.existsSync(paths.target)) {
|
|
401
|
+
fs.renameSync(paths.target, skillBackup);
|
|
402
|
+
hadSkill = true;
|
|
403
|
+
}
|
|
404
|
+
fs.renameSync(stagedSkill, paths.target);
|
|
405
|
+
skillMoved = true;
|
|
406
|
+
if (fs.existsSync(paths.shim)) {
|
|
407
|
+
fs.renameSync(paths.shim, shimBackup);
|
|
408
|
+
hadShim = true;
|
|
409
|
+
}
|
|
410
|
+
fs.renameSync(shimTemporary, paths.shim);
|
|
411
|
+
shimMoved = true;
|
|
412
|
+
|
|
413
|
+
const record = buildInstallationRecord(paths, verified.release);
|
|
414
|
+
writeJsonAtomic(paths.record, record);
|
|
415
|
+
assertInstalledState(paths, record, options.prewarm !== false);
|
|
416
|
+
|
|
417
|
+
removeOwnedDirectory(stateStaging, paths.stateDir, ".install.");
|
|
418
|
+
removeOwnedDirectory(skillStaging, paths.skillsDir, `.${SKILL_NAME}.install.`);
|
|
419
|
+
if (hadState) removeOwnedDirectory(stateBackup, paths.stateDir, ".backup.");
|
|
420
|
+
if (hadSkill) removeOwnedDirectory(skillBackup, paths.skillsDir, `.${SKILL_NAME}.backup.`);
|
|
421
|
+
if (hadShim) fs.rmSync(shimBackup, { force: true });
|
|
422
|
+
return installationResult(current || targetExists ? "upgraded" : "installed", paths, verified.release, record);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (shimMoved && fs.existsSync(paths.shim)) fs.rmSync(paths.shim, { force: true });
|
|
425
|
+
if (hadShim && fs.existsSync(shimBackup)) fs.renameSync(shimBackup, paths.shim);
|
|
426
|
+
if (skillMoved && fs.existsSync(paths.target)) removeOwnedDirectory(paths.target, paths.skillsDir, SKILL_NAME);
|
|
427
|
+
if (hadSkill && fs.existsSync(skillBackup)) fs.renameSync(skillBackup, paths.target);
|
|
428
|
+
if (stateMoved && fs.existsSync(paths.installRoot)) removeOwnedDirectory(paths.installRoot, paths.stateDir, "current");
|
|
429
|
+
if (hadState && fs.existsSync(stateBackup)) fs.renameSync(stateBackup, paths.installRoot);
|
|
430
|
+
if (previousRecord === null) fs.rmSync(paths.record, { force: true });
|
|
431
|
+
else fs.writeFileSync(paths.record, previousRecord);
|
|
432
|
+
if (fs.existsSync(shimTemporary)) fs.rmSync(shimTemporary, { force: true });
|
|
433
|
+
if (fs.existsSync(stateStaging)) removeOwnedDirectory(stateStaging, paths.stateDir, ".install.");
|
|
434
|
+
if (fs.existsSync(skillStaging)) removeOwnedDirectory(skillStaging, paths.skillsDir, `.${SKILL_NAME}.install.`);
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function stagePersistentInstall(target, packageRoot, verified) {
|
|
440
|
+
fs.mkdirSync(path.join(target, "scripts"), { recursive: true });
|
|
441
|
+
fs.mkdirSync(path.join(target, "runtime"), { recursive: true });
|
|
442
|
+
for (const name of ["main.js", "lib.js"]) {
|
|
443
|
+
fs.copyFileSync(path.join(packageRoot, "scripts", name), path.join(target, "scripts", name));
|
|
444
|
+
}
|
|
445
|
+
fs.chmodSync(path.join(target, "scripts", "main.js"), 0o755);
|
|
446
|
+
fs.cpSync(verified.clientRoot, path.join(target, "runtime", "business-client"), {
|
|
447
|
+
recursive: true,
|
|
448
|
+
errorOnExist: true,
|
|
449
|
+
});
|
|
450
|
+
fs.copyFileSync(path.join(packageRoot, "release.json"), path.join(target, "release.json"));
|
|
451
|
+
fs.copyFileSync(path.join(packageRoot, "release-policy.json"), path.join(target, "release-policy.json"));
|
|
452
|
+
writeJsonAtomic(path.join(target, "ownership.json"), {
|
|
453
|
+
schema_version: RUNTIME_RECORD_SCHEMA,
|
|
454
|
+
owner: PACKAGE_NAME,
|
|
455
|
+
release_version: verified.release.release_version,
|
|
456
|
+
cli_version: verified.release.cli.version,
|
|
457
|
+
cli_sha256: verified.release.cli.sha256,
|
|
458
|
+
});
|
|
459
|
+
if (treeDigest(path.join(target, "runtime", "business-client")) !== verified.release.cli.sha256) {
|
|
460
|
+
throw new Error("staged JavaScript CLI 摘要不匹配");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function buildInstallationRecord(paths, release) {
|
|
465
|
+
return {
|
|
466
|
+
schema_version: RECORD_SCHEMA,
|
|
467
|
+
owner: { package: PACKAGE_NAME, installation_id: installationId(paths) },
|
|
468
|
+
release_version: release.release_version,
|
|
469
|
+
agent: paths.agent,
|
|
470
|
+
scope: paths.scope,
|
|
471
|
+
cli: {
|
|
472
|
+
name: CLI_NAME,
|
|
473
|
+
version: release.cli.version,
|
|
474
|
+
path: paths.clientRoot,
|
|
475
|
+
sha256: release.cli.sha256,
|
|
476
|
+
},
|
|
477
|
+
skill: {
|
|
478
|
+
name: SKILL_NAME,
|
|
479
|
+
revision: release.skills[SKILL_NAME].revision,
|
|
480
|
+
requires_cli: release.skills[SKILL_NAME].requires_cli,
|
|
481
|
+
path: paths.target,
|
|
482
|
+
sha256: release.skills[SKILL_NAME].sha256,
|
|
483
|
+
},
|
|
484
|
+
shim: { path: paths.shim, sha256: sha256File(paths.shim) },
|
|
485
|
+
manager_sha256: treeDigest(path.join(paths.installRoot, "scripts")),
|
|
486
|
+
release_sha256: sha256File(path.join(paths.installRoot, "release.json")),
|
|
487
|
+
policy_sha256: sha256File(path.join(paths.installRoot, "release-policy.json")),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function assertInstalledState(paths, record, prewarm) {
|
|
492
|
+
if (!digestEquals(paths.clientRoot, record.cli.sha256)) throw new Error("安装后的 CLI 摘要不匹配");
|
|
493
|
+
if (!digestEquals(paths.target, record.skill.sha256)) throw new Error("安装后的 Skill 摘要不匹配");
|
|
494
|
+
if (sha256File(paths.shim) !== record.shim.sha256) throw new Error("安装后的 CLI shim 摘要不匹配");
|
|
495
|
+
if (prewarm) assertRuntime(paths.dispatcher);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function installationResult(action, paths, release, record) {
|
|
499
|
+
const pathMatches = findAllOnPath(CLI_NAME);
|
|
500
|
+
const pathReady = pathMatches.some((candidate) => sameFileOrContent(candidate, paths.shim));
|
|
501
|
+
return {
|
|
502
|
+
ok: true,
|
|
503
|
+
action,
|
|
504
|
+
release_version: release.release_version,
|
|
505
|
+
cli_version: release.cli.version,
|
|
506
|
+
cli_root: paths.clientRoot,
|
|
507
|
+
cli_shim: paths.shim,
|
|
508
|
+
skill: SKILL_NAME,
|
|
509
|
+
skill_root: paths.target,
|
|
510
|
+
installation_record: paths.record,
|
|
511
|
+
ownership: record.owner,
|
|
512
|
+
path_ready: pathReady,
|
|
513
|
+
path_setup: pathReady ? null : `将 ${paths.binDir} 加入 PATH`,
|
|
514
|
+
restart_agent_session: true,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function readInstallationRecord(file, paths) {
|
|
519
|
+
const record = readJson(file, "installation record");
|
|
520
|
+
if (record.schema_version !== RECORD_SCHEMA || record.owner?.package !== PACKAGE_NAME
|
|
521
|
+
|| record.owner.installation_id !== installationId(paths)
|
|
522
|
+
|| record.agent !== paths.agent || record.scope !== paths.scope
|
|
523
|
+
|| record.cli?.name !== CLI_NAME || record.cli.path !== paths.clientRoot
|
|
524
|
+
|| !isSemver(record.cli?.version) || !isSha256(record.cli?.sha256)
|
|
525
|
+
|| record.skill?.name !== SKILL_NAME || record.skill.path !== paths.target
|
|
526
|
+
|| !isSemver(record.skill?.revision) || !isSha256(record.skill?.sha256)
|
|
527
|
+
|| record.shim?.path !== paths.shim || !isSha256(record.shim?.sha256)
|
|
528
|
+
|| !isSha256(record.manager_sha256) || !isSha256(record.release_sha256)
|
|
529
|
+
|| !isSha256(record.policy_sha256)) {
|
|
530
|
+
throw new Error("安装记录 ownership、路径、版本或摘要无效");
|
|
531
|
+
}
|
|
532
|
+
return record;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function safeReadInstallationRecord(file, paths) {
|
|
536
|
+
try { return fs.existsSync(file) ? readInstallationRecord(file, paths) : null; }
|
|
537
|
+
catch (_) { return null; }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function safeReadRuntimeRecord(file) {
|
|
541
|
+
try {
|
|
542
|
+
const record = readJson(file, "runtime ownership record");
|
|
543
|
+
return record.schema_version === RUNTIME_RECORD_SCHEMA && record.owner === PACKAGE_NAME
|
|
544
|
+
&& isSemver(record.release_version) && isSemver(record.cli_version) && isSha256(record.cli_sha256)
|
|
545
|
+
? record : null;
|
|
546
|
+
} catch (_) {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function installationId(paths) {
|
|
552
|
+
return crypto.createHash("sha256")
|
|
553
|
+
.update(`${paths.agent}\0${paths.scope}\0${paths.skillsDir}`, "utf8")
|
|
554
|
+
.digest("hex");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function writeShim(file, dispatcher) {
|
|
558
|
+
const source = `#!/usr/bin/env node\n"use strict";\nconst { spawnSync } = require("child_process");\nconst result = spawnSync(process.execPath, [${JSON.stringify(dispatcher)}, ...process.argv.slice(2)], { stdio: "inherit", env: process.env });\nif (result.error) { process.stderr.write(result.error.message + "\\n"); process.exitCode = 1; } else { process.exitCode = result.status === null ? 1 : result.status; }\n`;
|
|
559
|
+
fs.writeFileSync(file, source, { mode: 0o755 });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function shimTargetsDispatcher(shim, dispatcher) {
|
|
563
|
+
try { return fs.readFileSync(shim, "utf8").includes(JSON.stringify(dispatcher)); }
|
|
564
|
+
catch (_) { return false; }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function assertRuntime(dispatcher) {
|
|
568
|
+
const result = runCliRuntime(dispatcher, ["version"], false);
|
|
569
|
+
if (!result.ok) throw new Error(`CLI runtime 检查失败: ${result.detail || "未知错误"}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function runCliRuntime(dispatcher, args, inheritOutput) {
|
|
573
|
+
if (!fs.existsSync(dispatcher)) return { ok: false, detail: "缺少持久 ml-platform dispatcher" };
|
|
574
|
+
const result = spawnSync(process.execPath, [dispatcher, ...args], {
|
|
575
|
+
encoding: "utf8",
|
|
576
|
+
stdio: inheritOutput ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
577
|
+
env: { ...process.env },
|
|
578
|
+
});
|
|
579
|
+
if (result.error) return { ok: false, detail: result.error.message };
|
|
580
|
+
const detail = inheritOutput ? null : (result.stdout || result.stderr || "").trim();
|
|
581
|
+
return { ok: result.status === 0, detail };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function runApiHealth(dispatcher, apiUrl) {
|
|
585
|
+
let parsed;
|
|
586
|
+
try { parsed = new URL(apiUrl); }
|
|
587
|
+
catch (_) { return { ok: false, detail: "--api-url 必须是绝对 HTTP(S) URL" }; }
|
|
588
|
+
if (!["http:", "https:"].includes(parsed.protocol) || !parsed.host) {
|
|
589
|
+
return { ok: false, detail: "--api-url 必须是绝对 HTTP(S) URL" };
|
|
590
|
+
}
|
|
591
|
+
return runCliRuntime(dispatcher, ["--api-url", apiUrl.replace(/\/+$/, ""), "health"], false);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function checkTree(checks, name, root, expected) {
|
|
595
|
+
if (!expected || !fs.existsSync(root)) {
|
|
596
|
+
checks.push({ name, ok: false, error: `缺少安装目录或预期摘要: ${root}` });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const actual = treeDigest(root);
|
|
601
|
+
checks.push({ name, ok: actual === expected, expected_sha256: expected, actual_sha256: actual });
|
|
602
|
+
} catch (error) {
|
|
603
|
+
checks.push({ name, ok: false, error: error.message });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function checkFile(checks, name, file, expected) {
|
|
608
|
+
if (!expected || !fs.existsSync(file)) {
|
|
609
|
+
checks.push({ name, ok: false, error: `缺少安装文件或预期摘要: ${file}` });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const actual = sha256File(file);
|
|
613
|
+
checks.push({ name, ok: actual === expected, expected_sha256: expected, actual_sha256: actual });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function digestEquals(root, expected) {
|
|
617
|
+
try { return fs.existsSync(root) && treeDigest(root) === expected; }
|
|
618
|
+
catch (_) { return false; }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function withInstallLock(lockPath, callback) {
|
|
622
|
+
let descriptor;
|
|
623
|
+
try {
|
|
624
|
+
descriptor = fs.openSync(lockPath, "wx", 0o600);
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (error.code === "EEXIST") throw new Error(`另一个安装进程正在运行: ${lockPath}`);
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
fs.closeSync(descriptor);
|
|
630
|
+
try { return callback(); }
|
|
631
|
+
finally { fs.rmSync(lockPath, { force: true }); }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function removeOwnedDirectory(target, parent, requiredName) {
|
|
635
|
+
const resolvedParent = fs.realpathSync(parent);
|
|
636
|
+
const resolvedTarget = fs.realpathSync(target);
|
|
637
|
+
const basename = path.basename(resolvedTarget);
|
|
638
|
+
const nameAllowed = requiredName.endsWith(".")
|
|
639
|
+
? basename.startsWith(requiredName)
|
|
640
|
+
: basename === requiredName;
|
|
641
|
+
if (path.dirname(resolvedTarget) !== resolvedParent || !nameAllowed) {
|
|
642
|
+
throw new Error(`拒绝删除不受安装器管理的目录: ${resolvedTarget}`);
|
|
643
|
+
}
|
|
644
|
+
fs.rmSync(resolvedTarget, { recursive: true, force: true });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function assertTopLevel(root, allowed, label) {
|
|
648
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${label} 目录不存在: ${root}`);
|
|
649
|
+
const actual = new Set(fs.readdirSync(root));
|
|
650
|
+
if (actual.size !== allowed.size || [...actual].some((item) => !allowed.has(item))) {
|
|
651
|
+
throw new Error(`${label} 顶层文件不在 allowlist`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function assertAllowedClient(root) {
|
|
656
|
+
const actual = new Set(fs.readdirSync(root));
|
|
657
|
+
const required = ["package.json", "README.md", "src"];
|
|
658
|
+
if (required.some((item) => !actual.has(item)) || [...actual].some((item) => !CLIENT_TOP_LEVEL.has(item))) {
|
|
659
|
+
throw new Error("business client 顶层文件不在 allowlist");
|
|
660
|
+
}
|
|
661
|
+
const packageJson = readJson(path.join(root, "package.json"), "business client package.json");
|
|
662
|
+
if (packageJson.bin?.[CLI_NAME] !== "src/cli.js") throw new Error("business client bin 入口无效");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function rejectForbiddenTree(root, label) {
|
|
666
|
+
const visit = (directory) => {
|
|
667
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
668
|
+
const item = path.join(directory, entry.name);
|
|
669
|
+
if (entry.isSymbolicLink()) throw new Error(`${label} 不允许符号链接: ${item}`);
|
|
670
|
+
if (entry.isDirectory()) {
|
|
671
|
+
if (FORBIDDEN_DIRECTORIES.has(entry.name) && fs.readdirSync(item).length > 0) {
|
|
672
|
+
throw new Error(`${label} 包含禁止目录: ${item}`);
|
|
673
|
+
}
|
|
674
|
+
visit(item);
|
|
675
|
+
} else if (entry.isFile()) {
|
|
676
|
+
if (FORBIDDEN_FILES.has(entry.name) || FORBIDDEN_SUFFIXES.has(path.extname(entry.name).toLowerCase())) {
|
|
677
|
+
throw new Error(`${label} 包含禁止文件: ${item}`);
|
|
678
|
+
}
|
|
679
|
+
} else {
|
|
680
|
+
throw new Error(`${label} 包含不支持的文件类型: ${item}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
visit(root);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function treeDigest(root) {
|
|
688
|
+
const files = listFiles(root);
|
|
689
|
+
const hash = crypto.createHash("sha256");
|
|
690
|
+
for (const file of files) {
|
|
691
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
692
|
+
hash.update(relative, "utf8");
|
|
693
|
+
hash.update("\0");
|
|
694
|
+
hash.update(sha256File(file), "utf8");
|
|
695
|
+
hash.update("\n");
|
|
696
|
+
}
|
|
697
|
+
return `sha256:${hash.digest("hex")}`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function listFiles(root) {
|
|
701
|
+
if (!fs.existsSync(root)) throw new Error(`目录不存在: ${root}`);
|
|
702
|
+
const result = [];
|
|
703
|
+
const visit = (directory) => {
|
|
704
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
705
|
+
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
706
|
+
for (const entry of entries) {
|
|
707
|
+
const item = path.join(directory, entry.name);
|
|
708
|
+
if (entry.isSymbolicLink()) throw new Error(`发布目录不允许符号链接: ${item}`);
|
|
709
|
+
if (entry.isDirectory()) visit(item);
|
|
710
|
+
else if (entry.isFile()) result.push(item);
|
|
711
|
+
else throw new Error(`发布目录包含不支持的文件类型: ${item}`);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
visit(root);
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function sha256File(file) {
|
|
719
|
+
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function writeJsonAtomic(file, payload) {
|
|
723
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
724
|
+
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
725
|
+
fs.renameSync(temporary, file);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function readJson(file, label) {
|
|
729
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
730
|
+
catch (error) { throw new Error(`无法读取 ${label}: ${file}: ${error.message}`); }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function resolveInside(root, relative) {
|
|
734
|
+
const resolvedRoot = path.resolve(root);
|
|
735
|
+
const target = path.resolve(resolvedRoot, relative);
|
|
736
|
+
if (target === resolvedRoot || !target.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
737
|
+
throw new Error(`路径越界: ${relative}`);
|
|
738
|
+
}
|
|
739
|
+
return target;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function normalizeRelative(value) {
|
|
743
|
+
if (value.includes("\\")) throw new Error(`checksum 路径必须使用 /: ${value}`);
|
|
744
|
+
const normalized = path.posix.normalize(value);
|
|
745
|
+
if (normalized !== value || normalized.startsWith("../") || normalized.startsWith("/") || normalized === "..") {
|
|
746
|
+
throw new Error(`非法相对路径: ${value}`);
|
|
747
|
+
}
|
|
748
|
+
return normalized;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function expandHome(value) {
|
|
752
|
+
if (value === "~") return os.homedir();
|
|
753
|
+
if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
|
754
|
+
return value;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function findAllOnPath(name) {
|
|
758
|
+
const matches = [];
|
|
759
|
+
const extensions = process.platform === "win32" ? [".cmd", ".exe", ""] : [""];
|
|
760
|
+
for (const directory of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
761
|
+
for (const extension of extensions) {
|
|
762
|
+
const candidate = path.join(directory, `${name}${extension}`);
|
|
763
|
+
try {
|
|
764
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
765
|
+
if (fs.statSync(candidate).isFile()) matches.push(candidate);
|
|
766
|
+
} catch (_) { /* continue */ }
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return matches;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function sameFileOrContent(left, right) {
|
|
773
|
+
try { return fs.realpathSync(left) === fs.realpathSync(right) || sha256File(left) === sha256File(right); }
|
|
774
|
+
catch (_) { return false; }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function isSha256(value) {
|
|
778
|
+
return typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function isSemver(value) {
|
|
782
|
+
return typeof value === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function compareSemver(left, right) {
|
|
786
|
+
if (!isSemver(left) || !isSemver(right)) throw new Error("无法比较无效 SemVer");
|
|
787
|
+
const a = left.split("-")[0].split(".").map(Number);
|
|
788
|
+
const b = right.split("-")[0].split(".").map(Number);
|
|
789
|
+
for (let index = 0; index < 3; index += 1) {
|
|
790
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
791
|
+
}
|
|
792
|
+
return 0;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function versionSatisfies(version, range) {
|
|
796
|
+
const match = /^(>=\d+\.\d+\.\d+)\s+(<\d+\.\d+\.\d+)$/.exec(range);
|
|
797
|
+
if (!match) return false;
|
|
798
|
+
return compareSemver(version, match[1].slice(2)) >= 0 && compareSemver(version, match[2].slice(1)) < 0;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
module.exports = {
|
|
802
|
+
CHECKSUM_SCHEMA,
|
|
803
|
+
CLI_NAME,
|
|
804
|
+
PACKAGE_NAME,
|
|
805
|
+
RECORD_SCHEMA,
|
|
806
|
+
RELEASE_SCHEMA,
|
|
807
|
+
SKILL_NAME,
|
|
808
|
+
compareSemver,
|
|
809
|
+
doctor,
|
|
810
|
+
install,
|
|
811
|
+
listFiles,
|
|
812
|
+
loadRelease,
|
|
813
|
+
parseOptions,
|
|
814
|
+
resolveInstallPaths,
|
|
815
|
+
sha256File,
|
|
816
|
+
status,
|
|
817
|
+
treeDigest,
|
|
818
|
+
verifyPackage,
|
|
819
|
+
};
|