@yottameta/yotta-skills-plugin 0.23.3 → 0.24.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.
@@ -0,0 +1,448 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/registry-fetch.js —— 无 npm 内置拉包通道(元阁 0.24.0,方案 A.1)。
4
+ *
5
+ * 零依赖:Node 内置 https/http 直连 registry,abbreviated packument 解析版本,
6
+ * tarball 下载 + integrity(sha512) / shasum(sha1) fail-closed 校验;
7
+ * 支持 HTTPS_PROXY / HTTP_PROXY(CONNECT 隧道简实现)与 NO_PROXY。
8
+ *
9
+ * 同步入口 fetchPackageSync 供安装管线(同步契约)使用:通过子进程执行同一套
10
+ * 异步核心(lib/registry-fetch-child.js),stdin/stdout 传 JSON,不落任何状态。
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const http = require('http');
16
+ const https = require('https');
17
+ const tls = require('tls');
18
+ const crypto = require('crypto');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
22
+ const DEFAULT_TIMEOUT = 15000;
23
+ const DEFAULT_DOWNLOAD_TIMEOUT = 120000;
24
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
25
+ const INTEGRITY_RANK = { sha512: 1, sha384: 2, sha256: 3, sha1: 4 };
26
+
27
+ function registryBase(opts) {
28
+ const raw = (opts && opts.registry) || process.env.YOTTA_SKILLS_REGISTRY || OFFICIAL_REGISTRY;
29
+ return String(raw).replace(/\/+$/, '') + '/';
30
+ }
31
+
32
+ function packumentUrl(pkg, opts) {
33
+ return registryBase(opts) + String(pkg).replace(/\//g, '%2f');
34
+ }
35
+
36
+ function tarballName(pkg, version) {
37
+ const parts = String(pkg).replace(/^@/, '').split('/');
38
+ const scope = parts.length > 1 ? parts[0] : '';
39
+ const bare = parts.length > 1 ? parts[1] : parts[0];
40
+ return (scope ? scope + '-' : '') + bare + '-' + version + '.tgz';
41
+ }
42
+
43
+ function majorRange(version) {
44
+ return String(version).split('.')[0] + '.x';
45
+ }
46
+
47
+ function compareSemver(a, b) {
48
+ const pa = String(a).split('.').map(Number);
49
+ const pb = String(b).split('.').map(Number);
50
+ for (let i = 0; i < 3; i += 1) {
51
+ const x = pa[i] || 0;
52
+ const y = pb[i] || 0;
53
+ if (x !== y) return x - y;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ function parseIntegrity(value) {
59
+ const entries = String(value || '')
60
+ .split(/\s+/)
61
+ .map((token) => {
62
+ const match = token.match(/^(sha512|sha384|sha256|sha1)-(.+)$/);
63
+ return match ? { algo: match[1], base64: match[2] } : null;
64
+ })
65
+ .filter(Boolean);
66
+ if (!entries.length) return null;
67
+ entries.sort((a, b) => INTEGRITY_RANK[a.algo] - INTEGRITY_RANK[b.algo]);
68
+ return entries[0];
69
+ }
70
+
71
+ function noProxyMatch(hostname) {
72
+ const raw = process.env.NO_PROXY || process.env.no_proxy || '';
73
+ if (!raw) return false;
74
+ const host = String(hostname || '').toLowerCase();
75
+ return String(raw)
76
+ .split(',')
77
+ .map((entry) => entry.trim().toLowerCase())
78
+ .filter(Boolean)
79
+ .some((entry) => {
80
+ if (entry === '*') return true;
81
+ const clean = entry.replace(/^\./, '').replace(/:\d+$/, '');
82
+ return host === clean || host.endsWith('.' + clean);
83
+ });
84
+ }
85
+
86
+ function proxyFor(url) {
87
+ if (noProxyMatch(url.hostname)) return null;
88
+ const raw = url.protocol === 'https:'
89
+ ? (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy)
90
+ : (process.env.HTTP_PROXY || process.env.http_proxy);
91
+ if (!raw) return null;
92
+ try {
93
+ const parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : 'http://' + raw);
94
+ return { hostname: parsed.hostname, port: Number(parsed.port) || 80 };
95
+ } catch (_) {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function tunneledAgent(socket) {
101
+ const agent = new https.Agent({ keepAlive: false });
102
+ agent.createConnection = function createConnection(options, callback) {
103
+ if (typeof callback === 'function') callback(null, socket);
104
+ return socket;
105
+ };
106
+ return agent;
107
+ }
108
+
109
+ function requestStreamOnce(rawUrl, opts) {
110
+ return new Promise((resolve) => {
111
+ let url;
112
+ try {
113
+ url = new URL(rawUrl);
114
+ } catch (error) {
115
+ resolve({ ok: false, error: 'URL 解析失败: ' + error.message });
116
+ return;
117
+ }
118
+ const options = opts || {};
119
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT;
120
+ const headers = Object.assign(
121
+ { 'user-agent': 'yotta-skills-fetch', accept: '*/*' },
122
+ options.headers || {},
123
+ );
124
+ const proxy = proxyFor(url);
125
+ let settled = false;
126
+ const done = (value) => {
127
+ if (!settled) {
128
+ settled = true;
129
+ resolve(value);
130
+ }
131
+ };
132
+ const fail = (error) => done({ ok: false, error: '网络错误: ' + ((error && error.message) || error) });
133
+ const arm = (req) => {
134
+ req.on('error', fail);
135
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('网络超时(' + timeoutMs + 'ms)')));
136
+ };
137
+
138
+ if (url.protocol === 'https:' && proxy) {
139
+ const target = url.hostname + ':' + (url.port || 443);
140
+ const connectReq = http.request({
141
+ host: proxy.hostname,
142
+ port: proxy.port,
143
+ method: 'CONNECT',
144
+ path: target,
145
+ headers: { host: target },
146
+ });
147
+ connectReq.on('connect', (res, socket) => {
148
+ if (res.statusCode !== 200) {
149
+ socket.destroy();
150
+ done({ ok: false, error: '代理 CONNECT 失败: HTTP ' + res.statusCode });
151
+ return;
152
+ }
153
+ const tlsSocket = tls.connect({ socket, servername: url.hostname });
154
+ tlsSocket.once('secureConnect', () => {
155
+ const req = https.request({
156
+ host: url.hostname,
157
+ port: url.port || 443,
158
+ path: url.pathname + url.search,
159
+ method: 'GET',
160
+ headers,
161
+ agent: tunneledAgent(tlsSocket),
162
+ });
163
+ arm(req);
164
+ req.on('response', (res2) => done({ ok: true, res: res2 }));
165
+ req.end();
166
+ });
167
+ tlsSocket.once('error', fail);
168
+ });
169
+ connectReq.on('error', fail);
170
+ connectReq.setTimeout(timeoutMs, () => connectReq.destroy(new Error('代理连接超时')));
171
+ connectReq.end();
172
+ return;
173
+ }
174
+
175
+ if (url.protocol === 'http:' && proxy) {
176
+ const req = http.request({
177
+ host: proxy.hostname,
178
+ port: proxy.port,
179
+ method: 'GET',
180
+ path: url.href,
181
+ headers: Object.assign({ host: url.host }, headers),
182
+ });
183
+ arm(req);
184
+ req.on('response', (res) => done({ ok: true, res }));
185
+ req.end();
186
+ return;
187
+ }
188
+
189
+ const mod = url.protocol === 'https:' ? https : http;
190
+ const req = mod.request({
191
+ host: url.hostname,
192
+ port: url.port || undefined,
193
+ path: url.pathname + url.search,
194
+ method: 'GET',
195
+ headers,
196
+ });
197
+ arm(req);
198
+ req.on('response', (res) => done({ ok: true, res }));
199
+ req.end();
200
+ });
201
+ }
202
+
203
+ async function requestWithRedirects(url, opts, depth) {
204
+ const result = await requestStreamOnce(url, opts);
205
+ if (!result.ok) return result;
206
+ const res = result.res;
207
+ const location = res.headers && res.headers.location;
208
+ if (location && [301, 302, 303, 307, 308].includes(res.statusCode) && (depth || 0) < 3) {
209
+ res.resume();
210
+ let next;
211
+ try {
212
+ next = new URL(location, url).href;
213
+ } catch (_) {
214
+ return { ok: false, error: '重定向地址非法: ' + location };
215
+ }
216
+ return requestWithRedirects(next, opts, (depth || 0) + 1);
217
+ }
218
+ return result;
219
+ }
220
+
221
+ function collectBody(res, maxBytes) {
222
+ return new Promise((resolve) => {
223
+ const chunks = [];
224
+ let size = 0;
225
+ let settled = false;
226
+ const done = (value) => {
227
+ if (!settled) {
228
+ settled = true;
229
+ resolve(value);
230
+ }
231
+ };
232
+ res.on('data', (chunk) => {
233
+ size += chunk.length;
234
+ if (size > maxBytes) {
235
+ res.destroy();
236
+ done({ ok: false, error: '响应超过大小上限(' + maxBytes + ' 字节)' });
237
+ return;
238
+ }
239
+ chunks.push(chunk);
240
+ });
241
+ res.on('error', (error) => done({ ok: false, error: '读取响应失败: ' + error.message }));
242
+ res.on('end', () => done({ ok: true, body: Buffer.concat(chunks), size }));
243
+ });
244
+ }
245
+
246
+ async function fetchPackument(pkg, opts) {
247
+ const options = opts || {};
248
+ const url = packumentUrl(pkg, options);
249
+ let lastError = null;
250
+ for (let attempt = 0; attempt < 2; attempt += 1) {
251
+ const result = await requestWithRedirects(url, {
252
+ timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT,
253
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
254
+ }, 0);
255
+ if (!result.ok) {
256
+ lastError = result.error;
257
+ continue;
258
+ }
259
+ const body = await collectBody(result.res, 8 * 1024 * 1024);
260
+ if (!body.ok) {
261
+ lastError = body.error;
262
+ continue;
263
+ }
264
+ if (result.res.statusCode !== 200) {
265
+ lastError = 'registry 返回 HTTP ' + result.res.statusCode;
266
+ continue;
267
+ }
268
+ try {
269
+ return { ok: true, packument: JSON.parse(body.body.toString('utf8')), url };
270
+ } catch (error) {
271
+ lastError = '解析 registry 响应失败: ' + error.message;
272
+ }
273
+ }
274
+ return { ok: false, error: lastError || 'packument 获取失败' };
275
+ }
276
+
277
+ /** pin=true 取精确版本;pin=false 过滤同 major 取最高正式版本。 */
278
+ function resolveVersion(packument, skill, pin) {
279
+ const versions = (packument && packument.versions) || {};
280
+ if (pin) {
281
+ const exact = versions[String(skill.version)];
282
+ if (!exact) {
283
+ const latest = (packument && packument['dist-tags'] && packument['dist-tags'].latest) || null;
284
+ return { error: 'registry 无 ' + skill.pkg + '@' + skill.version + (latest ? '(latest=' + latest + ')' : '') };
285
+ }
286
+ return { version: String(skill.version), dist: exact.dist || {} };
287
+ }
288
+ const major = String(skill.version).split('.')[0];
289
+ const matches = Object.keys(versions).filter((v) => (
290
+ /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v) && v.split('.')[0] === major
291
+ ));
292
+ if (!matches.length) return { error: 'registry 无 ' + major + '.x 版本(' + skill.pkg + ')' };
293
+ matches.sort(compareSemver);
294
+ const best = matches[matches.length - 1];
295
+ return { version: best, dist: versions[best].dist || {} };
296
+ }
297
+
298
+ function streamToFile(res, destFile, options) {
299
+ return new Promise((resolve) => {
300
+ const hasher = crypto.createHash(options.algo);
301
+ const out = fs.createWriteStream(destFile);
302
+ let size = 0;
303
+ let settled = false;
304
+ const done = (value) => {
305
+ if (!settled) {
306
+ settled = true;
307
+ resolve(value);
308
+ }
309
+ };
310
+ const fail = (message) => {
311
+ if (settled) return;
312
+ res.destroy();
313
+ out.destroy();
314
+ done({ ok: false, error: message });
315
+ };
316
+ res.on('data', (chunk) => {
317
+ size += chunk.length;
318
+ if (size > options.maxBytes) {
319
+ fail('下载超过大小上限(' + options.maxBytes + ' 字节)');
320
+ return;
321
+ }
322
+ hasher.update(chunk);
323
+ out.write(chunk);
324
+ });
325
+ res.on('aborted', () => fail('下载中断'));
326
+ res.on('error', (error) => fail('下载失败: ' + error.message));
327
+ out.on('error', (error) => fail('写入临时文件失败: ' + error.message));
328
+ res.on('end', () => {
329
+ if (settled) return;
330
+ out.end(() => {
331
+ if (settled) return;
332
+ done({ ok: true, size, digest: hasher.digest() });
333
+ });
334
+ });
335
+ });
336
+ }
337
+
338
+ async function downloadTarball(url, destFile, dist, opts) {
339
+ const options = opts || {};
340
+ const integrity = parseIntegrity(dist && dist.integrity);
341
+ const shasum = dist && dist.shasum ? String(dist.shasum).trim().toLowerCase() : null;
342
+ if (!integrity && !shasum) {
343
+ return { ok: false, error: 'registry 缺少 integrity / shasum(fail-closed 拒绝)' };
344
+ }
345
+ const result = await requestWithRedirects(url, {
346
+ timeoutMs: options.downloadTimeoutMs || DEFAULT_DOWNLOAD_TIMEOUT,
347
+ }, 0);
348
+ if (!result.ok) return { ok: false, error: result.error };
349
+ const res = result.res;
350
+ if (res.statusCode !== 200) {
351
+ res.resume();
352
+ return { ok: false, error: '下载失败:HTTP ' + res.statusCode };
353
+ }
354
+ const algo = integrity ? integrity.algo : 'sha1';
355
+ const saved = await streamToFile(res, destFile, {
356
+ maxBytes: options.maxBytes || DEFAULT_MAX_BYTES,
357
+ algo,
358
+ });
359
+ if (!saved.ok) {
360
+ fs.rmSync(destFile, { force: true });
361
+ return { ok: false, error: saved.error };
362
+ }
363
+ const expected = integrity ? integrity.base64 : shasum;
364
+ const got = integrity ? saved.digest.toString('base64') : saved.digest.toString('hex');
365
+ if (got !== expected) {
366
+ fs.rmSync(destFile, { force: true });
367
+ return { ok: false, error: 'integrity 校验失败(' + algo + '),已删除下载文件(fail-closed)' };
368
+ }
369
+ return { ok: true, size: saved.size, algo };
370
+ }
371
+
372
+ /** 异步核心:拉 packument → 解析版本 → 下载校验 → 落 packDir;返回 { ok, ... }。 */
373
+ async function fetchPackage(skill, opts, packDir) {
374
+ const options = opts || {};
375
+ const pin = options.pin !== false;
376
+ const spec = skill.pkg + '@' + (pin ? skill.version : majorRange(skill.version));
377
+ const packument = await fetchPackument(skill.pkg, options);
378
+ if (!packument.ok) return { ok: false, error: 'packument 获取失败: ' + packument.error };
379
+ const resolved = resolveVersion(packument.packument, skill, pin);
380
+ if (resolved.error) return { ok: false, error: '版本解析失败: ' + resolved.error };
381
+ const dist = resolved.dist || {};
382
+ if (!dist.tarball) return { ok: false, error: 'registry 版本缺少 dist.tarball(' + spec + ')' };
383
+ fs.mkdirSync(packDir, { recursive: true });
384
+ const destFile = path.join(packDir, tarballName(skill.pkg, resolved.version));
385
+ const downloaded = await downloadTarball(dist.tarball, destFile, dist, options);
386
+ if (!downloaded.ok) return { ok: false, error: '下载失败: ' + downloaded.error };
387
+ return {
388
+ ok: true,
389
+ tarball: destFile,
390
+ resolved: resolved.version,
391
+ spec,
392
+ registry: registryBase(options),
393
+ integrity: dist.integrity || null,
394
+ shasum: dist.shasum || null,
395
+ size: downloaded.size,
396
+ };
397
+ }
398
+
399
+ /** 同步入口:子进程执行同一套异步核心(安装管线为同步契约)。 */
400
+ function fetchPackageSync(skill, opts, packDir) {
401
+ const child = path.join(__dirname, 'registry-fetch-child.js');
402
+ const payload = JSON.stringify({
403
+ skill: { slug: skill.slug, pkg: skill.pkg, version: skill.version },
404
+ opts: { pin: opts.pin, registry: opts.registry || null },
405
+ packDir,
406
+ });
407
+ const result = spawnSync(process.execPath, [child], {
408
+ input: payload,
409
+ encoding: 'utf8',
410
+ timeout: 240000,
411
+ maxBuffer: 16 * 1024 * 1024,
412
+ env: process.env,
413
+ });
414
+ if (result.error) return { error: '内置拉包通道启动失败: ' + result.error.message };
415
+ const line = String(result.stdout || '').trim().split(/\r?\n/).filter(Boolean).pop();
416
+ let parsed = null;
417
+ if (line) {
418
+ try {
419
+ parsed = JSON.parse(line);
420
+ } catch (_) {
421
+ parsed = null;
422
+ }
423
+ }
424
+ if (!parsed) {
425
+ const brief = String(result.stderr || '').trim().split(/\r?\n/).filter(Boolean).slice(-2).join(' | ');
426
+ return { error: '内置拉包通道异常(exit ' + result.status + ')' + (brief ? ': ' + brief : '') };
427
+ }
428
+ if (!parsed.ok) return { error: parsed.error || '内置拉包失败' };
429
+ const out2 = parsed.result || {};
430
+ out2.channel = 'builtin';
431
+ return out2;
432
+ }
433
+
434
+ module.exports = {
435
+ OFFICIAL_REGISTRY,
436
+ registryBase,
437
+ packumentUrl,
438
+ tarballName,
439
+ majorRange,
440
+ parseIntegrity,
441
+ proxyFor,
442
+ noProxyMatch,
443
+ resolveVersion,
444
+ fetchPackument,
445
+ downloadTarball,
446
+ fetchPackage,
447
+ fetchPackageSync,
448
+ };
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/untar.js —— 零依赖内置解包(元阁 0.24.0,方案 A.2)。
4
+ *
5
+ * 只服务 npm 包形态的 tar.gz:必须 `package/` 前缀、拒绝链接条目与路径越界、
6
+ * 支持 ustar prefix / pax 扩展头 / GNU 长名;产出与 bin 的 extractTarball 相同契约
7
+ * (`{ pkgDir }` 或 `{ error }`)。
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const zlib = require('zlib');
13
+
14
+ /** 与 lib/install-pipeline.js 的 isSafeTarEntry 同口径(测试有 parity 断言)。 */
15
+ function isSafeTarEntry(entry) {
16
+ const value = String(entry || '').replace(/\\/g, '/');
17
+ if (value !== 'package' && !value.startsWith('package/')) return false;
18
+ if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return false;
19
+ return !value.split('/').includes('..');
20
+ }
21
+
22
+ function cstring(buf, start, length) {
23
+ const slice = buf.subarray(start, start + length);
24
+ const end = slice.indexOf(0);
25
+ return slice.subarray(0, end === -1 ? slice.length : end).toString('utf8').trim();
26
+ }
27
+
28
+ function parseOctal(buf, start, length) {
29
+ const raw = cstring(buf, start, length).replace(/\0/g, '').trim();
30
+ if (!raw) return 0;
31
+ const value = parseInt(raw, 8);
32
+ return Number.isFinite(value) ? value : 0;
33
+ }
34
+
35
+ function parsePaxRecords(data) {
36
+ const map = {};
37
+ let offset = 0;
38
+ while (offset < data.length) {
39
+ const space = data.indexOf(0x20, offset);
40
+ if (space === -1) break;
41
+ const length = parseInt(data.subarray(offset, space).toString('utf8'), 10);
42
+ if (!Number.isFinite(length) || length <= 0 || offset + length > data.length) break;
43
+ const record = data.subarray(space + 1, offset + length).toString('utf8');
44
+ const eq = record.indexOf('=');
45
+ if (eq !== -1) map[record.slice(0, eq)] = record.slice(eq + 1).replace(/\n$/, '');
46
+ offset += length;
47
+ }
48
+ return map;
49
+ }
50
+
51
+ /**
52
+ * 解析 tar(不含 gzip)为条目数组。
53
+ * 返回 { entries } 或 { error };条目形如 { name, mode, type, data }。
54
+ */
55
+ function parseEntries(buffer) {
56
+ const entries = [];
57
+ let offset = 0;
58
+ let pendingPax = {};
59
+ let pendingLongName = null;
60
+ while (offset + 512 <= buffer.length) {
61
+ const header = buffer.subarray(offset, offset + 512);
62
+ let allZero = true;
63
+ for (const byte of header) {
64
+ if (byte !== 0) {
65
+ allZero = false;
66
+ break;
67
+ }
68
+ }
69
+ if (allZero) break;
70
+
71
+ let name = cstring(header, 0, 100);
72
+ const prefix = cstring(header, 345, 155);
73
+ const mode = parseOctal(header, 100, 8);
74
+ const size = parseOctal(header, 124, 12);
75
+ const type = String.fromCharCode(header[156] || 0x30);
76
+ const dataStart = offset + 512;
77
+ const dataEnd = dataStart + size;
78
+ if (size < 0 || dataEnd > buffer.length) {
79
+ return { error: 'tar 结构损坏:条目数据越界(' + (name || '未命名') + ')' };
80
+ }
81
+ const data = buffer.subarray(dataStart, dataEnd);
82
+
83
+ if (type === 'x' || type === 'g') {
84
+ const records = parsePaxRecords(data);
85
+ if (type === 'x') pendingPax = Object.assign(pendingPax, records);
86
+ offset = dataStart + Math.ceil(size / 512) * 512;
87
+ continue;
88
+ }
89
+ if (type === 'L') {
90
+ pendingLongName = cstring(data, 0, data.length);
91
+ offset = dataStart + Math.ceil(size / 512) * 512;
92
+ continue;
93
+ }
94
+ if (prefix && type !== 'x' && type !== 'g') name = prefix + '/' + name;
95
+ if (pendingLongName) {
96
+ name = pendingLongName;
97
+ pendingLongName = null;
98
+ }
99
+ if (pendingPax.path) name = pendingPax.path;
100
+ if (pendingPax.size !== undefined && Number(pendingPax.size) !== size) {
101
+ return { error: 'tar 结构不支持:pax size 覆盖(' + name + ')' };
102
+ }
103
+ const entryMode = pendingPax.mode !== undefined ? parseInt(pendingPax.mode, 8) : mode;
104
+ pendingPax = {};
105
+
106
+ entries.push({ name, mode: Number.isFinite(entryMode) ? entryMode : mode, type, size, data: Buffer.from(data) });
107
+ offset = dataStart + Math.ceil(size / 512) * 512;
108
+ }
109
+ return { entries };
110
+ }
111
+
112
+ function extractTarballBuiltin(tarball, extractDir) {
113
+ let compressed;
114
+ try {
115
+ compressed = fs.readFileSync(tarball);
116
+ } catch (error) {
117
+ return { error: '读取压缩包失败: ' + error.message };
118
+ }
119
+ let buffer;
120
+ try {
121
+ buffer = zlib.gunzipSync(compressed);
122
+ } catch (error) {
123
+ return { error: 'gzip 解压失败: ' + error.message };
124
+ }
125
+
126
+ const parsed = parseEntries(buffer);
127
+ if (parsed.error) return { error: parsed.error };
128
+
129
+ // fail-closed:先整体校验,再落盘(不允许部分写入)。
130
+ const root = path.resolve(extractDir);
131
+ for (const entry of parsed.entries) {
132
+ if (!isSafeTarEntry(entry.name)) return { error: '压缩包包含不安全路径: ' + entry.name };
133
+ if (entry.type === '1' || entry.type === '2') {
134
+ return { error: '压缩包包含链接条目(fail-closed 拒绝): ' + entry.name };
135
+ }
136
+ if (!['0', '7', '5'].includes(entry.type)) {
137
+ return { error: '压缩包包含不支持的 tar 条目类型(' + entry.type + '): ' + entry.name };
138
+ }
139
+ const target = path.resolve(extractDir, entry.name);
140
+ if (target !== root && !target.startsWith(root + path.sep)) {
141
+ return { error: '压缩包路径越界: ' + entry.name };
142
+ }
143
+ }
144
+
145
+ fs.mkdirSync(extractDir, { recursive: true });
146
+ try {
147
+ for (const entry of parsed.entries) {
148
+ const target = path.resolve(extractDir, entry.name);
149
+ if (entry.type === '5') {
150
+ fs.mkdirSync(target, { recursive: true });
151
+ continue;
152
+ }
153
+ fs.mkdirSync(path.dirname(target), { recursive: true });
154
+ fs.writeFileSync(target, entry.data);
155
+ if (entry.mode & 0o111) {
156
+ try {
157
+ fs.chmodSync(target, 0o755);
158
+ } catch (_) {
159
+ // Windows 上 chmod 基本无效;忽略即可,不阻断解包。
160
+ }
161
+ }
162
+ }
163
+ } catch (error) {
164
+ return { error: '写入解包产物失败: ' + error.message };
165
+ }
166
+
167
+ const pkgDir = path.join(extractDir, 'package');
168
+ if (!fs.existsSync(path.join(pkgDir, 'SKILL.md'))) {
169
+ return { error: '解压产物缺少 SKILL.md(' + tarball + ')' };
170
+ }
171
+ return { pkgDir, channel: 'builtin' };
172
+ }
173
+
174
+ module.exports = {
175
+ isSafeTarEntry,
176
+ parseEntries,
177
+ parsePaxRecords,
178
+ extractTarballBuiltin,
179
+ };
@@ -8,9 +8,11 @@
8
8
  对每个待装技能:
9
9
 
10
10
  1. 读清单(`skills.json`,默认随包;`YOTTA_SKILLS_MANIFEST` 可覆盖);
11
- 2. `npm pack <pkg>@<spec> --pack-destination <临时目录>`(默认源返回 404 时自动改用官方源重试一次,
12
- 证据记 `npm_registry_fallback`);
13
- 3. 系统 `tar -xzf` 解压到临时目录(产物应有 `SKILL.md`);
11
+ 2. 拉包:默认**内置通道**(Node 内置 https 直连 registry:abbreviated packument → 解析版本 → tarball
12
+ 下载 + `integrity`(sha512) / `shasum`(sha1) fail-closed 校验),失败回退 `npm pack`
13
+ (默认源 404 时自动改用官方源重试一次,证据记 `npm_registry_fallback`);通道记 `fetch_channel`;
14
+ 3. 解包:默认**内置通道**(Node 内置 zlib + tar 解析:ustar / pax / GNU 长名;路径安全与链接条目
15
+ fail-closed),失败回退系统 `tar -xzf`;通道记 `extract_channel`;产物应有 `SKILL.md`;
14
16
  4. 读取包内 `skill-manifest.json`;没有 manifest 时使用 `skills.json` 的家族默认契约;
15
17
  5. 校验 slug / package / version / 权限 / 生命周期脚本路径;
16
18
  6. 元信装前扫描:已有元信则直接扫描;没有则在本次已获用户确认的安装动作内自举元信自身,再扫描;
@@ -28,6 +30,20 @@
28
30
  因此装进技能目录的是「技能本体」(SKILL.md / references / scripts 等),不含 npm
29
31
  安装器自身、测试夹具与 Python 字节码缓存。
30
32
 
33
+ ## 依赖与回退链
34
+
35
+ | 依赖 | 角色 | 缺失时 |
36
+ |---|---|---|
37
+ | Node.js 18+ | 必需(CLI 本体) | 明确提示 + 按平台给安装命令 |
38
+ | npm | 拉包回退通道(`YOTTA_SKILLS_NPM_FLAGS` / `--npm` 仍生效) | 不影响:内置拉包为主 |
39
+ | 系统 tar | 解包回退通道 | 不影响:内置解包为主 |
40
+ | Python 3.8+ | 元信装前扫描 | 门禁阻断(exitCode 5);优先 `--python` / `YOTTA_SKILLS_PYTHON` 指向宿主自带 Python,或 `--skip-scan` 应急 |
41
+
42
+ - 通道可用 `YOTTA_SKILLS_FETCH=builtin|npm` / `YOTTA_SKILLS_EXTRACT=builtin|tar` 强制;
43
+ 强制单一通道失败时不回退,并给出明确报错;
44
+ - 缺依赖时按需输出「需要什么 / 为什么 / 一条修复命令 / 不影响使用」,不常驻提醒;
45
+ - `doctor` 新增依赖自检块(text + `--json` 的 `dependencies` 字段),只告警不失败、不改变退出码。
46
+
31
47
  ## manifest 与家族默认契约
32
48
 
33
49
  每个技能包可以自带根目录 `skill-manifest.json`。有 manifest 时:
@@ -63,6 +79,21 @@
63
79
  - 与清单 `version` 一致 → 跳过(幂等:第二次安装 27 个全部跳过);
64
80
  - `--force` 强制重装;`update` 对「缺失 / 版本不一致」的技能重装。
65
81
 
82
+ ## 范围控制(接多少管多少)
83
+
84
+ | 用法 | 范围 |
85
+ |---|---|
86
+ | `install`(无旗标) | 全量清单全部安装 |
87
+ | `update`(无旗标) | 补齐缺失 + 升级已装(现状语义不变) |
88
+ | `update --installed-only` | 只维护目标目录已安装的家族技能(不补装缺失;无匹配时退出码 0) |
89
+ | `--only <a,b>` / `--domain <name>` | 只处理指定技能 / 家族;与 `--installed-only` 取交集 |
90
+ | `update --check` / `--auto` | 只检查 / 维护已装技能,不补装缺失 |
91
+
92
+ 接管语义:接管范围 = 目标机器**已安装**的元技能(扫描目标目录得出),
93
+ `skills.json` 只作身份 / 版本参照、不决定安装范围;宿主未装的技能一律不动作(不新增、不报错);
94
+ 已管理且已最新 → 跳过(退出码 0);非家族 / 未收录目录 → 忽略不报错。
95
+ 标准配方:`yotta-skills update --installed-only --dir <技能目录>`。
96
+
66
97
  ## 版本策略
67
98
 
68
99
  - 默认 pin:spec = `<pkg>@<清单精确版本>`,完全可复现,不跟随浮动版本;
@@ -80,6 +111,7 @@
80
111
 
81
112
  - 引擎查找:`--verify` → `YOTTA_SKILLS_VERIFY` → `<dest>/yotta-verify/scripts/yotta_verify.py`;
82
113
  - python 查找:`--python` → `YOTTA_SKILLS_PYTHON` → `python3` / `python` / `py`(win32);
114
+ 缺 Python 时输出统一人话提示(含「修复」与「不影响使用」,可指向宿主自带 Python 或 `--skip-scan` 应急);
83
115
  - 执行:`python -B <engine> scan <解压目录> --json`(`-B` 禁止写 `__pycache__`,防止污染
84
116
  引擎所在目录);
85
117
  - 元信缺失时:先按 `skills.json` 安装 `yotta-verify`,并记录 `gate_mode=trusted-bootstrap`
@@ -157,7 +189,8 @@ doctor 只读检查目录、`SKILL.md`、版本、manifest 身份、注册表记
157
189
  ```
158
190
 
159
191
  记录包含时间、事件、技能、包名、版本、`gate_mode`、verdict、decision
160
- 和快照路径;不记录技能内容或用户数据,只写本机。
192
+ 、`fetch_channel`(builtin / npm)、`extract_channel`(builtin / tar)和快照路径;
193
+ 不记录技能内容或用户数据,只写本机。
161
194
 
162
195
  ## 退出码
163
196