@yixinkj/inquiry-opening-coach-cli 0.3.1
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/index.js +528 -0
- package/launcher-core.js +1457 -0
- package/package.json +30 -0
- package/skill/SKILL.md +550 -0
- package/skill/agents/openai.yaml +6 -0
- package/skill/references/country-styles.md +16 -0
- package/skill/references/opening-rules.md +38 -0
- package/skills.json +30 -0
package/launcher-core.js
ADDED
|
@@ -0,0 +1,1457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 译心独立 Skill 系列 npm 启动器的共享内核。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这个文件:
|
|
5
|
+
* priority-buyer-alert / public-customer-reactivation / review-customer-insight 三份
|
|
6
|
+
* index.js 里有 700 行以上逐字相同的启动逻辑,一直靠手工复制保持同步。手工同步已经
|
|
7
|
+
* 出过事故——给三份都加「清理旧运行时」的日志时,日志前缀写成了模块级常量 SKILL_ID,
|
|
8
|
+
* 而只有 priority-buyer-alert 定义了它,另外两个发布后一启动就 ReferenceError,整个
|
|
9
|
+
* 启动中止。启动器是用户机器上跑的第一段代码,它崩了没有任何兜底。
|
|
10
|
+
*
|
|
11
|
+
* 因此本文件有一条不可让步的规矩:
|
|
12
|
+
* **这里的每个函数都不读取任何模块级配置常量。**
|
|
13
|
+
* SKILL_ID、SKILL_VERSION、RUNTIME_VERSION、版本目录、日志前缀……全部由调用方显式传入。
|
|
14
|
+
* 少传就在调用点抛出一句中文错误(见 requireOption),而不是等到某条冷路径才炸成
|
|
15
|
+
* ReferenceError。本文件里唯一的模块级常量是一个与具体 Skill 无关的数值默认值。
|
|
16
|
+
*
|
|
17
|
+
* 另外两条边界:
|
|
18
|
+
* - 不引入任何第三方依赖,只用 node: 内置模块。启动器要在 npx 冷启动、网络不确定的
|
|
19
|
+
* 环境下跑,多一个依赖就多一个装不上的理由。
|
|
20
|
+
* - 不做运行时远程下载来获取自身代码。业务运行时的下载是本文件的职责之一,但那条链路
|
|
21
|
+
* 有清单 + SHA256 双重校验;启动器自己的代码必须随 npm 包一起落地。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
import https from 'node:https';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 同机保留的运行时版本数(含当前正在用的这一版)。
|
|
35
|
+
*
|
|
36
|
+
* 每升一版就多留一份二进制,从来没人删。真机上 public-customer-reactivation 攒了 16 份
|
|
37
|
+
* 共 94M,三个 Skill 合计 164M,而任何时刻真正被执行的只有一份。
|
|
38
|
+
* 不设成 1 是因为同一台机器会交替跑不同 dist-tag(例如 latest 与 beta),
|
|
39
|
+
* 只留当前版会让每次切换都重新下载一遍。
|
|
40
|
+
*
|
|
41
|
+
* 这是本文件里唯一的模块级常量:它是一个跨 Skill 通用的策略数值,不含任何 Skill 身份,
|
|
42
|
+
* 复制到第四个仓也不会变。所有带 Skill 身份的值一律走参数。
|
|
43
|
+
*/
|
|
44
|
+
const RUNTIME_VERSIONS_TO_KEEP = 2;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把「调用方漏传配置」变成一句能读懂的中文错误,而不是运行到一半的 ReferenceError。
|
|
48
|
+
*
|
|
49
|
+
* 这就是那次事故的正解:日志前缀漏传时,应该在函数入口立刻说「缺少 logPrefix」,
|
|
50
|
+
* 而不是等到真的删掉了旧版本、走到打印那一行时才崩掉整个启动。
|
|
51
|
+
* @param {*} value 调用方传入的值。
|
|
52
|
+
* @param {string} name 参数名,直接出现在报错里。
|
|
53
|
+
* @returns {*} 原值。
|
|
54
|
+
*/
|
|
55
|
+
function requireOption(value, name) {
|
|
56
|
+
if (value === undefined || value === null || value === '') {
|
|
57
|
+
throw new Error(`launcher-core 缺少必填参数 ${name}:共享内核不读取任何模块级常量,请由调用方显式传入。`);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ------------------------------------------------------------------ *
|
|
63
|
+
* 一、命令行参数
|
|
64
|
+
* ------------------------------------------------------------------ */
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 摘出 `--skill-path`,其余参数原样转交原生运行时。
|
|
68
|
+
*
|
|
69
|
+
* 之所以由 npm 启动器自己解析而不是透传:这个参数决定的是「本次要同步哪一份 SKILL.md」,
|
|
70
|
+
* 属于启动器的职责,原生运行时既不认识也不该认识它。
|
|
71
|
+
* @param {string[]} args 原始命令行参数。
|
|
72
|
+
* @returns {{skillPath: string | null, forwardedArgs: string[]}} 解析出的路径与待转交参数。
|
|
73
|
+
*/
|
|
74
|
+
function extractSkillPath(args) {
|
|
75
|
+
let skillPath = null;
|
|
76
|
+
const forwardedArgs = [];
|
|
77
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
78
|
+
const arg = args[index];
|
|
79
|
+
if (arg === '--skill-path') {
|
|
80
|
+
if (!args[index + 1]) throw new Error('--skill-path 缺少 SKILL.md 路径');
|
|
81
|
+
skillPath = args[index + 1];
|
|
82
|
+
index += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg.startsWith('--skill-path=')) {
|
|
86
|
+
skillPath = arg.slice('--skill-path='.length);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
forwardedArgs.push(arg);
|
|
90
|
+
}
|
|
91
|
+
return { skillPath, forwardedArgs };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 判断参数中是否已经显式提供某个长选项。
|
|
96
|
+
*
|
|
97
|
+
* 必须同时认 `--x value` 和 `--x=value` 两种写法:只认一种时,模型换个写法就绕过了
|
|
98
|
+
* 「不允许模板文件旁路」这类硬门禁。
|
|
99
|
+
* @param {string[]} args 参数数组。
|
|
100
|
+
* @param {string} option 以 `--` 开头的选项名。
|
|
101
|
+
* @returns {boolean} 是否存在。
|
|
102
|
+
*/
|
|
103
|
+
function hasOption(args, option) {
|
|
104
|
+
return args.some((arg) => arg === option || arg.startsWith(`${option}=`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 判断是否为纯帮助请求。帮助必须在 Skill OTA 和 Runtime 初始化前返回,
|
|
109
|
+
* 避免一个只读命令改写本地状态、甚至触发一次几十兆的下载。
|
|
110
|
+
*
|
|
111
|
+
* `emptyArgsMeansHelp` 必须由调用方显式声明,因为三个仓在这里本来就不一致:
|
|
112
|
+
* priority-buyer-alert 把「不带任何参数」当帮助(它没有可用的默认动作),
|
|
113
|
+
* public-customer-reactivation 不这么认为。把它做成默认值会悄悄改掉某一边的行为,
|
|
114
|
+
* 所以宁可让每个调用点自己写清楚。
|
|
115
|
+
* @param {string[]} args 原始命令行参数。
|
|
116
|
+
* @param {{emptyArgsMeansHelp: boolean}} options 空参数是否视为帮助。
|
|
117
|
+
* @returns {boolean} 是否请求帮助。
|
|
118
|
+
*/
|
|
119
|
+
function isHelpRequest(args, { emptyArgsMeansHelp } = {}) {
|
|
120
|
+
if (typeof emptyArgsMeansHelp !== 'boolean') {
|
|
121
|
+
throw new Error('launcher-core 缺少必填参数 emptyArgsMeansHelp:三个仓对「空参数是否算帮助」的判断本就不同,必须显式声明。');
|
|
122
|
+
}
|
|
123
|
+
if (emptyArgsMeansHelp && args.length === 0) return true;
|
|
124
|
+
return args.some((arg) => arg === '--help' || arg === '-h');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------------ *
|
|
128
|
+
* 二、Skill 元数据与版本
|
|
129
|
+
* ------------------------------------------------------------------ */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 只读取 SKILL.md 顶部 frontmatter 里的简单键值。
|
|
133
|
+
*
|
|
134
|
+
* 这里故意不接 YAML 库:启动器不能有第三方依赖,而 frontmatter 里真正被使用的只有
|
|
135
|
+
* name / description / version 三个单行字段。引号是可选的,两种引号都要脱掉,
|
|
136
|
+
* 否则 `name: "重点买家预警"` 会连引号一起参与身份比对,把自己挡在门外。
|
|
137
|
+
* @param {string} content SKILL.md 全文。
|
|
138
|
+
* @returns {Record<string, string>} frontmatter 键值;没有 frontmatter 时返回空对象。
|
|
139
|
+
*/
|
|
140
|
+
function parseSkillMetadata(content) {
|
|
141
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
142
|
+
if (!match) return {};
|
|
143
|
+
const metadata = {};
|
|
144
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
145
|
+
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
146
|
+
if (!field) continue;
|
|
147
|
+
let value = field[2].trim();
|
|
148
|
+
if (
|
|
149
|
+
value.length >= 2 &&
|
|
150
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
151
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
152
|
+
) {
|
|
153
|
+
value = value.slice(1, -1);
|
|
154
|
+
}
|
|
155
|
+
metadata[field[1]] = value;
|
|
156
|
+
}
|
|
157
|
+
return metadata;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 解析三段式版本号;任何不合规的写法都返回空值。
|
|
162
|
+
* @param {string} version 版本字符串。
|
|
163
|
+
* @returns {number[] | null} [major, minor, patch]。
|
|
164
|
+
*/
|
|
165
|
+
function parseSemver(version) {
|
|
166
|
+
const match = String(version || '').trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
167
|
+
return match ? match.slice(1).map(Number) : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 比较两个版本号。
|
|
172
|
+
*
|
|
173
|
+
* 解析不出来的版本一律视为「更旧」,这样缺失或畸形的本地版本会被正常升级覆盖,
|
|
174
|
+
* 而不是因为比不出大小就卡住升级链路。两边都解析不出来时视为相等,避免反复重写。
|
|
175
|
+
* @param {string | null} left 左值。
|
|
176
|
+
* @param {string | null} right 右值。
|
|
177
|
+
* @returns {number} -1 / 0 / 1。
|
|
178
|
+
*/
|
|
179
|
+
function compareVersions(left, right) {
|
|
180
|
+
const a = parseSemver(left);
|
|
181
|
+
const b = parseSemver(right);
|
|
182
|
+
if (!a && !b) return 0;
|
|
183
|
+
if (!a) return -1;
|
|
184
|
+
if (!b) return 1;
|
|
185
|
+
for (let index = 0; index < 3; index += 1) {
|
|
186
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
187
|
+
}
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 生成 Skill 版本标记文件的内容。
|
|
193
|
+
*
|
|
194
|
+
* 写入和比对必须用同一个函数生成同一串字节(含结尾换行):曾经写入时带换行、比对时
|
|
195
|
+
* 不带,导致每次启动都判定为「有变化」,于是每次都触发一次 OTA 并要求用户重跑。
|
|
196
|
+
* @param {string} version 目标版本。
|
|
197
|
+
* @returns {string} 标记文件内容。
|
|
198
|
+
*/
|
|
199
|
+
function versionMarkerContent(version) {
|
|
200
|
+
return `${JSON.stringify({ version: requireOption(version, 'version') }, null, 2)}\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 读取本地已安装的 Skill 版本,优先信任版本标记文件。
|
|
205
|
+
*
|
|
206
|
+
* 为什么不直接读 SKILL.md 的 frontmatter:展示名和版本字段会随产品调整改写,
|
|
207
|
+
* 而标记文件是启动器自己写的、格式由自己控制。标记读不到时才退回 registry 里的版本,
|
|
208
|
+
* 让没有标记文件的历史安装也能被正确升级。
|
|
209
|
+
* @param {string} skillDir SKILL.md 所在目录。
|
|
210
|
+
* @param {{markerFileName: string, registryVersion?: string | null}} options 标记文件名与回退版本。
|
|
211
|
+
* @returns {string | null} 本地版本。
|
|
212
|
+
*/
|
|
213
|
+
function readInstalledSkillVersion(skillDir, { markerFileName, registryVersion = null } = {}) {
|
|
214
|
+
requireOption(markerFileName, 'markerFileName');
|
|
215
|
+
const markerPath = path.join(skillDir, markerFileName);
|
|
216
|
+
try {
|
|
217
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
218
|
+
return marker.version || registryVersion || null;
|
|
219
|
+
} catch {
|
|
220
|
+
return registryVersion || null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 校验 `--skill-path` 指向的确实是一份存在的 SKILL.md,并返回绝对路径。
|
|
226
|
+
*
|
|
227
|
+
* basename 校验不能省:这个参数后面会被当成覆盖写入的目标,指错了就是直接改写用户
|
|
228
|
+
* 工作区里的其它文件。大小写不敏感是因为 macOS / Windows 上 `skill.md` 同样能打开。
|
|
229
|
+
* @param {string} skillPath 调用方给出的路径。
|
|
230
|
+
* @returns {string} 解析后的绝对路径。
|
|
231
|
+
*/
|
|
232
|
+
function resolveSkillMdPath(skillPath) {
|
|
233
|
+
const resolvedSkillPath = path.resolve(requireOption(skillPath, 'skillPath'));
|
|
234
|
+
if (path.basename(resolvedSkillPath).toLowerCase() !== 'skill.md') {
|
|
235
|
+
throw new Error('--skill-path 必须指向 SKILL.md');
|
|
236
|
+
}
|
|
237
|
+
if (!fs.existsSync(resolvedSkillPath)) {
|
|
238
|
+
throw new Error(`SKILL.md 不存在:${resolvedSkillPath}`);
|
|
239
|
+
}
|
|
240
|
+
return resolvedSkillPath;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 读取并初检 npm 包内置的 SKILL.md。
|
|
245
|
+
*
|
|
246
|
+
* 只做「包自身是否完整」这层检查(文件在不在、name/description 有没有),
|
|
247
|
+
* 至于内置 name 是否等于本 Skill 的展示名,交给各仓自己判断:三个仓的身份判定规则
|
|
248
|
+
* 本来就不一样(有的比展示名、有的比内部 ID、有的还要兼容历史名),
|
|
249
|
+
* 把它塞进共享内核只会变成一堆互相矛盾的开关。
|
|
250
|
+
* @param {string} bundledSkillPath 内置 SKILL.md 路径。
|
|
251
|
+
* @returns {{content: string, metadata: Record<string, string>}} 内置内容与元数据。
|
|
252
|
+
*/
|
|
253
|
+
function loadBundledSkill(bundledSkillPath) {
|
|
254
|
+
requireOption(bundledSkillPath, 'bundledSkillPath');
|
|
255
|
+
if (!fs.existsSync(bundledSkillPath)) {
|
|
256
|
+
throw new Error('npm 包缺少内置 SKILL.md,请重新安装当前版本');
|
|
257
|
+
}
|
|
258
|
+
const content = fs.readFileSync(bundledSkillPath, 'utf8');
|
|
259
|
+
const metadata = parseSkillMetadata(content);
|
|
260
|
+
if (!metadata.name || !metadata.description) {
|
|
261
|
+
throw new Error('内置 SKILL.md 缺少 name 或 description');
|
|
262
|
+
}
|
|
263
|
+
return { content, metadata };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ------------------------------------------------------------------ *
|
|
267
|
+
* 三、skills.jsonc 注册表
|
|
268
|
+
* ------------------------------------------------------------------ */
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 去掉 JSONC 里的注释和尾逗号,再交给 JSON.parse。
|
|
272
|
+
*
|
|
273
|
+
* 智能体侧的 skills.jsonc 是人手维护的,带注释和尾逗号是常态。这里必须是一个真正的
|
|
274
|
+
* 状态机而不是正则:注释符号完全可能出现在字符串值里(比如 URL 或 Windows 路径),
|
|
275
|
+
* 用正则一刀切会把合法内容剪掉,从而把整份注册表改坏。
|
|
276
|
+
* @param {string} content JSONC 文本。
|
|
277
|
+
* @returns {string} 可被 JSON.parse 的文本。
|
|
278
|
+
*/
|
|
279
|
+
function stripJsonComments(content) {
|
|
280
|
+
let output = '';
|
|
281
|
+
let inString = false;
|
|
282
|
+
let escaped = false;
|
|
283
|
+
let lineComment = false;
|
|
284
|
+
let blockComment = false;
|
|
285
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
286
|
+
const char = content[index];
|
|
287
|
+
const next = content[index + 1];
|
|
288
|
+
if (lineComment) {
|
|
289
|
+
if (char === '\n') {
|
|
290
|
+
lineComment = false;
|
|
291
|
+
output += char;
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (blockComment) {
|
|
296
|
+
if (char === '*' && next === '/') {
|
|
297
|
+
blockComment = false;
|
|
298
|
+
index += 1;
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (inString) {
|
|
303
|
+
output += char;
|
|
304
|
+
if (escaped) escaped = false;
|
|
305
|
+
else if (char === '\\') escaped = true;
|
|
306
|
+
else if (char === '"') inString = false;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (char === '"') {
|
|
310
|
+
inString = true;
|
|
311
|
+
output += char;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (char === '/' && next === '/') {
|
|
315
|
+
lineComment = true;
|
|
316
|
+
index += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (char === '/' && next === '*') {
|
|
320
|
+
blockComment = true;
|
|
321
|
+
index += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
output += char;
|
|
325
|
+
}
|
|
326
|
+
return output.replace(/,\s*([}\]])/g, '$1');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 读取一份 skills.jsonc。
|
|
331
|
+
* @param {string} registryPath 注册表路径。
|
|
332
|
+
* @returns {object} 解析结果。
|
|
333
|
+
*/
|
|
334
|
+
function readSkillsRegistry(registryPath) {
|
|
335
|
+
return JSON.parse(stripJsonComments(fs.readFileSync(registryPath, 'utf8')));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 归一化路径以便比较。
|
|
340
|
+
*
|
|
341
|
+
* Windows 上同一个目录会以不同大小写出现在 registry 和实际调用里,直接字符串比较
|
|
342
|
+
* 会找不到条目,于是启动器误判「registry 里没有这个 Skill」并抛错终止。
|
|
343
|
+
* 只在 win32 上小写化:Linux 下路径大小写敏感,强行小写会把两个不同目录判成同一个。
|
|
344
|
+
* @param {string} filePath 任意路径。
|
|
345
|
+
* @param {{platform?: string}} options 平台名,测试可覆盖。
|
|
346
|
+
* @returns {string} 归一化后的绝对路径。
|
|
347
|
+
*/
|
|
348
|
+
function normalizePath(filePath, { platform = process.platform } = {}) {
|
|
349
|
+
const resolved = path.resolve(filePath);
|
|
350
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 在注册表里按安装目录找到本 Skill 的条目。
|
|
355
|
+
* @param {object} data 已解析的 skills.jsonc。
|
|
356
|
+
* @param {string} skillDir SKILL.md 所在目录。
|
|
357
|
+
* @param {{platform?: string}} options 平台名,测试可覆盖。
|
|
358
|
+
* @returns {object | undefined} 命中的条目。
|
|
359
|
+
*/
|
|
360
|
+
function findRegistryEntry(data, skillDir, { platform = process.platform } = {}) {
|
|
361
|
+
const target = normalizePath(skillDir, { platform });
|
|
362
|
+
return data.skills?.find(
|
|
363
|
+
(entry) => entry.installPath && normalizePath(entry.installPath, { platform }) === target
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 预演一次 skills.jsonc 更新,但不落盘。
|
|
369
|
+
*
|
|
370
|
+
* 拆成「预演 + 提交」两步是为了让 registry 里找不到条目这类错误在**写任何文件之前**
|
|
371
|
+
* 就抛出来。否则 SKILL.md 已经被覆盖、registry 却写不动,用户手上就剩下半个新版 Skill。
|
|
372
|
+
*
|
|
373
|
+
* `targetVersion` 必须显式传入:三个仓里有的写 npm 包版本、有的写内置 frontmatter 里的
|
|
374
|
+
* 版本,取值来源不同,共享内核不替谁做决定。
|
|
375
|
+
* @param {string} skillPath SKILL.md 绝对路径。
|
|
376
|
+
* @param {{name: string, description: string}} metadata 内置 Skill 元数据。
|
|
377
|
+
* @param {{targetVersion: string, platform?: string}} options 目标版本。
|
|
378
|
+
* @returns {{registryPath: string | null, currentVersion: string | null, changed: boolean, content: string | null}} 预演结果。
|
|
379
|
+
*/
|
|
380
|
+
function registryUpdatePreview(skillPath, metadata, { targetVersion, platform = process.platform } = {}) {
|
|
381
|
+
requireOption(targetVersion, 'targetVersion');
|
|
382
|
+
const skillDir = path.dirname(skillPath);
|
|
383
|
+
const registryPath = path.join(skillDir, '..', 'skills.jsonc');
|
|
384
|
+
if (!fs.existsSync(registryPath)) {
|
|
385
|
+
return { registryPath: null, currentVersion: null, changed: false, content: null };
|
|
386
|
+
}
|
|
387
|
+
const data = readSkillsRegistry(registryPath);
|
|
388
|
+
const entry = findRegistryEntry(data, skillDir, { platform });
|
|
389
|
+
if (!entry) {
|
|
390
|
+
throw new Error(`skills.jsonc 未找到 installPath 对应项:${skillDir}`);
|
|
391
|
+
}
|
|
392
|
+
const before = JSON.stringify(entry);
|
|
393
|
+
const currentVersion = entry.version || null;
|
|
394
|
+
entry.name = metadata.name;
|
|
395
|
+
entry.description = metadata.description;
|
|
396
|
+
entry.version = targetVersion;
|
|
397
|
+
return {
|
|
398
|
+
registryPath,
|
|
399
|
+
currentVersion,
|
|
400
|
+
changed: before !== JSON.stringify(entry),
|
|
401
|
+
content: `${JSON.stringify(data, null, 2)}\n`
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 写入之后再把 registry 读回来核对一遍。
|
|
407
|
+
*
|
|
408
|
+
* 写入成功不等于内容正确:注册表可能被别的进程同时改写,也可能因为 JSONC 里的注释
|
|
409
|
+
* 被 stripJsonComments 处理后丢失了某个字段。核对失败要让调用方走回滚,而不是让一份
|
|
410
|
+
* 版本号对不上的 registry 留在用户机器上——那会让下一次启动每次都判定需要升级。
|
|
411
|
+
* @param {{registryPath: string, skillDir: string, version: string, name: string, description: string, platform?: string}} options 期望值。
|
|
412
|
+
* @returns {void}
|
|
413
|
+
*/
|
|
414
|
+
function assertRegistryEntryWritten({ registryPath, skillDir, version, name, description, platform = process.platform }) {
|
|
415
|
+
const writtenEntry = findRegistryEntry(readSkillsRegistry(registryPath), skillDir, { platform });
|
|
416
|
+
if (
|
|
417
|
+
!writtenEntry ||
|
|
418
|
+
writtenEntry.version !== version ||
|
|
419
|
+
writtenEntry.name !== name ||
|
|
420
|
+
writtenEntry.description !== description
|
|
421
|
+
) {
|
|
422
|
+
throw new Error('写入后的 skills.jsonc 校验失败');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ------------------------------------------------------------------ *
|
|
427
|
+
* 四、可回滚的写入事务
|
|
428
|
+
* ------------------------------------------------------------------ */
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 记录单个文件的当前状态(含「原本不存在」这一状态)。
|
|
432
|
+
* @param {string} filePath 文件路径。
|
|
433
|
+
* @returns {{exists: boolean, content: Buffer | null}} 快照。
|
|
434
|
+
*/
|
|
435
|
+
function snapshotFile(filePath) {
|
|
436
|
+
return fs.existsSync(filePath)
|
|
437
|
+
? { exists: true, content: fs.readFileSync(filePath) }
|
|
438
|
+
: { exists: false, content: null };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 按快照还原单个文件。
|
|
443
|
+
*
|
|
444
|
+
* 这里的 mkdir 不能省:回滚时目标目录可能是本次刚创建的(例如首次写入 agents/openai.yaml),
|
|
445
|
+
* 直接 writeFileSync 会因为父目录不存在而在回滚过程中再抛一次错,把原始错误盖掉。
|
|
446
|
+
* 三份 index.js 里 review-customer-insight 那份恰好漏了这行,这里统一按有 mkdir 的写法。
|
|
447
|
+
* @param {string} filePath 文件路径。
|
|
448
|
+
* @param {{exists: boolean, content: Buffer | null}} snapshot 快照。
|
|
449
|
+
* @returns {void}
|
|
450
|
+
*/
|
|
451
|
+
function restoreFile(filePath, snapshot) {
|
|
452
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
453
|
+
if (snapshot.exists) fs.writeFileSync(filePath, snapshot.content);
|
|
454
|
+
else fs.rmSync(filePath, { force: true });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 记录整棵目录树的内容。
|
|
459
|
+
*
|
|
460
|
+
* 资源目录(模板、策略、图片)是整体替换的,回滚也必须整体还原:只还原被改动的文件会
|
|
461
|
+
* 把新版新增的文件留在原地,得到一份「一半新一半旧」的资源目录,比彻底失败更难排查。
|
|
462
|
+
* @param {string} root 目录根。
|
|
463
|
+
* @returns {{exists: boolean, files: Map<string, Buffer>}} 快照。
|
|
464
|
+
*/
|
|
465
|
+
function snapshotTree(root) {
|
|
466
|
+
if (!fs.existsSync(root)) return { exists: false, files: new Map() };
|
|
467
|
+
const files = new Map();
|
|
468
|
+
const visit = (current, relative = '') => {
|
|
469
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
470
|
+
const childRelative = path.join(relative, entry.name);
|
|
471
|
+
const childPath = path.join(current, entry.name);
|
|
472
|
+
if (entry.isDirectory()) visit(childPath, childRelative);
|
|
473
|
+
else if (entry.isFile()) files.set(childRelative, fs.readFileSync(childPath));
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
visit(root);
|
|
477
|
+
return { exists: true, files };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* 按快照还原整棵目录树;原本不存在就整棵删掉。
|
|
482
|
+
* @param {string} root 目录根。
|
|
483
|
+
* @param {{exists: boolean, files: Map<string, Buffer>}} snapshot 快照。
|
|
484
|
+
* @returns {void}
|
|
485
|
+
*/
|
|
486
|
+
function restoreTree(root, snapshot) {
|
|
487
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
488
|
+
if (!snapshot.exists) return;
|
|
489
|
+
for (const [relative, content] of snapshot.files) {
|
|
490
|
+
const target = path.join(root, relative);
|
|
491
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
492
|
+
fs.writeFileSync(target, content);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 计算目录树的内容摘要,用来判断「要不要重写」和「写完对不对」。
|
|
498
|
+
*
|
|
499
|
+
* 路径分隔符要归一化成 `/` 再进摘要,否则同一份资源在 Windows 和 macOS 上算出不同摘要,
|
|
500
|
+
* 于是 Windows 用户每次启动都被判定为「资源有变化」,每次都要重新同步并重跑一次。
|
|
501
|
+
* 每段之间插 `\0` 是为了避免「文件名尾巴 + 内容开头」拼出与另一组相同的字节序列。
|
|
502
|
+
* @param {string} root 目录根。
|
|
503
|
+
* @returns {string | null} sha256;目录不存在时为空值。
|
|
504
|
+
*/
|
|
505
|
+
function treeDigest(root) {
|
|
506
|
+
if (!fs.existsSync(root)) return null;
|
|
507
|
+
const hash = crypto.createHash('sha256');
|
|
508
|
+
const snapshot = snapshotTree(root);
|
|
509
|
+
for (const [relative, content] of [...snapshot.files.entries()].sort()) {
|
|
510
|
+
hash.update(relative.replaceAll(path.sep, '/'));
|
|
511
|
+
hash.update('\0');
|
|
512
|
+
hash.update(content);
|
|
513
|
+
hash.update('\0');
|
|
514
|
+
}
|
|
515
|
+
return hash.digest('hex');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 把一组文件/目录的写入包成一次「全成功或全还原」的事务。
|
|
520
|
+
*
|
|
521
|
+
* 三个仓的 syncSkillInstallation 各自内联了一遍这个骨架,而它恰恰是整个启动器里
|
|
522
|
+
* 最不能出错的一段:写到一半失败会让用户手上留下半个新版 Skill——SKILL.md 是新的、
|
|
523
|
+
* 渲染脚本还是旧的,下一次启动既不报错也跑不对。
|
|
524
|
+
*
|
|
525
|
+
* verify 一定要在事务内执行:写完之后的自检(名称对不对、版本标记对不对、registry 对不对)
|
|
526
|
+
* 失败时同样必须回滚,否则「校验」只是打印了一句话而已。
|
|
527
|
+
* @param {{files?: string[], trees?: string[], apply: () => *, verify?: () => void}} options 事务描述。
|
|
528
|
+
* @returns {*} apply 的返回值。
|
|
529
|
+
*/
|
|
530
|
+
function runWithRollback({ files = [], trees = [], apply, verify } = {}) {
|
|
531
|
+
if (typeof apply !== 'function') {
|
|
532
|
+
throw new Error('launcher-core 缺少必填参数 apply:事务必须显式给出写入动作。');
|
|
533
|
+
}
|
|
534
|
+
const fileSnapshots = files.map((filePath) => [filePath, snapshotFile(filePath)]);
|
|
535
|
+
const treeSnapshots = trees.map((root) => [root, snapshotTree(root)]);
|
|
536
|
+
try {
|
|
537
|
+
const result = apply();
|
|
538
|
+
if (verify) verify();
|
|
539
|
+
return result;
|
|
540
|
+
} catch (error) {
|
|
541
|
+
for (const [filePath, snapshot] of fileSnapshots) restoreFile(filePath, snapshot);
|
|
542
|
+
for (const [root, snapshot] of treeSnapshots) restoreTree(root, snapshot);
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* ------------------------------------------------------------------ *
|
|
548
|
+
* 五、Skill 安装位置发现与门禁
|
|
549
|
+
* ------------------------------------------------------------------ */
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 扫描本机智能体目录,找出可能是本 Skill 的安装位置。
|
|
553
|
+
*
|
|
554
|
+
* 这是给「调用方忘了传 --skill-path」准备的兜底:不是为了替它做决定,而是为了能在
|
|
555
|
+
* 报错里点名具体路径。判定函数由调用方注入,因为三个仓的识别特征各不相同(有的靠
|
|
556
|
+
* npm 包名出现在正文里,有的靠 frontmatter 的 name 前缀),写死在共享内核里的话,
|
|
557
|
+
* 第四个 Skill 接进来时就得改这个文件。
|
|
558
|
+
*
|
|
559
|
+
* 任何一层读失败都只跳过、不抛错:用户机器上完全可能存在权限不足或损坏的 skills.jsonc,
|
|
560
|
+
* 那不该让一次正常启动失败。
|
|
561
|
+
* @param {{homeDir?: string, matches: (content: string, skillPath: string) => boolean, platform?: string}} options 扫描选项。
|
|
562
|
+
* @returns {string[]} 去重后的候选 SKILL.md 绝对路径。
|
|
563
|
+
*/
|
|
564
|
+
function discoverLegacySkillPaths({ homeDir = os.homedir(), matches, platform = process.platform } = {}) {
|
|
565
|
+
if (typeof matches !== 'function') {
|
|
566
|
+
throw new Error('launcher-core 缺少必填参数 matches:各 Skill 的识别特征不同,必须由调用方注入判定函数。');
|
|
567
|
+
}
|
|
568
|
+
const accountsRoot = path.join(homeDir, '.accio', 'accounts');
|
|
569
|
+
if (!fs.existsSync(accountsRoot)) return [];
|
|
570
|
+
const candidates = [];
|
|
571
|
+
for (const account of fs.readdirSync(accountsRoot, { withFileTypes: true })) {
|
|
572
|
+
if (!account.isDirectory()) continue;
|
|
573
|
+
const agentsRoot = path.join(accountsRoot, account.name, 'agents');
|
|
574
|
+
if (!fs.existsSync(agentsRoot)) continue;
|
|
575
|
+
for (const agent of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
576
|
+
if (!agent.isDirectory()) continue;
|
|
577
|
+
const registryPath = path.join(agentsRoot, agent.name, 'agent-core', 'skills', 'skills.jsonc');
|
|
578
|
+
if (!fs.existsSync(registryPath)) continue;
|
|
579
|
+
let data;
|
|
580
|
+
try {
|
|
581
|
+
data = readSkillsRegistry(registryPath);
|
|
582
|
+
} catch {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
for (const entry of data.skills || []) {
|
|
586
|
+
if (!entry.installPath) continue;
|
|
587
|
+
const skillPath = path.join(entry.installPath, 'SKILL.md');
|
|
588
|
+
if (!fs.existsSync(skillPath)) continue;
|
|
589
|
+
let content;
|
|
590
|
+
try {
|
|
591
|
+
content = fs.readFileSync(skillPath, 'utf8');
|
|
592
|
+
} catch {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (matches(content, skillPath)) candidates.push(path.resolve(skillPath));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return [...new Set(candidates.map((candidate) => normalizePath(candidate, { platform })))];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Skill OTA 门禁:决定这一次调用是「可以干活」还是「先同步再重来」。
|
|
604
|
+
*
|
|
605
|
+
* 只有「本地副本与 npm 包完全一致」才放行。一旦发生了同步,本次就必须停:智能体是
|
|
606
|
+
* 按它**已经读进上下文的那份 SKILL.md** 在调用参数的,磁盘上换成新版之后再执行,
|
|
607
|
+
* 参数含义可能已经不同了。
|
|
608
|
+
*
|
|
609
|
+
* `discoveredPathPolicy` 显式区分两种历史行为,不设默认值偏向,因为它会改变退出协议:
|
|
610
|
+
* - 'require_argument':没传 --skill-path 时**永远不放行**,并在结论里点名参数和路径。
|
|
611
|
+
* 缺的是参数,不是同步;含糊的「请重新试一次」正好会诱导调用方原样重试——重试一万次
|
|
612
|
+
* 也不会成功。priority-buyer-alert 已经改成这个语义。
|
|
613
|
+
* - 'restart_required':public-customer-reactivation 与 review-customer-insight 的现状,
|
|
614
|
+
* 把扫描出来的路径当作已同步,返回 restart_required 让调用方重跑。迁移时先保持原样,
|
|
615
|
+
* 确认上层文案配套后再切换到 'require_argument'。
|
|
616
|
+
* @param {string | null} skillPath 调用方传入的路径。
|
|
617
|
+
* @param {{syncSkill: (path: string) => object, discoverSkillPaths: () => string[], discoveredPathPolicy: 'require_argument' | 'restart_required'}} options 注入项。
|
|
618
|
+
* @returns {{allowRun: boolean, sync: object}} 门禁结论。
|
|
619
|
+
*/
|
|
620
|
+
function resolveSkillGate(skillPath, { syncSkill, discoverSkillPaths, discoveredPathPolicy } = {}) {
|
|
621
|
+
if (typeof syncSkill !== 'function') {
|
|
622
|
+
throw new Error('launcher-core 缺少必填参数 syncSkill:同步哪些产物是各仓自己的事,必须注入。');
|
|
623
|
+
}
|
|
624
|
+
if (typeof discoverSkillPaths !== 'function') {
|
|
625
|
+
throw new Error('launcher-core 缺少必填参数 discoverSkillPaths:识别特征各仓不同,必须注入。');
|
|
626
|
+
}
|
|
627
|
+
if (!['require_argument', 'restart_required'].includes(discoveredPathPolicy)) {
|
|
628
|
+
throw new Error("launcher-core 缺少必填参数 discoveredPathPolicy:只接受 'require_argument' 或 'restart_required',该选择会改变退出协议,不设默认值。");
|
|
629
|
+
}
|
|
630
|
+
if (skillPath) {
|
|
631
|
+
const sync = syncSkill(skillPath);
|
|
632
|
+
return { allowRun: sync.action === 'current', sync };
|
|
633
|
+
}
|
|
634
|
+
const candidates = discoverSkillPaths();
|
|
635
|
+
if (candidates.length !== 1) {
|
|
636
|
+
return { allowRun: false, sync: { action: 'skill_path_required', candidates } };
|
|
637
|
+
}
|
|
638
|
+
const sync = syncSkill(candidates[0]);
|
|
639
|
+
if (sync.action === 'local_newer') {
|
|
640
|
+
// 本地版本更高是一个独立结论:它不是「缺参数」,也不该被重跑修复。
|
|
641
|
+
return { allowRun: false, sync };
|
|
642
|
+
}
|
|
643
|
+
if (discoveredPathPolicy === 'require_argument') {
|
|
644
|
+
return {
|
|
645
|
+
allowRun: false,
|
|
646
|
+
sync: { ...sync, action: 'skill_path_required', candidates, synced: sync.action !== 'current' }
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
return { allowRun: false, sync: { ...sync, action: 'restart_required' } };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* ------------------------------------------------------------------ *
|
|
653
|
+
* 六、平台识别与下载源
|
|
654
|
+
* ------------------------------------------------------------------ */
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 把当前进程的平台/架构映射为发布产物条目。
|
|
658
|
+
*
|
|
659
|
+
* 不支持的平台要在报错里列出**已支持**的清单:只说「暂不支持」的话,用户唯一能做的
|
|
660
|
+
* 就是换台机器试,而列出清单能立刻说明是缺 Windows 还是缺 x64。
|
|
661
|
+
* @param {{platforms: Record<string, object>, binName: string, platform?: string, arch?: string}} options 平台表与二进制前缀。
|
|
662
|
+
* @returns {object} 平台条目,附带 binaries 数组。
|
|
663
|
+
*/
|
|
664
|
+
function detectTarget({ platforms, binName, platform = process.platform, arch = process.arch } = {}) {
|
|
665
|
+
requireOption(platforms, 'platforms');
|
|
666
|
+
requireOption(binName, 'binName');
|
|
667
|
+
const entry = platforms[`${platform}-${arch}`];
|
|
668
|
+
if (!entry) {
|
|
669
|
+
const supported = Object.values(platforms).map((item) => item.id).join('、');
|
|
670
|
+
throw new Error(`暂不支持当前平台:${platform}/${arch}。已支持:${supported}。`);
|
|
671
|
+
}
|
|
672
|
+
return { ...entry, binaries: [`${binName}-${entry.id}${entry.exe || ''}`] };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* 生成业务运行时下载源,固定保持主源优先、备用源在后。
|
|
677
|
+
*
|
|
678
|
+
* 显式覆盖主源时不擅自追加默认备用源:私有部署或联调时把主源指到内网,却偷偷保留一个
|
|
679
|
+
* 公网备用源,会让「内网下载失败」被公网悄悄兜住,从而掩盖配置错误,也越过了部署边界。
|
|
680
|
+
* 去重和去尾斜杠是因为拼接时统一按 `${base}/${file}`,尾斜杠会拼出 `//` 这种在某些
|
|
681
|
+
* 对象存储上直接 404 的路径。
|
|
682
|
+
* @param {{defaultPrimary: string, defaultFallbacks?: string[], overridePrimary?: string | null, overrideFallbacks?: string | string[] | null}} options 下载源。
|
|
683
|
+
* @returns {string[]} 已清理、去重并保持优先级的下载根地址。
|
|
684
|
+
*/
|
|
685
|
+
function resolveReleaseBaseUrls({
|
|
686
|
+
defaultPrimary,
|
|
687
|
+
defaultFallbacks = [],
|
|
688
|
+
overridePrimary = null,
|
|
689
|
+
overrideFallbacks = null
|
|
690
|
+
} = {}) {
|
|
691
|
+
requireOption(defaultPrimary, 'defaultPrimary');
|
|
692
|
+
const primary = overridePrimary || defaultPrimary;
|
|
693
|
+
const rawFallbacks = overrideFallbacks
|
|
694
|
+
? (Array.isArray(overrideFallbacks) ? overrideFallbacks : String(overrideFallbacks).split(','))
|
|
695
|
+
: overridePrimary
|
|
696
|
+
? []
|
|
697
|
+
: defaultFallbacks;
|
|
698
|
+
return [
|
|
699
|
+
...new Set(
|
|
700
|
+
[primary, ...rawFallbacks]
|
|
701
|
+
.filter(Boolean)
|
|
702
|
+
.map((value) => String(value).trim().replace(/\/$/, ''))
|
|
703
|
+
)
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 按下载源优先级生成当前平台归档地址。
|
|
709
|
+
* @param {string[]} baseUrls 下载根地址。
|
|
710
|
+
* @param {object} target 平台条目。
|
|
711
|
+
* @returns {string[]} 归档地址。
|
|
712
|
+
*/
|
|
713
|
+
function releaseUrls(baseUrls, target) {
|
|
714
|
+
return baseUrls.map((baseUrl) => `${baseUrl}/${target.archive}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* 根据运行时下载源生成一一对应的外部清单地址。
|
|
719
|
+
* @param {string[]} baseUrls 下载根地址。
|
|
720
|
+
* @returns {string[]} 清单地址。
|
|
721
|
+
*/
|
|
722
|
+
function runtimeManifestUrls(baseUrls) {
|
|
723
|
+
return baseUrls.map((baseUrl) => `${baseUrl}/runtime-manifest.json`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/* ------------------------------------------------------------------ *
|
|
727
|
+
* 七、下载与校验
|
|
728
|
+
* ------------------------------------------------------------------ */
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 计算文件的 sha256。
|
|
732
|
+
* @param {string} filePath 文件路径。
|
|
733
|
+
* @returns {string} 十六进制摘要。
|
|
734
|
+
*/
|
|
735
|
+
function sha256File(filePath) {
|
|
736
|
+
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* 下载单个文件。
|
|
741
|
+
*
|
|
742
|
+
* 手写而不是用 fetch/第三方库的原因:启动器要兼容 Node 18,且不能有依赖。
|
|
743
|
+
* 几个细节都对应过真实故障:
|
|
744
|
+
* - 必须跟随重定向(对象存储的自定义域名会 302),但要有次数上限,否则循环重定向会挂死。
|
|
745
|
+
* - 必须显式处理 'aborted':连接被中断时 finish 事件不会触发,Promise 会永远悬着,
|
|
746
|
+
* 表现为启动器卡住不动、也不报错。
|
|
747
|
+
* - 必须有超时:默认无超时的情况下,一个不响应的源能让启动器无限期等待。
|
|
748
|
+
* @param {string} url 下载地址。
|
|
749
|
+
* @param {string} outputPath 落盘路径。
|
|
750
|
+
* @param {{userAgent: string, redirects?: number, timeoutMs?: number}} options 下载选项。
|
|
751
|
+
* @returns {Promise<void>} 下载完成。
|
|
752
|
+
*/
|
|
753
|
+
function download(url, outputPath, { userAgent, redirects = 5, timeoutMs = 60000 } = {}) {
|
|
754
|
+
requireOption(userAgent, 'userAgent');
|
|
755
|
+
return new Promise((resolve, reject) => {
|
|
756
|
+
const client = url.startsWith('http:') ? http : https;
|
|
757
|
+
const request = client.get(url, { headers: { 'User-Agent': userAgent } }, (response) => {
|
|
758
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
|
759
|
+
response.resume();
|
|
760
|
+
if (!response.headers.location || redirects === 0) {
|
|
761
|
+
reject(new Error(`下载重定向过多:${url}`));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
download(new URL(response.headers.location, url).toString(), outputPath, {
|
|
765
|
+
userAgent,
|
|
766
|
+
redirects: redirects - 1,
|
|
767
|
+
timeoutMs
|
|
768
|
+
}).then(resolve, reject);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (response.statusCode !== 200) {
|
|
772
|
+
response.resume();
|
|
773
|
+
reject(new Error(`下载失败:HTTP ${response.statusCode} ${url}`));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const file = fs.createWriteStream(outputPath);
|
|
777
|
+
response.pipe(file);
|
|
778
|
+
response.on('aborted', () => {
|
|
779
|
+
file.destroy();
|
|
780
|
+
reject(new Error(`下载连接意外中断:${url}`));
|
|
781
|
+
});
|
|
782
|
+
response.on('error', (error) => file.destroy(error));
|
|
783
|
+
file.on('finish', () => file.close(resolve));
|
|
784
|
+
file.on('error', reject);
|
|
785
|
+
});
|
|
786
|
+
request.setTimeout(timeoutMs, () => request.destroy(new Error(`下载超时:${url}`)));
|
|
787
|
+
request.on('error', reject);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* 校验运行时清单与归档是否匹配当前启动器。
|
|
793
|
+
*
|
|
794
|
+
* schema 字段是各 Skill 自己的字符串,必须由调用方传入:两个 Skill 的产物可能被放到
|
|
795
|
+
* 同一个对象存储桶里,只比版本号会让「下错 Skill 的包」这种事一路通过校验。
|
|
796
|
+
* 大小和 sha256 都要比:只比 sha256 时,一个被截断的空文件如果恰好命中缓存里的旧摘要
|
|
797
|
+
* 就会被放行;先比大小能让绝大多数损坏在算摘要之前就被拦下。
|
|
798
|
+
* @param {object} manifest 运行时清单。
|
|
799
|
+
* @param {object} target 平台条目。
|
|
800
|
+
* @param {string} archivePath 归档路径。
|
|
801
|
+
* @param {{manifestSchema: string, runtimeVersion: string}} options 期望的 schema 与版本。
|
|
802
|
+
* @returns {void}
|
|
803
|
+
*/
|
|
804
|
+
function verifyReleaseArchive(manifest, target, archivePath, { manifestSchema, runtimeVersion } = {}) {
|
|
805
|
+
requireOption(manifestSchema, 'manifestSchema');
|
|
806
|
+
requireOption(runtimeVersion, 'runtimeVersion');
|
|
807
|
+
if (manifest?.schema !== manifestSchema || manifest.runtime_version !== runtimeVersion) {
|
|
808
|
+
throw new Error('公共运行时清单版本与当前启动器不一致');
|
|
809
|
+
}
|
|
810
|
+
const expected = manifest.platforms?.[target.id];
|
|
811
|
+
if (!expected || expected.archive !== target.archive) {
|
|
812
|
+
throw new Error(`公共运行时清单缺少平台 ${target.id}`);
|
|
813
|
+
}
|
|
814
|
+
const stat = fs.statSync(archivePath);
|
|
815
|
+
if (stat.size !== expected.size || sha256File(archivePath) !== expected.sha256) {
|
|
816
|
+
throw new Error(`公共运行时压缩包校验失败:${target.archive}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 从同一个来源成对下载清单和压缩包,并在切换来源前完成版本、平台和 SHA256 校验。
|
|
822
|
+
*
|
|
823
|
+
* 清单与文件必须同源处理,避免主源清单与备用源文件版本漂移时被错误组合:主源清单说
|
|
824
|
+
* sha 是 A、备用源上的归档其实是上一版,两边各自都是「好的」,组合起来却是一个永远
|
|
825
|
+
* 校验不过、或者更糟——恰好校验通过但版本对不上的运行时。
|
|
826
|
+
*
|
|
827
|
+
* 每次换源前先删掉上一轮的残留文件,否则上一次下载了一半的归档会被当成本次结果。
|
|
828
|
+
* @param {object} target 当前平台的归档名称和标识。
|
|
829
|
+
* @param {string} releaseManifestPath 临时清单文件路径。
|
|
830
|
+
* @param {string} archivePath 临时运行时归档路径。
|
|
831
|
+
* @param {{baseUrls: string[], downloadFile: (url: string, outputPath: string) => Promise<void>, verifyArchive: (manifest: object, target: object, archivePath: string) => void}} options 注入项。
|
|
832
|
+
* @returns {Promise<object>} 与归档同源且已通过校验的运行时清单。
|
|
833
|
+
*/
|
|
834
|
+
async function downloadVerifiedRelease(target, releaseManifestPath, archivePath, {
|
|
835
|
+
baseUrls,
|
|
836
|
+
downloadFile,
|
|
837
|
+
verifyArchive
|
|
838
|
+
} = {}) {
|
|
839
|
+
requireOption(baseUrls, 'baseUrls');
|
|
840
|
+
if (typeof downloadFile !== 'function') {
|
|
841
|
+
throw new Error('launcher-core 缺少必填参数 downloadFile:User-Agent 属于各仓身份,必须由调用方绑定后注入。');
|
|
842
|
+
}
|
|
843
|
+
if (typeof verifyArchive !== 'function') {
|
|
844
|
+
throw new Error('launcher-core 缺少必填参数 verifyArchive:清单 schema 与运行时版本属于各仓身份,必须由调用方绑定后注入。');
|
|
845
|
+
}
|
|
846
|
+
const failures = [];
|
|
847
|
+
for (const baseUrl of baseUrls) {
|
|
848
|
+
const root = String(baseUrl).trim().replace(/\/$/, '');
|
|
849
|
+
try {
|
|
850
|
+
fs.rmSync(releaseManifestPath, { force: true });
|
|
851
|
+
fs.rmSync(archivePath, { force: true });
|
|
852
|
+
await downloadFile(`${root}/runtime-manifest.json`, releaseManifestPath);
|
|
853
|
+
const manifest = JSON.parse(fs.readFileSync(releaseManifestPath, 'utf8'));
|
|
854
|
+
await downloadFile(`${root}/${target.archive}`, archivePath);
|
|
855
|
+
verifyArchive(manifest, target, archivePath);
|
|
856
|
+
return manifest;
|
|
857
|
+
} catch (error) {
|
|
858
|
+
fs.rmSync(releaseManifestPath, { force: true });
|
|
859
|
+
fs.rmSync(archivePath, { force: true });
|
|
860
|
+
failures.push(`${root}: ${error.message}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
throw new Error(`所有下载源均未通过运行时校验:\n${failures.join('\n')}`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* 解压后再按包内 checksums.json 逐个核对二进制。
|
|
868
|
+
*
|
|
869
|
+
* 外层已经校验过归档的 sha256,这里再核一遍解压结果,是因为两者防的不是同一件事:
|
|
870
|
+
* 外层防「下载链路把包换了/传坏了」,这里防「包本身内容与它自称的版本不符」,
|
|
871
|
+
* 也防解压工具在个别平台上静默截断。
|
|
872
|
+
* @param {string} extractedDir 解压目录。
|
|
873
|
+
* @param {object} target 平台条目。
|
|
874
|
+
* @param {{runtimeVersion: string}} options 期望的运行时版本。
|
|
875
|
+
* @returns {Record<string, {size: number, sha256: string}>} 校验通过的文件清单。
|
|
876
|
+
*/
|
|
877
|
+
function verifyExtractedRuntime(extractedDir, target, { runtimeVersion } = {}) {
|
|
878
|
+
requireOption(runtimeVersion, 'runtimeVersion');
|
|
879
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(extractedDir, 'checksums.json'), 'utf8'));
|
|
880
|
+
if (manifest.version !== runtimeVersion || manifest.platform !== target.id) {
|
|
881
|
+
throw new Error('下载包版本或平台与当前启动器不一致');
|
|
882
|
+
}
|
|
883
|
+
for (const name of target.binaries) {
|
|
884
|
+
const expected = manifest.files?.[name];
|
|
885
|
+
const filePath = path.join(extractedDir, name);
|
|
886
|
+
if (
|
|
887
|
+
!expected ||
|
|
888
|
+
!fs.existsSync(filePath) ||
|
|
889
|
+
fs.statSync(filePath).size !== expected.size ||
|
|
890
|
+
sha256File(filePath) !== expected.sha256
|
|
891
|
+
) {
|
|
892
|
+
throw new Error(`下载包校验失败:${name}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return manifest.files;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/* ------------------------------------------------------------------ *
|
|
899
|
+
* 八、本地运行时安装
|
|
900
|
+
* ------------------------------------------------------------------ */
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* 执行外部命令并把失败转成可读错误。
|
|
904
|
+
*
|
|
905
|
+
* stdout/stderr 都捕获再拼进错误信息:tar 和 unzip 的失败原因有时只写在 stdout 上,
|
|
906
|
+
* 只取 stderr 会得到一句「退出码为 1」,完全无法定位。
|
|
907
|
+
* windowsHide 是为了不让用户看到一闪而过的黑窗口。
|
|
908
|
+
* @param {string} command 命令。
|
|
909
|
+
* @param {string[]} args 参数。
|
|
910
|
+
* @returns {void}
|
|
911
|
+
*/
|
|
912
|
+
function runCommand(command, args) {
|
|
913
|
+
const result = spawnSync(command, args, {
|
|
914
|
+
encoding: 'utf8',
|
|
915
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
916
|
+
windowsHide: true
|
|
917
|
+
});
|
|
918
|
+
if (result.error) throw result.error;
|
|
919
|
+
if (result.status !== 0) {
|
|
920
|
+
const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
|
|
921
|
+
throw new Error(detail || `${command} 退出码为 ${result.status}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* 解压运行时归档。
|
|
927
|
+
*
|
|
928
|
+
* 用系统自带工具而不是 Node 侧解压库,仍然是「不能有第三方依赖」的延伸。
|
|
929
|
+
* Windows 上没有 unzip,只能走 PowerShell 的 Expand-Archive,且路径必须用
|
|
930
|
+
* JSON.stringify 包起来——用户目录里带空格或中文时,不加引号会被拆成多个参数。
|
|
931
|
+
* @param {string} archivePath 归档路径。
|
|
932
|
+
* @param {string} destination 解压目标目录。
|
|
933
|
+
* @param {{platform?: string}} options 平台名,测试可覆盖。
|
|
934
|
+
* @returns {void}
|
|
935
|
+
*/
|
|
936
|
+
function extractArchive(archivePath, destination, { platform = process.platform } = {}) {
|
|
937
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
938
|
+
if (archivePath.endsWith('.tar.gz')) {
|
|
939
|
+
runCommand('tar', ['-xzf', archivePath, '-C', destination]);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (archivePath.endsWith('.zip')) {
|
|
943
|
+
if (platform === 'win32') {
|
|
944
|
+
runCommand('powershell.exe', [
|
|
945
|
+
'-NoProfile',
|
|
946
|
+
'-ExecutionPolicy',
|
|
947
|
+
'Bypass',
|
|
948
|
+
'-Command',
|
|
949
|
+
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`
|
|
950
|
+
]);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
runCommand('unzip', ['-q', archivePath, '-d', destination]);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
throw new Error(`暂不支持的压缩包类型:${archivePath}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* 判断本地是否已有一份可用的同版本运行时。
|
|
961
|
+
*
|
|
962
|
+
* 不能只看 installed.json 存不存在:磁盘清理工具、杀软隔离、同步盘都可能把二进制删掉
|
|
963
|
+
* 而留下标记文件。因此每次都真的比对大小和 sha256——这一步的开销远小于「以为装好了、
|
|
964
|
+
* 结果 spawn 一个不存在的文件」带来的排查成本。
|
|
965
|
+
* 整个函数用 try/catch 包住并返回 false:读不到、解析不了都等价于「没装」,
|
|
966
|
+
* 让调用方走重装流程,而不是让启动直接失败。
|
|
967
|
+
* @param {{target: object, binDir: string, installMarkerPath: string, runtimeVersion: string}} options 校验依据。
|
|
968
|
+
* @returns {boolean} 是否已安装。
|
|
969
|
+
*/
|
|
970
|
+
function isRuntimeInstalled({ target, binDir, installMarkerPath, runtimeVersion } = {}) {
|
|
971
|
+
requireOption(binDir, 'binDir');
|
|
972
|
+
requireOption(installMarkerPath, 'installMarkerPath');
|
|
973
|
+
requireOption(runtimeVersion, 'runtimeVersion');
|
|
974
|
+
try {
|
|
975
|
+
const marker = JSON.parse(fs.readFileSync(installMarkerPath, 'utf8'));
|
|
976
|
+
return (
|
|
977
|
+
marker.version === runtimeVersion &&
|
|
978
|
+
marker.platform === target.id &&
|
|
979
|
+
target.binaries.every((name) => {
|
|
980
|
+
const expected = marker.files?.[name];
|
|
981
|
+
const filePath = path.join(binDir, name);
|
|
982
|
+
return (
|
|
983
|
+
expected &&
|
|
984
|
+
fs.existsSync(filePath) &&
|
|
985
|
+
fs.statSync(filePath).size === expected.size &&
|
|
986
|
+
sha256File(filePath) === expected.sha256
|
|
987
|
+
);
|
|
988
|
+
})
|
|
989
|
+
);
|
|
990
|
+
} catch {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* macOS 上去掉隔离属性并补上可执行位。
|
|
997
|
+
*
|
|
998
|
+
* 从网络下载的二进制带 com.apple.quarantine,直接执行会被 Gatekeeper 弹窗拦下;
|
|
999
|
+
* xattr 失败不抛错(有些文件系统不支持扩展属性),但 chmod 失败必须抛——没有可执行位
|
|
1000
|
+
* 就是真的跑不起来。每次启动都执行一遍,因为备份还原、同步盘都可能把属性带回来。
|
|
1001
|
+
* @param {{target: object, binDir: string, platform?: string}} options 目标与目录。
|
|
1002
|
+
* @returns {void}
|
|
1003
|
+
*/
|
|
1004
|
+
function prepareMacBinaries({ target, binDir, platform = process.platform } = {}) {
|
|
1005
|
+
if (platform !== 'darwin') return;
|
|
1006
|
+
requireOption(binDir, 'binDir');
|
|
1007
|
+
for (const name of target.binaries) {
|
|
1008
|
+
const filePath = path.join(binDir, name);
|
|
1009
|
+
spawnSync('xattr', ['-dr', 'com.apple.quarantine', filePath], { stdio: 'ignore' });
|
|
1010
|
+
runCommand('chmod', ['+x', filePath]);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* 清理旧版本运行时目录,按最近使用时间保留最新的若干份。
|
|
1016
|
+
*
|
|
1017
|
+
* 尽力而为:删不掉就算了,绝不因为清理失败影响本次运行。
|
|
1018
|
+
* @param {{versionsRoot: string, active: string, keepCount?: number}} options 版本目录根、当前版本目录名、保留份数。
|
|
1019
|
+
* @returns {string[]} 实际删掉的版本目录名。
|
|
1020
|
+
*/
|
|
1021
|
+
function pruneOldRuntimeVersions({ versionsRoot, active, keepCount = RUNTIME_VERSIONS_TO_KEEP } = {}) {
|
|
1022
|
+
requireOption(versionsRoot, 'versionsRoot');
|
|
1023
|
+
requireOption(active, 'active');
|
|
1024
|
+
let entries;
|
|
1025
|
+
try {
|
|
1026
|
+
entries = fs.readdirSync(versionsRoot, { withFileTypes: true });
|
|
1027
|
+
} catch {
|
|
1028
|
+
return [];
|
|
1029
|
+
}
|
|
1030
|
+
// 先把当前版本的时间戳刷新成现在:保留依据是「最近用过」而不是「最近装的」,
|
|
1031
|
+
// 否则交替使用两个版本时,先装的那个会被误判成最旧的。
|
|
1032
|
+
try {
|
|
1033
|
+
const now = new Date();
|
|
1034
|
+
fs.utimesSync(path.join(versionsRoot, active), now, now);
|
|
1035
|
+
} catch {
|
|
1036
|
+
// 目录还没建好(首次安装前)就跳过,不影响后面的判断。
|
|
1037
|
+
}
|
|
1038
|
+
const ranked = entries
|
|
1039
|
+
.filter((entry) => entry.isDirectory())
|
|
1040
|
+
.map((entry) => entry.name)
|
|
1041
|
+
// 排除 v<版本>.staging-<pid>:那可能是另一个进程正在写的半成品。
|
|
1042
|
+
.filter((name) => /^v\d/.test(name) && !name.includes('.staging-'))
|
|
1043
|
+
.map((name) => {
|
|
1044
|
+
let usedAt = 0;
|
|
1045
|
+
try {
|
|
1046
|
+
usedAt = fs.statSync(path.join(versionsRoot, name)).mtimeMs;
|
|
1047
|
+
} catch {
|
|
1048
|
+
usedAt = 0;
|
|
1049
|
+
}
|
|
1050
|
+
return { name, usedAt };
|
|
1051
|
+
})
|
|
1052
|
+
.sort((a, b) => b.usedAt - a.usedAt);
|
|
1053
|
+
|
|
1054
|
+
const keep = new Set(ranked.slice(0, keepCount).map((item) => item.name));
|
|
1055
|
+
keep.add(active);
|
|
1056
|
+
|
|
1057
|
+
const removed = [];
|
|
1058
|
+
for (const { name } of ranked) {
|
|
1059
|
+
if (keep.has(name)) continue;
|
|
1060
|
+
try {
|
|
1061
|
+
fs.rmSync(path.join(versionsRoot, name), { recursive: true, force: true });
|
|
1062
|
+
removed.push(name);
|
|
1063
|
+
} catch {
|
|
1064
|
+
// 正被占用或权限不足都不是本次运行该处理的问题,留到下次。
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return removed;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* 每次启动都清理一次旧版本,并把删掉的版本写到 stderr。
|
|
1072
|
+
*
|
|
1073
|
+
* 放在「已安装」的快路径上也要跑:只在新装时清理的话,一台停止升级的机器会把
|
|
1074
|
+
* 历史版本永远留着。
|
|
1075
|
+
*
|
|
1076
|
+
* **logPrefix 必须显式传入,这一行就是那次事故的现场。** 当初这句日志的前缀写成了
|
|
1077
|
+
* 模块级常量 SKILL_ID,而三份启动器里只有一份定义了它,另外两份发布后一旦真的删掉了
|
|
1078
|
+
* 旧版本就抛 ReferenceError,整个启动中止。更阴的是这条路径只在「确实删了东西」时才
|
|
1079
|
+
* 走到,测试和联调机上都是空目录,压根碰不到。所以这里宁可在入口硬性要求参数。
|
|
1080
|
+
* @param {{versionsRoot: string, active: string, keepCount?: number, logPrefix: string, log?: (line: string) => void}} options 清理与日志选项。
|
|
1081
|
+
* @returns {string[]} 实际删掉的版本目录名。
|
|
1082
|
+
*/
|
|
1083
|
+
function reportPrunedRuntimeVersions({ versionsRoot, active, keepCount, logPrefix, log = console.error } = {}) {
|
|
1084
|
+
requireOption(logPrefix, 'logPrefix');
|
|
1085
|
+
const removed = pruneOldRuntimeVersions({ versionsRoot, active, keepCount });
|
|
1086
|
+
if (removed.length > 0) {
|
|
1087
|
+
log(`[${logPrefix}] 已清理旧版本运行时:${removed.join('、')}`);
|
|
1088
|
+
}
|
|
1089
|
+
return removed;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* 把校验完毕的 staging 目录提交为当前版本运行时。
|
|
1094
|
+
*
|
|
1095
|
+
* 同一 Skill 的两个实例可能同时首次安装同一版本,各自下载到自己的 staging。
|
|
1096
|
+
* 版本目录按版本号钉死,内容又逐个核对过大小与 sha256,谁装出来的字节都一样,
|
|
1097
|
+
* 因此没有必要抢:对方已经装好就直接用他那份。
|
|
1098
|
+
*
|
|
1099
|
+
* 抢的代价是真的。原来的写法是无条件 `rmSync` 再 `renameSync`,会删掉对方刚
|
|
1100
|
+
* 装好、正准备执行的目录:POSIX 上对方若已 spawn 还能活下来,但两步之间那一瞬
|
|
1101
|
+
* 版本目录是不存在的;Windows 上更直接——删正在运行的 exe 会抛错,把这次启动
|
|
1102
|
+
* 整个带崩。
|
|
1103
|
+
*
|
|
1104
|
+
* @param {string} stagingRoot 已校验完毕、等待就位的临时目录。
|
|
1105
|
+
* @param {string} versionRoot 版本目录。
|
|
1106
|
+
* @param {() => boolean} isVersionReady 版本目录里是否已经是一份可用的同版本运行时。
|
|
1107
|
+
* @returns {'installed' | 'kept_existing'} 就位的是本进程这份还是别人那份。
|
|
1108
|
+
*/
|
|
1109
|
+
function commitRuntimeInstall(stagingRoot, versionRoot, isVersionReady) {
|
|
1110
|
+
if (isVersionReady()) return 'kept_existing';
|
|
1111
|
+
fs.rmSync(versionRoot, { recursive: true, force: true });
|
|
1112
|
+
try {
|
|
1113
|
+
fs.renameSync(stagingRoot, versionRoot);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
// 对方恰好在这两步之间把同版本装好了。抢输不该让本次启动失败。
|
|
1116
|
+
if (!isVersionReady()) throw error;
|
|
1117
|
+
return 'kept_existing';
|
|
1118
|
+
}
|
|
1119
|
+
return 'installed';
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* 确保本地有一份可执行的当前版本运行时,没有就下载安装。
|
|
1124
|
+
*
|
|
1125
|
+
* 全程走「临时目录 → staging → rename 就位」,绝不直接往版本目录里写:版本目录随时
|
|
1126
|
+
* 可能正被另一个进程执行,半成品出现在那里等于让别人跑一个残缺的二进制。
|
|
1127
|
+
* staging 目录名带 pid,是为了让并发安装的两个进程互不覆盖。
|
|
1128
|
+
* finally 里无条件清掉 staging 和临时目录——失败时留下的半成品会在下次被 prune 跳过
|
|
1129
|
+
* (名字里有 .staging-),从此永远占着磁盘。
|
|
1130
|
+
* @param {object} options 安装参数,见下方各字段说明。
|
|
1131
|
+
* @returns {Promise<'already_installed' | 'installed' | 'kept_existing'>} 本次的处理结果。
|
|
1132
|
+
*/
|
|
1133
|
+
async function ensureRuntimeInstalled({
|
|
1134
|
+
target,
|
|
1135
|
+
runtimeVersion,
|
|
1136
|
+
versionRoot,
|
|
1137
|
+
binDir,
|
|
1138
|
+
tempDirPrefix,
|
|
1139
|
+
logPrefix,
|
|
1140
|
+
downloadRelease,
|
|
1141
|
+
keepCount,
|
|
1142
|
+
platform = process.platform,
|
|
1143
|
+
log = console.error,
|
|
1144
|
+
pid = process.pid,
|
|
1145
|
+
tempDirRoot = os.tmpdir(),
|
|
1146
|
+
installMarkerPath = versionRoot ? path.join(versionRoot, 'installed.json') : null,
|
|
1147
|
+
extract = extractArchive,
|
|
1148
|
+
verifyExtracted = null,
|
|
1149
|
+
isInstalled = null
|
|
1150
|
+
} = {}) {
|
|
1151
|
+
requireOption(target, 'target');
|
|
1152
|
+
requireOption(runtimeVersion, 'runtimeVersion');
|
|
1153
|
+
requireOption(versionRoot, 'versionRoot');
|
|
1154
|
+
requireOption(binDir, 'binDir');
|
|
1155
|
+
requireOption(tempDirPrefix, 'tempDirPrefix');
|
|
1156
|
+
requireOption(logPrefix, 'logPrefix');
|
|
1157
|
+
if (typeof downloadRelease !== 'function') {
|
|
1158
|
+
throw new Error('launcher-core 缺少必填参数 downloadRelease:下载源与清单 schema 属于各仓身份,必须由调用方绑定后注入。');
|
|
1159
|
+
}
|
|
1160
|
+
const checkInstalled = isInstalled
|
|
1161
|
+
|| (() => isRuntimeInstalled({ target, binDir, installMarkerPath, runtimeVersion }));
|
|
1162
|
+
const checkExtracted = verifyExtracted
|
|
1163
|
+
|| ((extractedDir) => verifyExtractedRuntime(extractedDir, target, { runtimeVersion }));
|
|
1164
|
+
const prune = () => reportPrunedRuntimeVersions({
|
|
1165
|
+
versionsRoot: path.dirname(versionRoot),
|
|
1166
|
+
active: path.basename(versionRoot),
|
|
1167
|
+
keepCount,
|
|
1168
|
+
logPrefix,
|
|
1169
|
+
log
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
if (checkInstalled()) {
|
|
1173
|
+
prepareMacBinaries({ target, binDir, platform });
|
|
1174
|
+
prune();
|
|
1175
|
+
return 'already_installed';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const tempRoot = fs.mkdtempSync(path.join(tempDirRoot, tempDirPrefix));
|
|
1179
|
+
const archivePath = path.join(tempRoot, target.archive);
|
|
1180
|
+
const releaseManifestPath = path.join(tempRoot, 'runtime-manifest.json');
|
|
1181
|
+
const extractedDir = path.join(tempRoot, 'extracted');
|
|
1182
|
+
const stagingRoot = `${versionRoot}.staging-${pid}`;
|
|
1183
|
+
try {
|
|
1184
|
+
await downloadRelease(target, releaseManifestPath, archivePath);
|
|
1185
|
+
extract(archivePath, extractedDir, { platform });
|
|
1186
|
+
const files = checkExtracted(extractedDir, target);
|
|
1187
|
+
|
|
1188
|
+
fs.mkdirSync(path.dirname(versionRoot), { recursive: true });
|
|
1189
|
+
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
|
1190
|
+
fs.mkdirSync(path.join(stagingRoot, 'bin'), { recursive: true });
|
|
1191
|
+
for (const name of target.binaries) {
|
|
1192
|
+
fs.copyFileSync(path.join(extractedDir, name), path.join(stagingRoot, 'bin', name));
|
|
1193
|
+
}
|
|
1194
|
+
fs.writeFileSync(
|
|
1195
|
+
path.join(stagingRoot, 'installed.json'),
|
|
1196
|
+
`${JSON.stringify({ version: runtimeVersion, platform: target.id, files }, null, 2)}\n`
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
const outcome = commitRuntimeInstall(stagingRoot, versionRoot, checkInstalled);
|
|
1200
|
+
prepareMacBinaries({ target, binDir, platform });
|
|
1201
|
+
prune();
|
|
1202
|
+
return outcome;
|
|
1203
|
+
} finally {
|
|
1204
|
+
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
|
1205
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/* ------------------------------------------------------------------ *
|
|
1210
|
+
* 九、共享译心桥
|
|
1211
|
+
* ------------------------------------------------------------------ */
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* 校验当前共享桥是否具备本 Skill 的硬依赖能力。
|
|
1215
|
+
*
|
|
1216
|
+
* 只校验自己声明的能力,避免无关运行时能力成为全局硬依赖:从别的仓照抄一份能力清单
|
|
1217
|
+
* (比如用不到的 frame_url_v1),会在插件升级改名或合并能力时变成假硬依赖,
|
|
1218
|
+
* 把本来能跑的 Skill 直接挡在门外。空清单必须真的放行,而不是被当成「未配置」而报错。
|
|
1219
|
+
* @param {object} result 共享桥管理器返回的运行时信息。
|
|
1220
|
+
* @param {string[]} requiredCapabilities 本 Skill 声明的能力白名单。
|
|
1221
|
+
* @returns {object} 校验通过后的原始运行时信息。
|
|
1222
|
+
*/
|
|
1223
|
+
function validateBridgeCapabilities(result, requiredCapabilities = []) {
|
|
1224
|
+
const missing = requiredCapabilities.filter(
|
|
1225
|
+
(capability) => !result.capabilities?.includes(capability)
|
|
1226
|
+
);
|
|
1227
|
+
if (missing.length > 0) {
|
|
1228
|
+
throw new Error(`共享译心桥缺少必要能力:${missing.join(', ')}`);
|
|
1229
|
+
}
|
|
1230
|
+
return result;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* 确保共享译心桥已安装,并返回它的运行路径。
|
|
1235
|
+
*
|
|
1236
|
+
* 桥的 import 由调用方注入,这样共享内核里不会出现任何第三方包名的静态依赖——
|
|
1237
|
+
* 它可以被复制到任何一个仓、任何一个测试里而不需要先 npm install。
|
|
1238
|
+
* 「返回了路径」和「路径真的存在」是两件事,必须都查:管理器在部分失败时仍可能返回
|
|
1239
|
+
* 一个指向已被清理目录的路径,那样后面 spawn 会得到一句难以理解的 ENOENT。
|
|
1240
|
+
* @param {{importBridge: () => Promise<object>, minVersion: string, requiredCapabilities?: string[], packageName?: string}} options 注入项。
|
|
1241
|
+
* @returns {Promise<object>} 共享桥运行时信息。
|
|
1242
|
+
*/
|
|
1243
|
+
async function ensureSharedBridge({
|
|
1244
|
+
importBridge,
|
|
1245
|
+
minVersion,
|
|
1246
|
+
requiredCapabilities = [],
|
|
1247
|
+
packageName = '@yixinkj/yixin-bridge-cli'
|
|
1248
|
+
} = {}) {
|
|
1249
|
+
if (typeof importBridge !== 'function') {
|
|
1250
|
+
throw new Error('launcher-core 缺少必填参数 importBridge:共享内核不静态依赖任何第三方包,桥的加载方式必须注入。');
|
|
1251
|
+
}
|
|
1252
|
+
requireOption(minVersion, 'minVersion');
|
|
1253
|
+
let manager;
|
|
1254
|
+
try {
|
|
1255
|
+
manager = await importBridge();
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
throw new Error(`缺少共享译心桥管理器 ${packageName}:${error.message}`);
|
|
1258
|
+
}
|
|
1259
|
+
const result = await manager.ensureBridgeRuntime({ minVersion });
|
|
1260
|
+
if (!result?.bridge_path || !fs.existsSync(result.bridge_path)) {
|
|
1261
|
+
throw new Error('共享译心桥安装完成后未返回有效运行路径');
|
|
1262
|
+
}
|
|
1263
|
+
validateBridgeCapabilities(result, requiredCapabilities);
|
|
1264
|
+
return result;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* 把共享管理器的底层插件状态转换为用户可理解的有限提示。
|
|
1269
|
+
*
|
|
1270
|
+
* 无更新时返回空值,避免每次分析都输出无行动价值的运行时细节——每次都刷一行
|
|
1271
|
+
* 「插件版本 x.y.z」只会让真正需要动手的那一次被淹没。
|
|
1272
|
+
*
|
|
1273
|
+
* `manualReloadMessage` 可覆盖:priority-buyer-alert 把这句改成了「先删除旧插件、
|
|
1274
|
+
* 再按下方路径导入新版」的完整步骤,因为只说「请手动重新加载」时,用户在扩展管理页
|
|
1275
|
+
* 点了刷新却仍然是旧版(旧目录还在),反复来回。默认用这版更完整的说法。
|
|
1276
|
+
* @param {object | null} sharedBridge 共享桥管理器返回的安装和插件状态。
|
|
1277
|
+
* @param {{manualReloadMessage?: (version: string) => string}} options 文案覆盖。
|
|
1278
|
+
* @returns {object | null} 需要展示的提示结构;无需提示时返回空值。
|
|
1279
|
+
*/
|
|
1280
|
+
function browserExtensionNotice(sharedBridge, { manualReloadMessage = null } = {}) {
|
|
1281
|
+
if (!sharedBridge) return null;
|
|
1282
|
+
const reloadStatus = sharedBridge.extension_reload?.status || 'not_requested';
|
|
1283
|
+
const details = {
|
|
1284
|
+
extension_version: sharedBridge.extension_version || null,
|
|
1285
|
+
extension_path: sharedBridge.extension_path || null,
|
|
1286
|
+
reload_status: reloadStatus
|
|
1287
|
+
};
|
|
1288
|
+
const version = details.extension_version || '最新版';
|
|
1289
|
+
if (sharedBridge.extension_updated) {
|
|
1290
|
+
if (reloadStatus === 'reloaded') {
|
|
1291
|
+
return {
|
|
1292
|
+
...details,
|
|
1293
|
+
status: 'updated_reloaded',
|
|
1294
|
+
message: `浏览器插件已更新至 ${version},并已自动重新加载。`
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
return {
|
|
1298
|
+
...details,
|
|
1299
|
+
status: 'manual_reload_required',
|
|
1300
|
+
message: manualReloadMessage
|
|
1301
|
+
? manualReloadMessage(version)
|
|
1302
|
+
: `浏览器插件已更新至 ${version},但未能自动加载。请先删除 Chrome 扩展管理页中的旧译心插件,再按下方“浏览器插件本地路径”导入新版并重新加载。`
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
if (reloadStatus === 'active_tasks_present') {
|
|
1306
|
+
return {
|
|
1307
|
+
...details,
|
|
1308
|
+
status: 'update_deferred',
|
|
1309
|
+
message: '检测到浏览器插件更新,但当前仍有采集任务,已暂缓更新。任务结束后请重新运行。'
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
if (reloadStatus === 'daemon_status_unavailable') {
|
|
1313
|
+
return {
|
|
1314
|
+
...details,
|
|
1315
|
+
status: 'update_deferred',
|
|
1316
|
+
message: '检测到浏览器插件更新,但暂时无法确认浏览器任务状态,未自动替换。请稍后重新运行。'
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
return null;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* 仅把需要用户知晓的插件动作写入 stderr。
|
|
1324
|
+
*
|
|
1325
|
+
* 原生 CLI 的 stdout 是机器解析的 JSON,因此提示只能写 stderr——混进 stdout 会让
|
|
1326
|
+
* 调用方的 JSON.parse 直接失败,表现成「Skill 坏了」而不是「插件需要重载」。
|
|
1327
|
+
* logPrefix 同样必须显式传入,理由与 reportPrunedRuntimeVersions 完全一致。
|
|
1328
|
+
* @param {object | null} sharedBridge 共享桥管理器返回的安装和插件状态。
|
|
1329
|
+
* @param {{logPrefix: string, log?: (line: string) => void, noticeOptions?: object}} options 日志选项。
|
|
1330
|
+
* @returns {object | null} 实际输出的提示结构。
|
|
1331
|
+
*/
|
|
1332
|
+
function emitBrowserExtensionNotice(sharedBridge, { logPrefix, log = console.error, noticeOptions = {} } = {}) {
|
|
1333
|
+
requireOption(logPrefix, 'logPrefix');
|
|
1334
|
+
const notice = browserExtensionNotice(sharedBridge, noticeOptions);
|
|
1335
|
+
if (!notice) return null;
|
|
1336
|
+
log(`[${logPrefix}] ${notice.message}`);
|
|
1337
|
+
if (notice.extension_path) {
|
|
1338
|
+
log(`[${logPrefix}] 浏览器插件本地路径:${notice.extension_path}`);
|
|
1339
|
+
}
|
|
1340
|
+
return notice;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/* ------------------------------------------------------------------ *
|
|
1344
|
+
* 十、输出与进程边界
|
|
1345
|
+
* ------------------------------------------------------------------ */
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* 造一个绑定了当前 Skill 版本的状态输出函数。
|
|
1349
|
+
*
|
|
1350
|
+
* 做成工厂而不是普通函数,是因为 skill_version 必须出现在**每一条**状态里:
|
|
1351
|
+
* 调用方拿到一条状态时要能立刻判断「这是哪一版说的话」,靠每个调用点自己记得传是不现实的。
|
|
1352
|
+
* details 放在最后展开,允许个别状态覆盖同名字段。
|
|
1353
|
+
* @param {{skillVersion: string, write?: (line: string) => void}} options 版本与输出通道。
|
|
1354
|
+
* @returns {(status: string, message: string, details?: object) => void} 状态输出函数。
|
|
1355
|
+
*/
|
|
1356
|
+
function createStatusEmitter({ skillVersion, write = (line) => console.log(line) } = {}) {
|
|
1357
|
+
requireOption(skillVersion, 'skillVersion');
|
|
1358
|
+
return function emitStatus(status, message, details = {}) {
|
|
1359
|
+
write(JSON.stringify({ status, message, skill_version: skillVersion, ...details }, null, 2));
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* 造一个统一的致命错误处理函数。
|
|
1365
|
+
*
|
|
1366
|
+
* 前缀由调用方传入(同一类事故的第三处现场)。错误一律写 stderr 并以非零码退出:
|
|
1367
|
+
* 启动器失败时 stdout 必须保持干净,否则调用方会把半截错误文本当成结果去解析。
|
|
1368
|
+
* @param {{logPrefix: string, log?: (line: string) => void, exit?: (code: number) => void}} options 日志与退出通道。
|
|
1369
|
+
* @returns {(message: string) => void} 失败处理函数。
|
|
1370
|
+
*/
|
|
1371
|
+
function createFailHandler({ logPrefix, log = console.error, exit = (code) => process.exit(code) } = {}) {
|
|
1372
|
+
requireOption(logPrefix, 'logPrefix');
|
|
1373
|
+
return function fail(message) {
|
|
1374
|
+
log(`[${logPrefix}] ${message}`);
|
|
1375
|
+
exit(1);
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* 判断当前模块是不是被直接执行(而不是被测试 import)。
|
|
1381
|
+
*
|
|
1382
|
+
* **moduleUrl 必须由调用方传入自己的 import.meta.url。** 共享内核如果用自己的
|
|
1383
|
+
* import.meta.url 来比,结果永远是 false,三个启动器会全部变成「导入即返回、什么都不做」——
|
|
1384
|
+
* 这正是「隐式依赖模块级常量」这类事故的同族,而且它不会报错,只会静默失效。
|
|
1385
|
+
*
|
|
1386
|
+
* 先走 realpath 再比较,是因为 npm 会把 bin 装成 node_modules/.bin 下的符号链接,
|
|
1387
|
+
* argv[1] 是链接路径而模块路径是真实路径,不解析就永远判不相等。
|
|
1388
|
+
* @param {string} moduleUrl 调用方的 import.meta.url。
|
|
1389
|
+
* @param {{argv?: string[], platform?: string}} options 进程信息,测试可覆盖。
|
|
1390
|
+
* @returns {boolean} 是否为直接执行。
|
|
1391
|
+
*/
|
|
1392
|
+
function isDirectExecution(moduleUrl, { argv = process.argv, platform = process.platform } = {}) {
|
|
1393
|
+
requireOption(moduleUrl, 'moduleUrl');
|
|
1394
|
+
if (!argv[1]) return false;
|
|
1395
|
+
let invoked = path.resolve(argv[1]);
|
|
1396
|
+
let current = moduleUrl.startsWith('file:') ? fileURLToPath(moduleUrl) : path.resolve(moduleUrl);
|
|
1397
|
+
try {
|
|
1398
|
+
invoked = fs.realpathSync(invoked);
|
|
1399
|
+
current = fs.realpathSync(current);
|
|
1400
|
+
} catch {
|
|
1401
|
+
// 某些文件系统无法解析符号链接时使用绝对路径比较。
|
|
1402
|
+
}
|
|
1403
|
+
return platform === 'win32'
|
|
1404
|
+
? invoked.toLowerCase() === current.toLowerCase()
|
|
1405
|
+
: invoked === current;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
export {
|
|
1409
|
+
RUNTIME_VERSIONS_TO_KEEP,
|
|
1410
|
+
assertRegistryEntryWritten,
|
|
1411
|
+
browserExtensionNotice,
|
|
1412
|
+
commitRuntimeInstall,
|
|
1413
|
+
compareVersions,
|
|
1414
|
+
createFailHandler,
|
|
1415
|
+
createStatusEmitter,
|
|
1416
|
+
detectTarget,
|
|
1417
|
+
discoverLegacySkillPaths,
|
|
1418
|
+
download,
|
|
1419
|
+
downloadVerifiedRelease,
|
|
1420
|
+
emitBrowserExtensionNotice,
|
|
1421
|
+
ensureRuntimeInstalled,
|
|
1422
|
+
ensureSharedBridge,
|
|
1423
|
+
extractArchive,
|
|
1424
|
+
extractSkillPath,
|
|
1425
|
+
findRegistryEntry,
|
|
1426
|
+
hasOption,
|
|
1427
|
+
isDirectExecution,
|
|
1428
|
+
isHelpRequest,
|
|
1429
|
+
isRuntimeInstalled,
|
|
1430
|
+
loadBundledSkill,
|
|
1431
|
+
normalizePath,
|
|
1432
|
+
parseSkillMetadata,
|
|
1433
|
+
prepareMacBinaries,
|
|
1434
|
+
pruneOldRuntimeVersions,
|
|
1435
|
+
readInstalledSkillVersion,
|
|
1436
|
+
readSkillsRegistry,
|
|
1437
|
+
registryUpdatePreview,
|
|
1438
|
+
releaseUrls,
|
|
1439
|
+
reportPrunedRuntimeVersions,
|
|
1440
|
+
resolveReleaseBaseUrls,
|
|
1441
|
+
resolveSkillGate,
|
|
1442
|
+
resolveSkillMdPath,
|
|
1443
|
+
restoreFile,
|
|
1444
|
+
restoreTree,
|
|
1445
|
+
runCommand,
|
|
1446
|
+
runWithRollback,
|
|
1447
|
+
runtimeManifestUrls,
|
|
1448
|
+
sha256File,
|
|
1449
|
+
snapshotFile,
|
|
1450
|
+
snapshotTree,
|
|
1451
|
+
stripJsonComments,
|
|
1452
|
+
treeDigest,
|
|
1453
|
+
validateBridgeCapabilities,
|
|
1454
|
+
verifyExtractedRuntime,
|
|
1455
|
+
verifyReleaseArchive,
|
|
1456
|
+
versionMarkerContent
|
|
1457
|
+
};
|