@morehao/dsh-skills-console 0.2.4 → 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/README.md +14 -19
- package/client.js +43 -17
- package/lib/core/archive.js +246 -0
- package/lib/core/discovery.js +148 -0
- package/lib/core/git-tree.js +132 -0
- package/lib/core/installer.js +351 -0
- package/lib/core/lock.js +310 -0
- package/lib/core/remote.js +332 -0
- package/lib/core/service.js +348 -354
- package/lib/core/skill-md.js +49 -0
- package/lib/core/trash.js +2 -2
- package/lib/index.js +3 -3
- package/package.json +2 -2
- package/lib/core/cli.js +0 -256
- package/lib/core/git.js +0 -282
package/lib/core/lock.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* v3 skill lock(`~/.agents/.skill-lock.json`,或 XDG_STATE_HOME 下)的**唯一写入者**。
|
|
7
|
+
*
|
|
8
|
+
* 兼容目标(与 `skills` CLI 落盘格式一致):
|
|
9
|
+
* - `version` 必须恰好是 3;>3 视为不认识的新版本,只读并抛 `lock-unsupported`,绝不覆盖。
|
|
10
|
+
* - JSON 损坏同样抛错;宁可写失败,也不拿空账覆盖用户的 lock。
|
|
11
|
+
* - 顶层未知字段、每个技能条目的未知字段、`dismissed` 全部原样保留,只动调用方明确要动的键。
|
|
12
|
+
* - 写入:同目录临时文件 → fsync → rename;写前留 `.bak`;rename 失败时原文件不会被截断。
|
|
13
|
+
* - `expectedDigest` 用来防止覆盖外部 CLI 刚写入的内容:写前重算,不一致就报冲突。
|
|
14
|
+
*
|
|
15
|
+
* 语义边界:只负责文件一致性,不判断技能是否该装、不碰 canonical;事务边界由 installer 负责。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const LOCK_VERSION = 3;
|
|
19
|
+
|
|
20
|
+
const SOURCE_TYPES = new Set(['github', 'local', 'download']);
|
|
21
|
+
const TREE_SHA_RE = /^[0-9a-f]{40}$/;
|
|
22
|
+
const CONTENT_SHA_RE = /^[0-9a-f]{64}$/;
|
|
23
|
+
|
|
24
|
+
export class LockError extends Error {
|
|
25
|
+
constructor(code, message, extra = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'LockError';
|
|
28
|
+
this.code = code;
|
|
29
|
+
Object.assign(this, extra);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function lockDigest(text) {
|
|
38
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读 lock 用于写。返回 `{ present, raw, digest, data }`;
|
|
43
|
+
* 文件不存在返回 `present:false`(调用方创建空账);其余异常一律抛 LockError。
|
|
44
|
+
*/
|
|
45
|
+
export function readLockFile(lockPath) {
|
|
46
|
+
if (typeof lockPath !== 'string' || lockPath.length === 0) {
|
|
47
|
+
throw new LockError('lock-invalid', 'readLockFile 需要 lock 路径');
|
|
48
|
+
}
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = fs.readFileSync(lockPath, 'utf8');
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error && error.code === 'ENOENT') {
|
|
54
|
+
return { present: false, raw: null, digest: null, data: null };
|
|
55
|
+
}
|
|
56
|
+
throw new LockError('lock-unreadable', `无法读取 lock: ${error.message}`, { cause: error });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
data = JSON.parse(raw);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new LockError('lock-corrupt', `lock 不是合法 JSON: ${error.message}`, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
if (!isPlainObject(data)) throw new LockError('lock-corrupt', 'lock 顶层不是对象');
|
|
66
|
+
if (typeof data.version !== 'number' || !Number.isInteger(data.version)) {
|
|
67
|
+
throw new LockError('lock-corrupt', 'lock.version 缺失或不是整数');
|
|
68
|
+
}
|
|
69
|
+
if (data.version !== LOCK_VERSION) {
|
|
70
|
+
throw new LockError(
|
|
71
|
+
'lock-unsupported',
|
|
72
|
+
`lock.version ${data.version} 不是可写的 v${LOCK_VERSION},拒绝覆盖`,
|
|
73
|
+
{ version: data.version },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(data.skills)) throw new LockError('lock-corrupt', 'lock.skills 缺失或不是对象');
|
|
77
|
+
if (data.dismissed !== undefined && !isPlainObject(data.dismissed)) {
|
|
78
|
+
throw new LockError('lock-corrupt', 'lock.dismissed 不是对象');
|
|
79
|
+
}
|
|
80
|
+
return { present: true, raw, digest: lockDigest(raw), data };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function emptyLock() {
|
|
84
|
+
return { version: LOCK_VERSION, skills: {}, dismissed: {} };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertLockShape(data) {
|
|
88
|
+
if (!isPlainObject(data)) throw new LockError('lock-invalid', '写回的 lock 不是对象');
|
|
89
|
+
if (data.version !== LOCK_VERSION) {
|
|
90
|
+
throw new LockError('lock-invalid', `写回的 lock.version 必须是 ${LOCK_VERSION}`);
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainObject(data.skills)) throw new LockError('lock-invalid', '写回的 lock.skills 不是对象');
|
|
93
|
+
if (data.dismissed !== undefined && !isPlainObject(data.dismissed)) {
|
|
94
|
+
throw new LockError('lock-invalid', '写回的 lock.dismissed 不是对象');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function atomicWrite(target, text) {
|
|
99
|
+
const dir = path.dirname(target);
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const tmp = path.join(
|
|
102
|
+
dir,
|
|
103
|
+
`.${path.basename(target)}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
104
|
+
);
|
|
105
|
+
let fd = null;
|
|
106
|
+
try {
|
|
107
|
+
fd = fs.openSync(tmp, 'wx', 0o644);
|
|
108
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
109
|
+
fs.fsyncSync(fd);
|
|
110
|
+
fs.closeSync(fd);
|
|
111
|
+
fd = null;
|
|
112
|
+
fs.renameSync(tmp, target);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (fd !== null) {
|
|
115
|
+
try {
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
} catch {
|
|
118
|
+
// 已经关掉就算了,别覆盖原始错误
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
fs.rmSync(tmp, { force: true });
|
|
122
|
+
throw new LockError('lock-write-failed', `写 lock 失败: ${error.message}`, { cause: error });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 读改写 lock。`mutate(draft)` 直接改 draft(同步);返回 `{ data, digest, changed, backup }`。
|
|
128
|
+
*
|
|
129
|
+
* - `expectedDigest` 传 `null` 表示“写之前必须不存在”;传字符串表示必须等于当前内容摘要;
|
|
130
|
+
* 不传则不校验。
|
|
131
|
+
* - 内容没变化时不落盘(不产生 `.bak`、不更新时间戳)。
|
|
132
|
+
*/
|
|
133
|
+
export function updateLock({ lockPath, mutate, expectedDigest } = {}) {
|
|
134
|
+
if (typeof mutate !== 'function') {
|
|
135
|
+
throw new LockError('lock-invalid', 'updateLock 需要 mutate 函数');
|
|
136
|
+
}
|
|
137
|
+
const current = readLockFile(lockPath);
|
|
138
|
+
if (expectedDigest !== undefined && current.digest !== expectedDigest) {
|
|
139
|
+
throw new LockError('lock-conflict', 'lock 在本次操作期间被其他进程修改,拒绝覆盖', {
|
|
140
|
+
expectedDigest,
|
|
141
|
+
actualDigest: current.digest,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const draft = current.present ? structuredClone(current.data) : emptyLock();
|
|
146
|
+
const result = mutate(draft);
|
|
147
|
+
const next = result === undefined ? draft : result;
|
|
148
|
+
assertLockShape(next);
|
|
149
|
+
|
|
150
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
151
|
+
if (current.present && text === current.raw) {
|
|
152
|
+
return { data: next, digest: current.digest, changed: false, backup: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let backup = null;
|
|
156
|
+
if (current.present) {
|
|
157
|
+
backup = `${lockPath}.bak`;
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(backup, current.raw, { encoding: 'utf8', mode: 0o644 });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new LockError('lock-write-failed', `备份 lock 失败: ${error.message}`, { cause: error });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
atomicWrite(lockPath, text);
|
|
165
|
+
return { data: next, digest: lockDigest(text), changed: true, backup };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const KNOWN_ENTRY_KEYS = new Set([
|
|
169
|
+
'source',
|
|
170
|
+
'sourceType',
|
|
171
|
+
'sourceUrl',
|
|
172
|
+
'ref',
|
|
173
|
+
'skillPath',
|
|
174
|
+
'skillFolderHash',
|
|
175
|
+
'installedAt',
|
|
176
|
+
'updatedAt',
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 新旧条目合并:已知字段以新条目为准(新条目没有 `ref` 就删掉旧的),
|
|
181
|
+
* 未知字段原样保留 —— 与 CLI 的“不重建整个对象”语义一致,未来 CLI 加字段不会被我们抹掉。
|
|
182
|
+
*/
|
|
183
|
+
function mergeLockEntry(previous, entry) {
|
|
184
|
+
if (!isPlainObject(previous)) return entry;
|
|
185
|
+
const merged = {};
|
|
186
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
187
|
+
if (!KNOWN_ENTRY_KEYS.has(key)) merged[key] = value;
|
|
188
|
+
}
|
|
189
|
+
return { ...merged, ...entry };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 安装/更新后写入一个技能条目;`entry` 由 makeLockEntry 或调用方按 CLI 形状构造。 */
|
|
193
|
+
export function upsertSkill({ lockPath, name, entry, expectedDigest } = {}) {
|
|
194
|
+
assertSkillName(name);
|
|
195
|
+
assertLockEntry(name, entry);
|
|
196
|
+
return updateLock({
|
|
197
|
+
lockPath,
|
|
198
|
+
expectedDigest,
|
|
199
|
+
mutate: (draft) => {
|
|
200
|
+
draft.skills[name] = mergeLockEntry(draft.skills[name], entry);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 删除一个技能条目;条目本就不存在时视为成功(changed:false)。 */
|
|
206
|
+
export function removeSkill({ lockPath, name, expectedDigest } = {}) {
|
|
207
|
+
assertSkillName(name);
|
|
208
|
+
return updateLock({
|
|
209
|
+
lockPath,
|
|
210
|
+
expectedDigest,
|
|
211
|
+
mutate: (draft) => {
|
|
212
|
+
delete draft.skills[name];
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function assertSkillName(name) {
|
|
218
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
219
|
+
throw new LockError('lock-invalid', '技能名必须是非空字符串');
|
|
220
|
+
}
|
|
221
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
222
|
+
throw new LockError('lock-invalid', `技能名不能含路径分隔符: ${name}`);
|
|
223
|
+
}
|
|
224
|
+
// eslint-disable-next-line no-control-regex
|
|
225
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
226
|
+
throw new LockError('lock-invalid', `技能名不能含控制字符: ${JSON.stringify(name)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isIsoDate(value) {
|
|
231
|
+
if (typeof value !== 'string' || value.length === 0) return false;
|
|
232
|
+
const time = Date.parse(value);
|
|
233
|
+
return Number.isFinite(time);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 校验一条 lock 条目。允许 40 位 tree SHA 或旧数据的 64 位内容 hash,
|
|
238
|
+
* 但 `makeLockEntry` 只产出 40 位 —— 新安装绝不用内容 hash 冒充 tree SHA。
|
|
239
|
+
*/
|
|
240
|
+
export function assertLockEntry(name, entry) {
|
|
241
|
+
const fail = (message) => {
|
|
242
|
+
throw new LockError('lock-invalid', `${name}: ${message}`);
|
|
243
|
+
};
|
|
244
|
+
if (!isPlainObject(entry)) fail('条目不是对象');
|
|
245
|
+
if (typeof entry.source !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(entry.source)) {
|
|
246
|
+
fail('source 不是 owner/repo');
|
|
247
|
+
}
|
|
248
|
+
if (!SOURCE_TYPES.has(entry.sourceType)) fail(`sourceType 不认识: ${entry.sourceType}`);
|
|
249
|
+
if (typeof entry.sourceUrl !== 'string' || !entry.sourceUrl.startsWith('https://')) {
|
|
250
|
+
fail('sourceUrl 必须是 https URL');
|
|
251
|
+
}
|
|
252
|
+
if (typeof entry.skillPath !== 'string' || entry.skillPath.length === 0) fail('skillPath 缺失');
|
|
253
|
+
if (
|
|
254
|
+
entry.skillPath.startsWith('/') ||
|
|
255
|
+
entry.skillPath.includes('\\') ||
|
|
256
|
+
entry.skillPath.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
257
|
+
) {
|
|
258
|
+
fail(`skillPath 不是安全的 POSIX 相对路径: ${entry.skillPath}`);
|
|
259
|
+
}
|
|
260
|
+
if (!entry.skillPath.endsWith('.md')) fail('skillPath 应指向 .md 文件');
|
|
261
|
+
if (typeof entry.skillFolderHash !== 'string') fail('skillFolderHash 缺失');
|
|
262
|
+
if (!TREE_SHA_RE.test(entry.skillFolderHash) && !CONTENT_SHA_RE.test(entry.skillFolderHash)) {
|
|
263
|
+
fail('skillFolderHash 既不是 40 位 tree SHA 也不是 64 位内容 hash');
|
|
264
|
+
}
|
|
265
|
+
if (!isIsoDate(entry.installedAt)) fail('installedAt 不是 ISO 时间');
|
|
266
|
+
if (!isIsoDate(entry.updatedAt)) fail('updatedAt 不是 ISO 时间');
|
|
267
|
+
if (entry.ref !== undefined && entry.ref !== null) {
|
|
268
|
+
if (typeof entry.ref !== 'string' || entry.ref.length === 0 || entry.ref.length > 200) {
|
|
269
|
+
fail('ref 不是合法字符串');
|
|
270
|
+
}
|
|
271
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(entry.ref) || entry.ref.includes('..')) fail(`ref 含非法字符: ${entry.ref}`);
|
|
272
|
+
}
|
|
273
|
+
return entry;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 按 CLI 落盘形状构造条目(键顺序照 skills@1.7.4:source / sourceType / sourceUrl /
|
|
278
|
+
* [ref] / skillPath / skillFolderHash / installedAt / updatedAt)。
|
|
279
|
+
* `ref` 为空时直接省略(CLI 对默认分支安装就是这么写的)。
|
|
280
|
+
*/
|
|
281
|
+
export function makeLockEntry({
|
|
282
|
+
source,
|
|
283
|
+
ref = null,
|
|
284
|
+
skillPath,
|
|
285
|
+
treeSha,
|
|
286
|
+
installedAt,
|
|
287
|
+
updatedAt,
|
|
288
|
+
now = new Date(),
|
|
289
|
+
} = {}) {
|
|
290
|
+
const normalized = typeof source === 'string' ? source.trim().replace(/\.git$/, '') : source;
|
|
291
|
+
if (typeof normalized !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(normalized)) {
|
|
292
|
+
throw new LockError('lock-invalid', `source 必须是 owner/repo: ${source}`);
|
|
293
|
+
}
|
|
294
|
+
if (typeof treeSha !== 'string' || !TREE_SHA_RE.test(treeSha)) {
|
|
295
|
+
throw new LockError('lock-invalid', `treeSha 必须是 40 位小写 hex: ${treeSha}`);
|
|
296
|
+
}
|
|
297
|
+
const stamp = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
|
298
|
+
const entry = {
|
|
299
|
+
source: normalized,
|
|
300
|
+
sourceType: 'github',
|
|
301
|
+
sourceUrl: `https://github.com/${normalized}.git`,
|
|
302
|
+
};
|
|
303
|
+
if (ref !== null && ref !== undefined) entry.ref = ref;
|
|
304
|
+
entry.skillPath = skillPath;
|
|
305
|
+
entry.skillFolderHash = treeSha;
|
|
306
|
+
entry.installedAt = installedAt === undefined ? stamp : installedAt;
|
|
307
|
+
entry.updatedAt = updatedAt === undefined ? stamp : updatedAt;
|
|
308
|
+
assertLockEntry(source, entry);
|
|
309
|
+
return entry;
|
|
310
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { extractTarball } from './archive.js';
|
|
5
|
+
import { discoverSkills } from './discovery.js';
|
|
6
|
+
import { gitTreeSha } from './git-tree.js';
|
|
7
|
+
import { parseSource } from './updates.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 远端通道:codeload tarball、Atom commit feed、限流时的快照扫描。
|
|
11
|
+
*
|
|
12
|
+
* 安全边界(与设计文档 §安全一致):
|
|
13
|
+
* - 只接受 HTTPS,只允许 GitHub 的 host;重定向手动跟随,跨 host 一律拒绝并剥离 Authorization。
|
|
14
|
+
* - token 只进 header,绝不拼进 URL、错误消息或日志。
|
|
15
|
+
* - 响应 `content-length` 与流式累计双重限额,超限立即中止并删掉半截文件。
|
|
16
|
+
* - 默认 60s 超时;超时/中止都抛 RemoteError,不留下半写文件。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
export const DEFAULT_TIMEOUT_MS = 60_000;
|
|
21
|
+
const MAX_REDIRECTS = 5;
|
|
22
|
+
const ALLOWED_HOSTS = new Set([
|
|
23
|
+
'codeload.github.com',
|
|
24
|
+
'github.com',
|
|
25
|
+
'objects.githubusercontent.com',
|
|
26
|
+
'raw.githubusercontent.com',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export class RemoteError extends Error {
|
|
30
|
+
constructor(code, message, extra = {}) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'RemoteError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
Object.assign(this, extra);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** commit(40 位)或分支/tag;分支里的 `/` 是合法的,但 `..`/空白/反斜杠不行。 */
|
|
39
|
+
export function assertRef(ref) {
|
|
40
|
+
if (typeof ref !== 'string' || ref.length === 0 || ref.length > 200) {
|
|
41
|
+
throw new RemoteError('invalid-ref', 'ref 必须是非空字符串');
|
|
42
|
+
}
|
|
43
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(ref) || ref.includes('..') || ref.startsWith('/') || ref.endsWith('/')) {
|
|
44
|
+
throw new RemoteError('invalid-ref', `ref 含非法字符: ${ref}`);
|
|
45
|
+
}
|
|
46
|
+
return ref;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function codeloadUrl({ source, ref }) {
|
|
50
|
+
const parsed = parseSource(source);
|
|
51
|
+
if (!parsed) throw new RemoteError('invalid-source', `不是 GitHub 来源: ${source}`);
|
|
52
|
+
assertRef(ref);
|
|
53
|
+
return `https://codeload.github.com/${parsed.owner}/${parsed.repo}/tar.gz/${ref}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertAllowedUrl(url) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(url);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new RemoteError('redirect-rejected', `重定向目标不是合法 URL: ${url}`);
|
|
62
|
+
}
|
|
63
|
+
if (parsed.protocol !== 'https:') {
|
|
64
|
+
throw new RemoteError('redirect-rejected', `只允许 HTTPS: ${url}`);
|
|
65
|
+
}
|
|
66
|
+
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
|
|
67
|
+
throw new RemoteError('redirect-rejected', `不允许的 host: ${parsed.hostname}`);
|
|
68
|
+
}
|
|
69
|
+
if (parsed.username || parsed.password) {
|
|
70
|
+
throw new RemoteError('redirect-rejected', '重定向目标不能带 userinfo');
|
|
71
|
+
}
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function tooLarge(maxBytes, bytes) {
|
|
76
|
+
return new RemoteError('tarball-too-large', `tarball 超过 ${maxBytes} 字节上限(已收到 ${bytes})`, {
|
|
77
|
+
maxBytes,
|
|
78
|
+
bytes,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ATOM_ID_RE = /<id>tag:github\.com,2008:Grit::Commit\/([0-9a-f]{40})<\/id>/i;
|
|
83
|
+
const ATOM_LINK_RE = /https:\/\/github\.com\/[^"<\s]+\/commit\/([0-9a-f]{40})/i;
|
|
84
|
+
|
|
85
|
+
/** 从 GitHub 的 commits Atom feed 里取第一条 commit(feed 按时间倒序)。 */
|
|
86
|
+
export function parseAtomCommit(text) {
|
|
87
|
+
const body = String(text || '');
|
|
88
|
+
const match = ATOM_ID_RE.exec(body) || ATOM_LINK_RE.exec(body);
|
|
89
|
+
return match ? match[1].toLowerCase() : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Atom commit feed:`/commits/<ref>.atom`(不带 ref 即默认分支)。
|
|
94
|
+
* 不占 REST 配额,也不需要 `--json` / git;失败抛 RemoteError,由调用方决定是否降级。
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchAtomCommit({
|
|
97
|
+
source,
|
|
98
|
+
ref = null,
|
|
99
|
+
token = null,
|
|
100
|
+
fetchImpl = fetch,
|
|
101
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
102
|
+
} = {}) {
|
|
103
|
+
const parsed = parseSource(source);
|
|
104
|
+
if (!parsed) throw new RemoteError('bad-source', `只接受 owner/repo 或 GitHub URL: ${source}`);
|
|
105
|
+
if (ref !== null) assertRef(ref);
|
|
106
|
+
const refPath = ref ? `/${ref.split('/').map(encodeURIComponent).join('/')}` : '';
|
|
107
|
+
const url = `https://github.com/${parsed.owner}/${parsed.repo}/commits${refPath}.atom`;
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
let timedOut = false;
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
timedOut = true;
|
|
112
|
+
controller.abort();
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
try {
|
|
116
|
+
const headers = { accept: 'application/atom+xml', 'user-agent': 'dsh-skills-console' };
|
|
117
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetchImpl(url, { headers, signal: controller.signal });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (timedOut) throw new RemoteError('timeout', `读取 Atom 超时(${timeoutMs}ms)`, { cause: error });
|
|
123
|
+
throw new RemoteError('network', `读取 Atom 失败: ${error.message}`, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
const text = await response.text().catch(() => '');
|
|
127
|
+
throw new RemoteError('http-error', `HTTP ${response.status}: ${text.slice(0, 200)}`, {
|
|
128
|
+
status: response.status,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const commit = parseAtomCommit(await response.text());
|
|
132
|
+
if (!commit) throw new RemoteError('atom-empty', 'Atom feed 里没有 commit');
|
|
133
|
+
return { commit, url, status: response.status };
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function skillDirOf(skillPath) {
|
|
140
|
+
const index = skillPath.lastIndexOf('/');
|
|
141
|
+
return index < 0 ? '' : skillPath.slice(0, index);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 用 codeload tarball 组装一份与 REST 同形的快照(限流兜底)。
|
|
146
|
+
* 只在临时目录里解压/扫描;rootSha 与每个 skill 的 treeSha 都在本地算。
|
|
147
|
+
*/
|
|
148
|
+
export async function snapshotFromTarball({
|
|
149
|
+
source,
|
|
150
|
+
commit,
|
|
151
|
+
ref = null,
|
|
152
|
+
subpath = null,
|
|
153
|
+
token = null,
|
|
154
|
+
fetchImpl = fetch,
|
|
155
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
156
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
157
|
+
} = {}) {
|
|
158
|
+
const parsed = parseSource(source);
|
|
159
|
+
if (!parsed) throw new RemoteError('bad-source', `只接受 owner/repo 或 GitHub URL: ${source}`);
|
|
160
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/i.test(commit)) {
|
|
161
|
+
throw new RemoteError('missing-commit', 'codeload 快照需要 40 位 commit');
|
|
162
|
+
}
|
|
163
|
+
const repo = `${parsed.owner}/${parsed.repo}`;
|
|
164
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'dshsc-scan-'));
|
|
165
|
+
try {
|
|
166
|
+
const tarball = path.join(work, 'repo.tar.gz');
|
|
167
|
+
await downloadTarball({ source: repo, ref: commit, dest: tarball, token, fetchImpl, maxBytes, timeoutMs });
|
|
168
|
+
const extracted = await extractTarball({ tarball, dest: path.join(work, 'stage') });
|
|
169
|
+
let skills;
|
|
170
|
+
try {
|
|
171
|
+
skills = discoverSkills({ dir: extracted.dir, subpath, repoName: parsed.repo });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw new RemoteError('scan-failed', `扫描快照失败: ${error.message}`, { cause: error, code: error.code });
|
|
174
|
+
}
|
|
175
|
+
let rootSha;
|
|
176
|
+
try {
|
|
177
|
+
rootSha = gitTreeSha(extracted.dir);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new RemoteError('scan-failed', `计算 root tree 失败: ${error.message}`, { cause: error });
|
|
180
|
+
}
|
|
181
|
+
const entries = [];
|
|
182
|
+
const tree = [];
|
|
183
|
+
const seenDirs = new Set();
|
|
184
|
+
for (const skill of skills) {
|
|
185
|
+
let treeSha;
|
|
186
|
+
try {
|
|
187
|
+
treeSha = gitTreeSha(skill.dirPath);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new RemoteError('scan-failed', `计算 ${skill.name} tree 失败: ${error.message}`, { cause: error });
|
|
190
|
+
}
|
|
191
|
+
entries.push({ name: skill.name, skillPath: skill.skillPath, treeSha });
|
|
192
|
+
const dir = skillDirOf(skill.skillPath);
|
|
193
|
+
if (dir && !seenDirs.has(dir)) {
|
|
194
|
+
seenDirs.add(dir);
|
|
195
|
+
tree.push({ path: dir, type: 'tree', sha: treeSha });
|
|
196
|
+
}
|
|
197
|
+
// blob 条目只用于判定「skillPath 是否还在」;真正比对用目录 treeSha
|
|
198
|
+
tree.push({ path: skill.skillPath, type: 'blob' });
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
branch: ref || null,
|
|
202
|
+
via: 'codeload',
|
|
203
|
+
tree,
|
|
204
|
+
rootSha,
|
|
205
|
+
snapshot: {
|
|
206
|
+
commit: commit.toLowerCase(),
|
|
207
|
+
rootSha,
|
|
208
|
+
entries,
|
|
209
|
+
fetchedAt: new Date().toISOString(),
|
|
210
|
+
etag: null,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
} finally {
|
|
214
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 把 codeload tarball 下载到 `dest`。`dest` 的父目录必须已存在。
|
|
220
|
+
* 成功返回 `{ file, bytes, url }`;任何失败都会删除 `dest` 并抛 RemoteError。
|
|
221
|
+
*/
|
|
222
|
+
export async function downloadTarball({
|
|
223
|
+
source,
|
|
224
|
+
ref,
|
|
225
|
+
token,
|
|
226
|
+
dest,
|
|
227
|
+
fetchImpl = fetch,
|
|
228
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
229
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
230
|
+
signal: externalSignal,
|
|
231
|
+
} = {}) {
|
|
232
|
+
if (typeof dest !== 'string' || dest.length === 0) {
|
|
233
|
+
throw new RemoteError('invalid-dest', 'downloadTarball 需要 dest 文件路径');
|
|
234
|
+
}
|
|
235
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
236
|
+
throw new RemoteError('invalid-limit', 'maxBytes 必须是正数');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const initialUrl = codeloadUrl({ source, ref });
|
|
240
|
+
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
let timedOut = false;
|
|
243
|
+
const timer =
|
|
244
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
245
|
+
? setTimeout(() => {
|
|
246
|
+
timedOut = true;
|
|
247
|
+
controller.abort();
|
|
248
|
+
}, timeoutMs)
|
|
249
|
+
: null;
|
|
250
|
+
const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
|
|
251
|
+
|
|
252
|
+
let url = initialUrl;
|
|
253
|
+
const authHost = new URL(initialUrl).hostname;
|
|
254
|
+
let handle = null;
|
|
255
|
+
let declared = null;
|
|
256
|
+
try {
|
|
257
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
258
|
+
const headers = {
|
|
259
|
+
accept: 'application/x-gzip',
|
|
260
|
+
'user-agent': 'dsh-skills-console',
|
|
261
|
+
};
|
|
262
|
+
// token 只发给最初那个 host;跨 host 重定向一律剥离,避免泄漏到 CDN/第三方。
|
|
263
|
+
const currentHost = new URL(url).hostname;
|
|
264
|
+
if (token && currentHost === authHost) headers.authorization = `Bearer ${token}`;
|
|
265
|
+
|
|
266
|
+
let response;
|
|
267
|
+
try {
|
|
268
|
+
response = await fetchImpl(url, { headers, redirect: 'manual', signal });
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (timedOut) throw new RemoteError('timeout', `下载超时(${timeoutMs}ms)`, { cause: error });
|
|
271
|
+
if (signal.aborted) throw new RemoteError('aborted', '下载已中止', { cause: error });
|
|
272
|
+
throw new RemoteError('download-failed', `下载失败: ${error.message}`, { cause: error });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (response.status >= 300 && response.status < 400) {
|
|
276
|
+
const location = response.headers.get('location');
|
|
277
|
+
await response.body?.cancel().catch(() => {});
|
|
278
|
+
if (!location) throw new RemoteError('redirect-rejected', `HTTP ${response.status} 缺少 Location`);
|
|
279
|
+
if (hop === MAX_REDIRECTS) {
|
|
280
|
+
throw new RemoteError('redirect-rejected', `重定向超过 ${MAX_REDIRECTS} 次`);
|
|
281
|
+
}
|
|
282
|
+
const next = assertAllowedUrl(new URL(location, url).toString());
|
|
283
|
+
// 跨 host 时不沿用上一跳的 Authorization:下一轮按新 host 重新决定。
|
|
284
|
+
url = next.toString();
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!response.ok) {
|
|
289
|
+
const text = await response.text().catch(() => '');
|
|
290
|
+
throw new RemoteError('http-error', `HTTP ${response.status}: ${text.slice(0, 200)}`, {
|
|
291
|
+
status: response.status,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const lengthHeader = response.headers.get('content-length');
|
|
296
|
+
declared = lengthHeader === null ? null : Number(lengthHeader);
|
|
297
|
+
if (declared !== null && Number.isFinite(declared) && declared > maxBytes) {
|
|
298
|
+
throw tooLarge(maxBytes, declared);
|
|
299
|
+
}
|
|
300
|
+
if (!response.body) throw new RemoteError('download-failed', '响应没有 body');
|
|
301
|
+
|
|
302
|
+
handle = await fs.promises.open(dest, 'w');
|
|
303
|
+
let bytes = 0;
|
|
304
|
+
for await (const chunk of response.body) {
|
|
305
|
+
bytes += chunk.byteLength;
|
|
306
|
+
if (bytes > maxBytes) throw tooLarge(maxBytes, bytes);
|
|
307
|
+
let offset = 0;
|
|
308
|
+
while (offset < chunk.byteLength) {
|
|
309
|
+
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
|
|
310
|
+
offset += bytesWritten;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (bytes === 0) throw new RemoteError('download-failed', '响应为空');
|
|
314
|
+
if (declared !== null && Number.isFinite(declared) && bytes !== declared) {
|
|
315
|
+
throw new RemoteError('truncated-download', `下载不完整:声明 ${declared} 字节,实际 ${bytes} 字节`);
|
|
316
|
+
}
|
|
317
|
+
await handle.close();
|
|
318
|
+
handle = null;
|
|
319
|
+
return { file: dest, bytes, url };
|
|
320
|
+
}
|
|
321
|
+
// 循环正常结束只可能是重定向用尽,上面的 hop 判断已抛错。
|
|
322
|
+
throw new RemoteError('redirect-rejected', '重定向次数过多');
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (handle) await handle.close().catch(() => {});
|
|
325
|
+
await fs.promises.unlink(dest).catch(() => {});
|
|
326
|
+
if (error instanceof RemoteError) throw error;
|
|
327
|
+
if (timedOut) throw new RemoteError('timeout', `下载超时(${timeoutMs}ms)`, { cause: error });
|
|
328
|
+
throw new RemoteError('download-failed', `下载失败: ${error.message}`, { cause: error });
|
|
329
|
+
} finally {
|
|
330
|
+
if (timer) clearTimeout(timer);
|
|
331
|
+
}
|
|
332
|
+
}
|