@morehao/dsh-skills-console 0.2.2 → 0.2.3
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 +17 -2
- package/lib/core/git.js +282 -0
- package/lib/core/service.js +317 -58
- package/lib/core/updates.js +92 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
66
66
|
|
|
67
67
|
## 可靠性
|
|
68
68
|
|
|
69
|
-
- **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json` 的增删改只经 `skills` CLI
|
|
69
|
+
- **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json` 的增删改只经 `skills` CLI,插件不直接写;探测 / 更新检查会把仓库临时 clone 到 `os.tmpdir()`(只读、用完即删),结果绝不落进 canonical 或 lock。
|
|
70
70
|
- **只动自己的东西**:只处理本插件创建的 `managed` 链接,以及已记账的 `managed-external`;`real` 目录和 `foreign-link` 永不改写,删除前体检直接中止。
|
|
71
71
|
- **启停不改正文**:`mode` 只增删 DSH 侧链接,避免本地修改被下一次 `add` 覆盖。
|
|
72
72
|
- **可回滚**:删除进回收站(7 天);更新前快照到 `backups/`,失败自动还原(接口另提供手动回滚)。
|
|
@@ -84,7 +84,22 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
84
84
|
| 备份 / 回收站 | `$DSH_HOME/skills-console/{backups,trash}` | 本插件 |
|
|
85
85
|
| 事件日志 | `$DSH_HOME/skills-console/dsh-skills-console.log` | 本插件(JSONL,1MB 滚动) |
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
### 环境变量
|
|
88
|
+
|
|
89
|
+
| 变量 | 默认 | 作用 |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `DSH_SKILLS_CONSOLE_HOME` | `$DSH_HOME/skills-console` | 状态、备份、回收站与事件日志的根 |
|
|
92
|
+
| `DSH_SKILLS_CONSOLE_CLI` | 自动定位 | 覆盖 `skills` CLI(测试桩也走这里) |
|
|
93
|
+
| `DSH_SKILLS_CONSOLE_NPX_SPEC` | `skills` | 需要 `npx` 兜底时用的包名 / 版本 |
|
|
94
|
+
| `DSH_SKILLS_CONSOLE_TIMEOUT_MS` | `120000` | 单次 CLI 调用超时 |
|
|
95
|
+
| `DSH_SKILLS_CONSOLE_GIT` | `auto` | 设为 `off` 关闭 git 通道,强制只走 GitHub REST |
|
|
96
|
+
| `DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS` | `60000` | git 单次操作(`ls-remote` / `clone`)超时 |
|
|
97
|
+
| `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时 |
|
|
98
|
+
|
|
99
|
+
关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403。本插件默认让探测、更新检查与 git 兜底走系统 `git`(`ls-remote` + `clone --filter=blob:none --no-checkout`),不占 REST 配额、也不需要 token;git 不可用时才回退 REST,所以原本能用的环境不会因为这条路径变坏。限流且 git 也不可用时,错误文案会给出恢复时间,并提示设置 `GITHUB_TOKEN`。
|
|
100
|
+
|
|
101
|
+
> git 通道只在 `os.tmpdir()` 里读一份临时仓库,用完即删 —— 不写 canonical、不写 CLI lock,落盘始终只交给 `skills` CLI。
|
|
102
|
+
|
|
88
103
|
|
|
89
104
|
## 开发与发布
|
|
90
105
|
|
package/lib/core/git.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 只读 git 通道:`ls-remote` 拿默认分支与 HEAD,`clone --filter=blob:none --no-checkout` +
|
|
8
|
+
* `ls-tree` 拿整树。**不占 GitHub REST 配额**,也不需要 token / gh —— 这是「出口 IP 被
|
|
9
|
+
* 匿名配额打死」时唯一还通的路。
|
|
10
|
+
*
|
|
11
|
+
* 硬约束(见 AGENTS.md「数据边界」):
|
|
12
|
+
* - 只读:只在 `os.tmpdir()` 里建临时仓库,`finally` 必删;不碰 canonical、不碰 lock;
|
|
13
|
+
* - 不接受任意 URL:只认 `https://github.com/<owner>/<repo>.git`(由 `repoUrl` 从
|
|
14
|
+
* `parseSource` 的产物重建),拒绝以 `-` 开头的 ref(防 argv 注入成选项);
|
|
15
|
+
* - 零依赖:只用 node:child_process / node:fs / node:os / node:path。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
19
|
+
const VERSION_TIMEOUT_MS = 10_000;
|
|
20
|
+
const MAX_STDOUT = 8 * 1024 * 1024;
|
|
21
|
+
const MAX_STDERR = 64 * 1024;
|
|
22
|
+
const TMP_PREFIX = 'dsh-skills-git-';
|
|
23
|
+
const REPO_URL_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\.git$/;
|
|
24
|
+
const COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
25
|
+
// 分支/tag 名:足够覆盖真实用法,同时排除以 `-` 开头(会被 git 当成选项)
|
|
26
|
+
const REF_RE = /^[\w][\w./-]{0,254}$/;
|
|
27
|
+
|
|
28
|
+
/** `kind` 供上层决定是否回退 REST:`git-missing` / `git-timeout` / `auth` / `clone-failed` / `invalid-url`。 */
|
|
29
|
+
export class GitError extends Error {
|
|
30
|
+
constructor(kind, message, detail) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'GitError';
|
|
33
|
+
this.kind = kind;
|
|
34
|
+
this.detail = detail || null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 从 `owner/repo`(`parseSource` 的产物)重建 URL,再做一次白名单断言。 */
|
|
39
|
+
export function repoUrl(source) {
|
|
40
|
+
const match = /^([\w.-]+)\/([\w.-]+)$/.exec(String(source || '').trim());
|
|
41
|
+
if (!match) throw new GitError('invalid-url', `不是 owner/repo 形态:${source}`);
|
|
42
|
+
const repo = match[2].replace(/\.git$/, '');
|
|
43
|
+
const url = `https://github.com/${match[1]}/${repo}.git`;
|
|
44
|
+
if (!REPO_URL_RE.test(url)) throw new GitError('invalid-url', `拒绝不安全的仓库地址:${url}`);
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertUrl(url) {
|
|
49
|
+
if (typeof url !== 'string' || !REPO_URL_RE.test(url)) {
|
|
50
|
+
throw new GitError('invalid-url', `拒绝不安全的仓库地址:${url}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertRef(ref) {
|
|
55
|
+
if (typeof ref !== 'string' || !(REF_RE.test(ref) || COMMIT_RE.test(ref))) {
|
|
56
|
+
throw new GitError('invalid-url', `拒绝不安全的 ref:${ref}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function withDefaults(env) {
|
|
61
|
+
return {
|
|
62
|
+
...env,
|
|
63
|
+
// 私有仓库 / 需要凭据时立刻失败,绝不卡在交互提示上
|
|
64
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
65
|
+
GIT_HTTP_LOW_SPEED_LIMIT: '1',
|
|
66
|
+
GIT_HTTP_LOW_SPEED_TIME: '30',
|
|
67
|
+
LC_ALL: 'C',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 子进程封装:无 shell、argv 直传、硬超时 SIGKILL、stdout 上限。永不 reject。 */
|
|
72
|
+
function runGit(args, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env } = {}) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let settled = false;
|
|
75
|
+
const finish = (result) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
resolve(result);
|
|
79
|
+
};
|
|
80
|
+
let child;
|
|
81
|
+
try {
|
|
82
|
+
child = spawn('git', args, {
|
|
83
|
+
cwd,
|
|
84
|
+
shell: false,
|
|
85
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
86
|
+
env: withDefaults(env),
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
finish({ spawnError: error, code: null, stdout: '', stderr: '', timedOut: false, args });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let stdout = '';
|
|
93
|
+
let stderr = '';
|
|
94
|
+
let timedOut = false;
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
timedOut = true;
|
|
97
|
+
child.kill('SIGKILL');
|
|
98
|
+
// 孙进程(git-remote-https 之类)可能还握着管道:不等它们退出,直接判超时
|
|
99
|
+
child.stdout?.destroy();
|
|
100
|
+
child.stderr?.destroy();
|
|
101
|
+
finish({ code: null, stdout, stderr, timedOut: true, args });
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
child.stdout.on('data', (chunk) => {
|
|
104
|
+
if (stdout.length >= MAX_STDOUT) return;
|
|
105
|
+
stdout += chunk.toString('utf8').slice(0, MAX_STDOUT - stdout.length);
|
|
106
|
+
});
|
|
107
|
+
child.stderr.on('data', (chunk) => {
|
|
108
|
+
if (stderr.length < MAX_STDERR) stderr += chunk.toString('utf8');
|
|
109
|
+
});
|
|
110
|
+
child.on('error', (error) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
finish({ spawnError: error, code: null, stdout, stderr, timedOut, args });
|
|
113
|
+
});
|
|
114
|
+
child.on('close', (code) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
finish({ code, stdout, stderr, timedOut, args });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 退出码非 0 → 分类。`auth` 涵盖「私有仓库 / 凭据不对 / 仓库不存在」这几种 git 都只说一句话的情况。 */
|
|
122
|
+
function toGitError(result, fallbackMessage = 'git 失败') {
|
|
123
|
+
if (result.spawnError) {
|
|
124
|
+
return new GitError('git-missing', result.spawnError.message, { code: result.spawnError.code || null });
|
|
125
|
+
}
|
|
126
|
+
if (result.timedOut) {
|
|
127
|
+
return new GitError('git-timeout', `git ${result.args?.[0] || ''} 超时`, { args: result.args || [] });
|
|
128
|
+
}
|
|
129
|
+
const stderr = String(result.stderr || '').trim();
|
|
130
|
+
if (
|
|
131
|
+
/authentication failed|could not read username|terminal prompts disabled|permission denied|repository[^\n]*not found|invalid username or password|access denied/i.test(
|
|
132
|
+
stderr,
|
|
133
|
+
)
|
|
134
|
+
) {
|
|
135
|
+
return new GitError('auth', stderr.slice(-400) || '认证失败', { code: result.code ?? null });
|
|
136
|
+
}
|
|
137
|
+
return new GitError('clone-failed', (stderr || fallbackMessage).slice(-400), { code: result.code ?? null });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `git --version` 探测。没有 git 的环境本来也装不了技能,回退 REST 即可。 */
|
|
141
|
+
export async function gitAvailable({ env = process.env, timeoutMs = VERSION_TIMEOUT_MS } = {}) {
|
|
142
|
+
const result = await runGit(['--version'], { env, timeoutMs });
|
|
143
|
+
if (result.spawnError || result.timedOut || result.code !== 0) return false;
|
|
144
|
+
return /git version \d+/.test(result.stdout);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `git ls-remote --symref <url> HEAD` → `{ branch, commit }`。零配额拿默认分支 + HEAD。 */
|
|
148
|
+
export async function lsRemote({ url, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
149
|
+
assertUrl(url);
|
|
150
|
+
const result = await runGit(['ls-remote', '--symref', url, 'HEAD'], { env, timeoutMs });
|
|
151
|
+
if (result.spawnError || result.timedOut || result.code !== 0) throw toGitError(result, 'ls-remote 失败');
|
|
152
|
+
let branch = null;
|
|
153
|
+
let commit = null;
|
|
154
|
+
for (const raw of result.stdout.split('\n')) {
|
|
155
|
+
const line = raw.trim();
|
|
156
|
+
if (!line) continue;
|
|
157
|
+
if (line.startsWith('ref:')) {
|
|
158
|
+
const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(line);
|
|
159
|
+
if (symref) branch = symref[1];
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const head = /^([0-9a-f]{40})\s+HEAD$/.exec(line);
|
|
163
|
+
if (head) commit = head[1];
|
|
164
|
+
}
|
|
165
|
+
if (!commit) throw new GitError('clone-failed', 'ls-remote 没有返回 HEAD 指向的 commit');
|
|
166
|
+
return { branch, commit };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `git ls-tree -r -t -z` 的输出 → `[{ type, path, sha }]`,与 Trees API 同形。 */
|
|
170
|
+
export function parseTreeEntries(stdout) {
|
|
171
|
+
const entries = [];
|
|
172
|
+
for (const record of String(stdout || '').split('\0')) {
|
|
173
|
+
if (!record) continue;
|
|
174
|
+
// 默认格式:`<mode> <type> <sha>\t<path>`;路径里的空格/中文都靠 tab 定位,不会被切错
|
|
175
|
+
const tab = record.indexOf('\t');
|
|
176
|
+
if (tab < 0) continue;
|
|
177
|
+
const meta = record.slice(0, tab).split(' ');
|
|
178
|
+
const type = meta[1];
|
|
179
|
+
if (type !== 'blob' && type !== 'tree') continue;
|
|
180
|
+
entries.push({ type, path: record.slice(tab + 1), sha: meta[2] });
|
|
181
|
+
}
|
|
182
|
+
return entries;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `git clone --depth 1 --filter=blob:none --no-checkout` + `ls-tree` → `{ entries, rootSha, commit }`。
|
|
187
|
+
* 临时目录用完必删;容器型 `<ref>`(commit SHA)走 fetch,分支/tag 走 `--branch`。
|
|
188
|
+
* `tmpRoot` 只是给测试一个隔离的临时目录根,生产走 `os.tmpdir()`。
|
|
189
|
+
*/
|
|
190
|
+
export async function lsTree({ url, ref = 'HEAD', env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, tmpRoot = os.tmpdir() } = {}) {
|
|
191
|
+
assertUrl(url);
|
|
192
|
+
assertRef(ref);
|
|
193
|
+
const isCommit = COMMIT_RE.test(ref);
|
|
194
|
+
fs.mkdirSync(tmpRoot, { recursive: true });
|
|
195
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, TMP_PREFIX));
|
|
196
|
+
const base = ['clone', '--depth', '1', '--no-checkout', '--quiet'];
|
|
197
|
+
const cloneArgs = [...base, '--filter=blob:none'];
|
|
198
|
+
if (!isCommit) cloneArgs.push('--branch', ref);
|
|
199
|
+
cloneArgs.push(url, dir);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
let cloned = await runGit(cloneArgs, { env, timeoutMs });
|
|
203
|
+
// 老 git(< 2.19)不认 --filter:去掉再试一次(慢一点,但能用)
|
|
204
|
+
if ((cloned.spawnError || cloned.timedOut || cloned.code !== 0) && /filter/i.test(cloned.stderr || '')) {
|
|
205
|
+
const retry = [...base];
|
|
206
|
+
if (!isCommit) retry.push('--branch', ref);
|
|
207
|
+
retry.push(url, dir);
|
|
208
|
+
cloned = await runGit(retry, { env, timeoutMs });
|
|
209
|
+
}
|
|
210
|
+
if (cloned.spawnError || cloned.timedOut || cloned.code !== 0) throw toGitError(cloned, 'clone 失败');
|
|
211
|
+
|
|
212
|
+
// HEAD 对应的 commit:分支/tag 克隆时是 HEAD,commit 形态直接用它自己
|
|
213
|
+
const revTarget = isCommit ? `${ref}^{commit}` : 'HEAD^{commit}';
|
|
214
|
+
let rev = await runGit(['-C', dir, 'rev-parse', '--verify', revTarget], { env, timeoutMs });
|
|
215
|
+
if (rev.code !== 0 && isCommit) {
|
|
216
|
+
// 默认分支的浅克隆里可能没有这个 commit:单独取一次
|
|
217
|
+
await runGit(['-C', dir, 'fetch', '--depth', '1', '--quiet', url, ref], { env, timeoutMs });
|
|
218
|
+
rev = await runGit(['-C', dir, 'rev-parse', '--verify', revTarget], { env, timeoutMs });
|
|
219
|
+
}
|
|
220
|
+
const commit = rev.code === 0 ? rev.stdout.trim() : '';
|
|
221
|
+
if (!COMMIT_RE.test(commit)) throw toGitError(rev, 'rev-parse 拿不到 commit');
|
|
222
|
+
|
|
223
|
+
const treeRev = await runGit(['-C', dir, 'rev-parse', '--verify', `${commit}^{tree}`], { env, timeoutMs });
|
|
224
|
+
const rootSha = treeRev.code === 0 ? treeRev.stdout.trim() : '';
|
|
225
|
+
if (!COMMIT_RE.test(rootSha)) throw toGitError(treeRev, 'rev-parse 拿不到根 tree');
|
|
226
|
+
|
|
227
|
+
const listed = await runGit(['-C', dir, 'ls-tree', '-r', '-t', '-z', commit], { env, timeoutMs });
|
|
228
|
+
if (listed.code !== 0) throw toGitError(listed, 'ls-tree 失败');
|
|
229
|
+
return { entries: parseTreeEntries(listed.stdout), rootSha, commit };
|
|
230
|
+
} finally {
|
|
231
|
+
if (dir.includes(TMP_PREFIX)) fs.rmSync(dir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 服务实例用的封装:进程内缓存 `git --version` 结论与 `(url, ref)` 的整树
|
|
237
|
+
* (一个仓库装多个技能时只 clone 一次),并统一读 `DSH_SKILLS_CONSOLE_GIT*`。
|
|
238
|
+
*/
|
|
239
|
+
export function createGit({ env = process.env, logger = () => {} } = {}) {
|
|
240
|
+
const TREE_TTL_MS = 60_000;
|
|
241
|
+
const TREE_MAX = 20;
|
|
242
|
+
const trees = new Map();
|
|
243
|
+
let available = null;
|
|
244
|
+
|
|
245
|
+
const enabled = () => String(env.DSH_SKILLS_CONSOLE_GIT || '').trim().toLowerCase() !== 'off';
|
|
246
|
+
const timeoutMs = () => {
|
|
247
|
+
const value = Number(env.DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS);
|
|
248
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_MS;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
async function isAvailable() {
|
|
252
|
+
if (!enabled()) return false;
|
|
253
|
+
if (available === null) available = await gitAvailable({ env, timeoutMs: Math.min(timeoutMs(), VERSION_TIMEOUT_MS) });
|
|
254
|
+
if (!available) logger('git.missing');
|
|
255
|
+
return available;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function tree({ url, ref }) {
|
|
259
|
+
const key = `${url}@${ref}`;
|
|
260
|
+
const cached = trees.get(key);
|
|
261
|
+
if (cached && Date.now() - cached.at < TREE_TTL_MS) return cached.value;
|
|
262
|
+
const value = await lsTree({ url, ref, env, timeoutMs: timeoutMs() });
|
|
263
|
+
trees.set(key, { value, at: Date.now() });
|
|
264
|
+
for (const oldest of trees.keys()) {
|
|
265
|
+
if (trees.size <= TREE_MAX) break;
|
|
266
|
+
trees.delete(oldest);
|
|
267
|
+
}
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
enabled,
|
|
273
|
+
isAvailable,
|
|
274
|
+
timeoutMs,
|
|
275
|
+
url: repoUrl,
|
|
276
|
+
lsRemote: (options = {}) => lsRemote({ ...options, env, timeoutMs: options.timeoutMs || timeoutMs() }),
|
|
277
|
+
tree,
|
|
278
|
+
clearCache() {
|
|
279
|
+
trees.clear();
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
package/lib/core/service.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { StateReadOnlyError } from './state.js';
|
|
4
4
|
import { contentHash } from './hash.js';
|
|
5
|
+
import { createGit } from './git.js';
|
|
5
6
|
import { ensureLink, inspectTarget, removeLink } from './links.js';
|
|
6
7
|
import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
7
8
|
import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
checkSkill,
|
|
10
11
|
fetchDefaultBranch,
|
|
11
12
|
fetchTree,
|
|
13
|
+
hashShape,
|
|
12
14
|
listSkillsFromTree,
|
|
13
15
|
parseSource,
|
|
14
16
|
planToken,
|
|
@@ -31,6 +33,41 @@ function nowIso() {
|
|
|
31
33
|
return new Date().toISOString();
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
|
|
37
|
+
function formatClock(ms) {
|
|
38
|
+
const at = new Date(ms);
|
|
39
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
40
|
+
return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 上游失败 → 可执行错误(T1)。四类必须互不相同,否则用户只知道「读不到」,
|
|
45
|
+
* 不知道该等、该改来源,还是该查网络。
|
|
46
|
+
*/
|
|
47
|
+
function upstreamError(source, failure) {
|
|
48
|
+
const status = failure?.status ?? null;
|
|
49
|
+
const resetAt = failure?.resetAt ?? null;
|
|
50
|
+
const detail = { source, status, resetAt, error: failure?.error ?? null };
|
|
51
|
+
if (failure?.rateLimited) {
|
|
52
|
+
const resume = resetAt ? `约 ${formatClock(resetAt)} 后自动恢复;` : '';
|
|
53
|
+
return new ServiceError(
|
|
54
|
+
'error.skills.rate-limited',
|
|
55
|
+
`GitHub 接口已限流(匿名 60 次/小时,按出口 IP 共享)。${resume}设置 GITHUB_TOKEN 可提到 5000 次/小时`,
|
|
56
|
+
detail,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (status === 404) {
|
|
60
|
+
return new ServiceError('error.skills.repo-not-found', `读不到 ${source}:仓库不存在或为私有`, detail);
|
|
61
|
+
}
|
|
62
|
+
if (status === 401 || status === 403) {
|
|
63
|
+
return new ServiceError('error.skills.repo-forbidden', `读取 ${source} 被拒绝(HTTP ${status})`, detail);
|
|
64
|
+
}
|
|
65
|
+
if (status) {
|
|
66
|
+
return new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:HTTP ${status}`, detail);
|
|
67
|
+
}
|
|
68
|
+
return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
/** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
|
|
35
72
|
function installedNamesFrom(stdout) {
|
|
36
73
|
try {
|
|
@@ -141,6 +178,7 @@ function createQueue(logger) {
|
|
|
141
178
|
export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
|
|
142
179
|
const enqueue = createQueue(logger);
|
|
143
180
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
181
|
+
const git = createGit({ env, logger });
|
|
144
182
|
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
145
183
|
const probeCache = new Map();
|
|
146
184
|
const PROBE_TTL_MS = 60_000;
|
|
@@ -203,44 +241,51 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
203
241
|
return { created, skipped, failed };
|
|
204
242
|
}
|
|
205
243
|
|
|
206
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。
|
|
246
|
+
* 返回 `{ branch, failure }`:`failure` 原样透出 `fetchDefaultBranch` 的结构化失败,
|
|
247
|
+
* 由 `readSnapshot` 决定报哪种错误(T1)。
|
|
248
|
+
*/
|
|
207
249
|
async function branchOf(subscription, { persist = false } = {}) {
|
|
208
|
-
if (subscription.ref) return subscription.ref;
|
|
209
|
-
let
|
|
250
|
+
if (subscription.ref) return { branch: subscription.ref, failure: null };
|
|
251
|
+
let resolved;
|
|
210
252
|
try {
|
|
211
|
-
|
|
253
|
+
resolved = await fetchDefaultBranch({
|
|
212
254
|
source: subscription.source,
|
|
213
255
|
token: tokenOf,
|
|
214
256
|
fetchImpl,
|
|
215
257
|
});
|
|
216
|
-
} catch {
|
|
217
|
-
branch
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return { branch: null, failure: { status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) } };
|
|
218
260
|
}
|
|
219
|
-
|
|
261
|
+
const branch = resolved?.branch || null;
|
|
262
|
+
if (!branch) return { branch: null, failure: resolved || null };
|
|
220
263
|
if (persist && !store.readOnly) {
|
|
221
264
|
store.update((draft) => {
|
|
222
265
|
const row = draft.subscriptions.find((item) => item.id === subscription.id);
|
|
223
266
|
if (row && !row.ref) row.ref = branch;
|
|
224
267
|
});
|
|
225
268
|
}
|
|
226
|
-
return branch;
|
|
269
|
+
return { branch, failure: null };
|
|
227
270
|
}
|
|
228
271
|
|
|
229
272
|
/**
|
|
230
|
-
* 读一次订阅快照:只有网络与纯计算,**不写 store
|
|
273
|
+
* 读一次订阅快照:只有网络与纯计算,**不写 store**(`persistRef` 例外,只补订阅的 ref)。
|
|
231
274
|
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
232
275
|
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
276
|
+
*
|
|
277
|
+
* T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
|
|
233
278
|
*/
|
|
234
|
-
async function readSnapshot(subscription, { force = false, withCommit = true } = {}) {
|
|
279
|
+
async function readSnapshot(subscription, { force = false, withCommit = true, persistRef = false } = {}) {
|
|
235
280
|
const source = subscription.source;
|
|
236
281
|
try {
|
|
237
|
-
const branch = await branchOf(subscription);
|
|
282
|
+
const { branch, failure } = await branchOf(subscription, { persist: persistRef });
|
|
238
283
|
if (!branch) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
284
|
+
if (failure?.rateLimited || failure?.status === 404) {
|
|
285
|
+
const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
|
|
286
|
+
if (viaGit) return viaGit;
|
|
287
|
+
}
|
|
288
|
+
throw upstreamError(source, failure);
|
|
244
289
|
}
|
|
245
290
|
const result = await fetchTree({
|
|
246
291
|
source,
|
|
@@ -250,6 +295,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
250
295
|
fetchImpl,
|
|
251
296
|
});
|
|
252
297
|
if (result.error) {
|
|
298
|
+
// 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
|
|
299
|
+
if (result.rateLimited) {
|
|
300
|
+
const viaGit = await gitSnapshot(subscription, { ref: branch });
|
|
301
|
+
if (viaGit) return viaGit;
|
|
302
|
+
throw upstreamError(source, {
|
|
303
|
+
status: result.status ?? null,
|
|
304
|
+
rateLimited: true,
|
|
305
|
+
resetAt: result.resetAt ?? null,
|
|
306
|
+
error: result.error,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
253
309
|
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
254
310
|
source,
|
|
255
311
|
ref: branch,
|
|
@@ -277,6 +333,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
277
333
|
return {
|
|
278
334
|
branch,
|
|
279
335
|
changed,
|
|
336
|
+
via: 'rest',
|
|
337
|
+
tree: result.tree,
|
|
338
|
+
rootSha: result.rootSha,
|
|
280
339
|
snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
|
|
281
340
|
};
|
|
282
341
|
} catch (error) {
|
|
@@ -287,8 +346,162 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
287
346
|
}
|
|
288
347
|
}
|
|
289
348
|
|
|
290
|
-
|
|
291
|
-
|
|
349
|
+
/**
|
|
350
|
+
* T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
|
|
351
|
+
* 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
|
|
352
|
+
*/
|
|
353
|
+
async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
|
|
354
|
+
const source = subscription.source;
|
|
355
|
+
const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
|
|
356
|
+
if (!git.enabled()) return null;
|
|
357
|
+
const startedAt = Date.now();
|
|
358
|
+
let url;
|
|
359
|
+
try {
|
|
360
|
+
url = git.url(source);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
if (!(await git.isAvailable())) {
|
|
366
|
+
logger('git.fallback', { source, kind: 'git-missing', purpose });
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
let branch = ref || null;
|
|
371
|
+
if (!branch) {
|
|
372
|
+
const head = await git.lsRemote({ url });
|
|
373
|
+
branch = head.branch || 'main';
|
|
374
|
+
}
|
|
375
|
+
const tree = await git.tree({ url, ref: branch });
|
|
376
|
+
const entries = listSkillsFromTree({
|
|
377
|
+
tree: tree.entries,
|
|
378
|
+
rootSha: tree.rootSha,
|
|
379
|
+
subpath: subscription.subpath,
|
|
380
|
+
repoName: source.split('/').pop(),
|
|
381
|
+
});
|
|
382
|
+
const changed =
|
|
383
|
+
!subscription.snapshot ||
|
|
384
|
+
subscription.snapshot.rootSha !== tree.rootSha ||
|
|
385
|
+
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
386
|
+
logger(event, {
|
|
387
|
+
source,
|
|
388
|
+
ref: branch,
|
|
389
|
+
commit: tree.commit,
|
|
390
|
+
entries: entries.length,
|
|
391
|
+
changed,
|
|
392
|
+
ms: Date.now() - startedAt,
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
branch,
|
|
396
|
+
changed,
|
|
397
|
+
via: 'git',
|
|
398
|
+
tree: tree.entries,
|
|
399
|
+
rootSha: tree.rootSha,
|
|
400
|
+
snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
logger('git.fallback', {
|
|
404
|
+
source,
|
|
405
|
+
kind: error.kind || 'clone-failed',
|
|
406
|
+
message: error.message,
|
|
407
|
+
purpose,
|
|
408
|
+
ms: Date.now() - startedAt,
|
|
409
|
+
});
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
|
|
416
|
+
* 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
|
|
417
|
+
*/
|
|
418
|
+
async function remoteCommitUnchanged(subscription) {
|
|
419
|
+
const commit = subscription.snapshot?.commit;
|
|
420
|
+
if (!commit || !git.enabled()) return false;
|
|
421
|
+
if (!(await git.isAvailable())) return false;
|
|
422
|
+
try {
|
|
423
|
+
const head = await git.lsRemote({ url: git.url(subscription.source) });
|
|
424
|
+
if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
|
|
425
|
+
return head.commit === commit;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
logger('git.fallback', {
|
|
428
|
+
source: subscription.source,
|
|
429
|
+
kind: error.kind || 'clone-failed',
|
|
430
|
+
purpose: 'refresh',
|
|
431
|
+
});
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
|
|
438
|
+
* 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
|
|
439
|
+
* 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
|
|
440
|
+
*/
|
|
441
|
+
async function probeViaGit({ source, ref, subpath }) {
|
|
442
|
+
const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
|
|
443
|
+
if (!result) return null;
|
|
444
|
+
return { branch: result.branch, via: 'git', snapshot: result.snapshot };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
|
|
449
|
+
* 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
|
|
450
|
+
* `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
|
|
451
|
+
*
|
|
452
|
+
* best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
|
|
453
|
+
*/
|
|
454
|
+
async function backfillBaselines({ names, source, lock, ref }) {
|
|
455
|
+
if (!git.enabled() || names.length === 0) return [];
|
|
456
|
+
const targets = [];
|
|
457
|
+
for (const name of names) {
|
|
458
|
+
const lockEntry = lock.skills?.[name];
|
|
459
|
+
if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
|
|
460
|
+
const install = store.findInstall(name);
|
|
461
|
+
if (!install || install.treeSha) continue;
|
|
462
|
+
targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
|
|
463
|
+
}
|
|
464
|
+
if (targets.length === 0) return [];
|
|
465
|
+
if (!(await git.isAvailable())) {
|
|
466
|
+
logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const startedAt = Date.now();
|
|
470
|
+
try {
|
|
471
|
+
const url = git.url(source);
|
|
472
|
+
let useRef = ref || null;
|
|
473
|
+
if (!useRef) useRef = (await git.lsRemote({ url })).commit;
|
|
474
|
+
// 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
|
|
475
|
+
const tree = await git.tree({ url, ref: useRef });
|
|
476
|
+
const updated = [];
|
|
477
|
+
for (const target of targets) {
|
|
478
|
+
const treeSha = target.skillPath
|
|
479
|
+
? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
|
|
480
|
+
: null;
|
|
481
|
+
if (!treeSha) {
|
|
482
|
+
logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
store.update((draft) => {
|
|
486
|
+
const row = draft.installs.find((item) => item.name === target.name);
|
|
487
|
+
if (row && !row.treeSha) row.treeSha = treeSha;
|
|
488
|
+
});
|
|
489
|
+
updated.push({ name: target.name, treeSha });
|
|
490
|
+
}
|
|
491
|
+
if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
|
|
492
|
+
return updated;
|
|
493
|
+
} catch (error) {
|
|
494
|
+
logger('git.error', {
|
|
495
|
+
source,
|
|
496
|
+
kind: error.kind || 'clone-failed',
|
|
497
|
+
message: error.message,
|
|
498
|
+
purpose: 'baseline',
|
|
499
|
+
});
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
|
|
292
505
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
293
506
|
if (existing) return;
|
|
294
507
|
if (!created) return;
|
|
@@ -369,6 +582,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
369
582
|
skillPath: record.skillPath,
|
|
370
583
|
ref: record.ref,
|
|
371
584
|
commit: null,
|
|
585
|
+
treeSha: null,
|
|
372
586
|
installedHash: record.installedHash,
|
|
373
587
|
installedAt: nowIso(),
|
|
374
588
|
updatedAt: nowIso(),
|
|
@@ -667,12 +881,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
667
881
|
warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
|
|
668
882
|
}
|
|
669
883
|
const expected = snapshot?.entries?.find((entry) => entry.name === name);
|
|
670
|
-
|
|
884
|
+
const lockHash = lockEntry?.skillFolderHash || null;
|
|
885
|
+
const shape = hashShape(lockHash);
|
|
886
|
+
// 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
|
|
887
|
+
// (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
|
|
888
|
+
if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
|
|
671
889
|
warnings.push({
|
|
672
890
|
name,
|
|
673
891
|
warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
|
|
674
892
|
expected: expected.treeSha,
|
|
675
|
-
actual:
|
|
893
|
+
actual: lockHash,
|
|
676
894
|
});
|
|
677
895
|
}
|
|
678
896
|
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
@@ -682,6 +900,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
682
900
|
skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
|
|
683
901
|
ref: pin,
|
|
684
902
|
commit: snapshot?.commit || null,
|
|
903
|
+
// 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
|
|
904
|
+
treeSha: expected?.treeSha || null,
|
|
685
905
|
installedHash,
|
|
686
906
|
installedAt: nowIso(),
|
|
687
907
|
updatedAt: nowIso(),
|
|
@@ -694,7 +914,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
694
914
|
if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
|
|
695
915
|
else draft.installs.push(record);
|
|
696
916
|
});
|
|
697
|
-
installed.push({ name, lockHash:
|
|
917
|
+
installed.push({ name, lockHash, lockHashShape: shape, installedHash });
|
|
698
918
|
}
|
|
699
919
|
|
|
700
920
|
const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
|
|
@@ -706,6 +926,14 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
706
926
|
linkResult.failed.push(...one.failed);
|
|
707
927
|
}
|
|
708
928
|
|
|
929
|
+
// T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
|
|
930
|
+
// 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
|
|
931
|
+
const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
|
|
932
|
+
for (const item of installed) {
|
|
933
|
+
const filled = baselines.find((row) => row.name === item.name);
|
|
934
|
+
if (filled) item.treeSha = filled.treeSha;
|
|
935
|
+
}
|
|
936
|
+
|
|
709
937
|
logger('install', { source: sourceArg, skills: names, installed: installed.length });
|
|
710
938
|
return {
|
|
711
939
|
ok: true,
|
|
@@ -793,19 +1021,30 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
793
1021
|
if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
|
|
794
1022
|
return cached.pending || cached.value;
|
|
795
1023
|
}
|
|
796
|
-
const pending =
|
|
797
|
-
|
|
798
|
-
|
|
1024
|
+
const pending = (async () => {
|
|
1025
|
+
// 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
|
|
1026
|
+
const viaGit = await probeViaGit({
|
|
799
1027
|
source: canonicalSource,
|
|
800
1028
|
ref: requestedRef,
|
|
801
1029
|
subpath: requestedSubpath,
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1030
|
+
});
|
|
1031
|
+
const probe =
|
|
1032
|
+
viaGit ||
|
|
1033
|
+
(await readSnapshot(
|
|
1034
|
+
{
|
|
1035
|
+
id: null,
|
|
1036
|
+
source: canonicalSource,
|
|
1037
|
+
ref: requestedRef,
|
|
1038
|
+
subpath: requestedSubpath,
|
|
1039
|
+
snapshot: null,
|
|
1040
|
+
},
|
|
1041
|
+
{ force: true, withCommit: false },
|
|
1042
|
+
));
|
|
1043
|
+
// 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
|
|
1044
|
+
const enrichRef = probe.snapshot?.commit || probe.branch;
|
|
806
1045
|
const enriched = await enrichSkillEntries({
|
|
807
1046
|
source: canonicalSource,
|
|
808
|
-
ref:
|
|
1047
|
+
ref: enrichRef,
|
|
809
1048
|
entries: probe.snapshot.entries,
|
|
810
1049
|
token: tokenOf,
|
|
811
1050
|
fetchImpl,
|
|
@@ -817,10 +1056,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
817
1056
|
ok: true,
|
|
818
1057
|
source: canonicalSource,
|
|
819
1058
|
ref: probe.branch,
|
|
1059
|
+
via: probe.via || 'rest',
|
|
820
1060
|
skills,
|
|
821
1061
|
skipped: enriched.length - skills.length,
|
|
822
1062
|
};
|
|
823
|
-
});
|
|
1063
|
+
})();
|
|
824
1064
|
probeCache.set(key, { pending, at: Date.now() });
|
|
825
1065
|
try {
|
|
826
1066
|
const value = await pending;
|
|
@@ -930,6 +1170,22 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
930
1170
|
assertWritable();
|
|
931
1171
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
932
1172
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
1173
|
+
// T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
|
|
1174
|
+
if (await remoteCommitUnchanged(subscription)) {
|
|
1175
|
+
const at = nowIso();
|
|
1176
|
+
store.update((draft) => {
|
|
1177
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
1178
|
+
if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
1179
|
+
});
|
|
1180
|
+
logger('subscription.refresh', { id, notModified: true, via: 'git' });
|
|
1181
|
+
return {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
changed: false,
|
|
1184
|
+
notModified: true,
|
|
1185
|
+
via: 'git',
|
|
1186
|
+
subscription: state().subscriptions.find((item) => item.id === id),
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
933
1189
|
let probe;
|
|
934
1190
|
try {
|
|
935
1191
|
probe = await readSnapshot(subscription, { force });
|
|
@@ -961,6 +1217,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
961
1217
|
ok: true,
|
|
962
1218
|
changed: !probe.notModified && probe.changed,
|
|
963
1219
|
...(probe.notModified ? { notModified: true } : {}),
|
|
1220
|
+
via: probe.via || 'rest',
|
|
964
1221
|
subscription: state().subscriptions.find((item) => item.id === id),
|
|
965
1222
|
};
|
|
966
1223
|
},
|
|
@@ -983,22 +1240,24 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
983
1240
|
|
|
984
1241
|
const results = [];
|
|
985
1242
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
token: tokenOf,
|
|
993
|
-
fetchImpl,
|
|
994
|
-
});
|
|
995
|
-
if (tree.error) {
|
|
1243
|
+
// T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
|
|
1244
|
+
// withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
|
|
1245
|
+
let probe;
|
|
1246
|
+
try {
|
|
1247
|
+
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
|
|
1248
|
+
} catch (error) {
|
|
996
1249
|
for (const install of groupInstalls) {
|
|
997
|
-
results.push({
|
|
1250
|
+
results.push({
|
|
1251
|
+
name: install.name,
|
|
1252
|
+
status: 'unknown',
|
|
1253
|
+
detail: error.message,
|
|
1254
|
+
code: error.code || null,
|
|
1255
|
+
checkedAt: nowIso(),
|
|
1256
|
+
});
|
|
998
1257
|
}
|
|
999
1258
|
continue;
|
|
1000
1259
|
}
|
|
1001
|
-
if (
|
|
1260
|
+
if (probe.notModified) {
|
|
1002
1261
|
for (const install of groupInstalls) {
|
|
1003
1262
|
const previousCheck = state().checks?.[install.name];
|
|
1004
1263
|
results.push({
|
|
@@ -1012,16 +1271,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1012
1271
|
}
|
|
1013
1272
|
for (const install of groupInstalls) {
|
|
1014
1273
|
const skillPath = lock.skills?.[install.name]?.skillPath || install.skillPath;
|
|
1015
|
-
const entry =
|
|
1274
|
+
const entry = probe.tree.some((item) => item.type === 'blob' && item.path === skillPath)
|
|
1016
1275
|
? { skillPath }
|
|
1017
1276
|
: null;
|
|
1018
1277
|
const verdict = checkSkill({
|
|
1019
1278
|
lockHash: lock.skills?.[install.name]?.skillFolderHash || null,
|
|
1279
|
+
recordedTreeSha: install.treeSha ?? null,
|
|
1020
1280
|
snapshotEntry: entry,
|
|
1021
|
-
tree:
|
|
1022
|
-
rootSha:
|
|
1281
|
+
tree: probe.tree,
|
|
1282
|
+
rootSha: probe.rootSha,
|
|
1023
1283
|
});
|
|
1024
|
-
results.push({ name: install.name, ...verdict, checkedAt: nowIso() });
|
|
1284
|
+
results.push({ name: install.name, ...verdict, via: probe.via, checkedAt: nowIso() });
|
|
1025
1285
|
}
|
|
1026
1286
|
}
|
|
1027
1287
|
|
|
@@ -1049,20 +1309,15 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1049
1309
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1050
1310
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
1051
1311
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
ref,
|
|
1056
|
-
token: tokenOf,
|
|
1057
|
-
fetchImpl,
|
|
1058
|
-
});
|
|
1059
|
-
if (tree.error) throw new ServiceError('error.skills.upstream-failed', tree.error);
|
|
1312
|
+
// 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
|
|
1313
|
+
const probe = await readSnapshot(subscription, { force: true });
|
|
1314
|
+
const ref = probe.branch;
|
|
1060
1315
|
const lock = readLock(paths);
|
|
1061
1316
|
const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
|
|
1062
|
-
const entry =
|
|
1317
|
+
const entry = probe.tree.find((item) => item.type === 'blob' && item.path === skillPath);
|
|
1063
1318
|
if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
|
|
1064
|
-
const targetTreeSha = skillTreeSha({ tree:
|
|
1065
|
-
const targetCommit =
|
|
1319
|
+
const targetTreeSha = skillTreeSha({ tree: probe.tree, skillPath, rootSha: probe.rootSha });
|
|
1320
|
+
const targetCommit = probe.snapshot?.commit ?? null;
|
|
1066
1321
|
const token = planToken({
|
|
1067
1322
|
subscriptionId: subscription.id,
|
|
1068
1323
|
from: install.commit || null,
|
|
@@ -1131,9 +1386,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1131
1386
|
const row = draft.installs.find((item) => item.name === name);
|
|
1132
1387
|
if (!row) return;
|
|
1133
1388
|
row.commit = plan.toCommit;
|
|
1389
|
+
// 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
|
|
1390
|
+
row.treeSha = plan.targetTreeSha || row.treeSha || null;
|
|
1134
1391
|
row.installedHash = installedHash;
|
|
1135
1392
|
row.updatedAt = nowIso();
|
|
1136
|
-
row.backup = backup ? { key: backup.key, hash: install.installedHash || null } : row.backup;
|
|
1393
|
+
row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
|
|
1137
1394
|
draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
|
|
1138
1395
|
});
|
|
1139
1396
|
|
|
@@ -1160,6 +1417,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1160
1417
|
row.installedHash = installedHash;
|
|
1161
1418
|
row.updatedAt = nowIso();
|
|
1162
1419
|
row.commit = null;
|
|
1420
|
+
// 回滚回去的是备份那一刻的内容,tree 基线也得跟着退回去(不知道就置空,绝不沿用新版)
|
|
1421
|
+
row.treeSha = install.backup.treeSha ?? null;
|
|
1163
1422
|
draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
|
|
1164
1423
|
});
|
|
1165
1424
|
logger('updates.rollback', { name, backup: install.backup.key });
|
package/lib/core/updates.js
CHANGED
|
@@ -25,6 +25,27 @@ export function githubToken(env = process.env) {
|
|
|
25
25
|
return env.GITHUB_TOKEN || env.GH_TOKEN || null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 限流相关响应头。GitHub 对匿名请求按**出口 IP** 记 60 次/小时,403 时这三个头是
|
|
30
|
+
* 用户唯一能拿到的可执行信息(还剩多少、什么时候恢复、要不要等)。
|
|
31
|
+
*/
|
|
32
|
+
function rateLimitOf(response) {
|
|
33
|
+
const read = (name) => {
|
|
34
|
+
const value = response?.headers?.get?.(name);
|
|
35
|
+
return value === null || value === undefined || value === '' ? null : String(value);
|
|
36
|
+
};
|
|
37
|
+
const reset = read('x-ratelimit-reset');
|
|
38
|
+
const retryAfter = read('retry-after');
|
|
39
|
+
const resetAt = reset && /^\d+$/.test(reset) ? Number(reset) * 1000 : null;
|
|
40
|
+
return { remaining: read('x-ratelimit-remaining'), resetAt, retryAfter };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 403 + 剩余 0,或 429 —— 与上游判定一致(blob.ts:147-152)。 */
|
|
44
|
+
export function isRateLimited(status, rateLimit) {
|
|
45
|
+
if (status === 429) return true;
|
|
46
|
+
return status === 403 && rateLimit?.remaining === '0';
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
|
|
29
50
|
const headers = {
|
|
30
51
|
accept: 'application/vnd.github+json',
|
|
@@ -34,13 +55,14 @@ async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
|
|
|
34
55
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
35
56
|
if (etag) headers['if-none-match'] = etag;
|
|
36
57
|
const response = await fetchImpl(url, { headers });
|
|
37
|
-
|
|
58
|
+
const rateLimit = rateLimitOf(response);
|
|
59
|
+
if (response.status === 304) return { status: 304, notModified: true, etag, rateLimit };
|
|
38
60
|
if (!response.ok) {
|
|
39
61
|
const text = await response.text().catch(() => '');
|
|
40
|
-
return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}
|
|
62
|
+
return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}`, rateLimit };
|
|
41
63
|
}
|
|
42
64
|
const body = await response.json();
|
|
43
|
-
return { status: response.status, body, etag: response.headers.get('etag') };
|
|
65
|
+
return { status: response.status, body, etag: response.headers.get('etag'), rateLimit };
|
|
44
66
|
}
|
|
45
67
|
|
|
46
68
|
/**
|
|
@@ -53,7 +75,15 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
|
|
|
53
75
|
const url = `${API}/repos/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
|
54
76
|
const result = await callJson(url, { token, etag, fetchImpl });
|
|
55
77
|
if (result.notModified) return { notModified: true, etag: result.etag };
|
|
56
|
-
if (result.error)
|
|
78
|
+
if (result.error) {
|
|
79
|
+
return {
|
|
80
|
+
error: result.error,
|
|
81
|
+
status: result.status,
|
|
82
|
+
rateLimited: isRateLimited(result.status, result.rateLimit),
|
|
83
|
+
resetAt: result.rateLimit?.resetAt ?? null,
|
|
84
|
+
rateLimit: result.rateLimit,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
57
87
|
const tree = Array.isArray(result.body?.tree) ? result.body.tree : [];
|
|
58
88
|
return {
|
|
59
89
|
tree,
|
|
@@ -67,13 +97,28 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
|
|
|
67
97
|
/**
|
|
68
98
|
* 仓库默认分支。`ref` 缺省时以它为准:不少技能仓库默认是 `master`,
|
|
69
99
|
* 硬套 `main` 只会得到 404(例如 alchaincyf/huashu-design)。
|
|
100
|
+
*
|
|
101
|
+
* 返回结构化结果而不是 `string | null`:调用方要靠 `status` / `rateLimited` / `resetAt`
|
|
102
|
+
* 把「仓库不存在」「被限流」「网络不通」拆成三句不同的话(T1)。
|
|
70
103
|
*/
|
|
71
104
|
export async function fetchDefaultBranch({ source, token, fetchImpl } = {}) {
|
|
72
105
|
const parsed = parseSource(source);
|
|
73
|
-
if (!parsed)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
if (!parsed) {
|
|
107
|
+
return { branch: null, status: null, rateLimited: false, resetAt: null, error: 'invalid-source' };
|
|
108
|
+
}
|
|
109
|
+
let result;
|
|
110
|
+
try {
|
|
111
|
+
result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}`, { token, fetchImpl });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { branch: null, status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) };
|
|
114
|
+
}
|
|
115
|
+
const rateLimit = result.rateLimit || {};
|
|
116
|
+
const rateLimited = isRateLimited(result.status, rateLimit);
|
|
117
|
+
const base = { status: result.status ?? null, rateLimited, resetAt: rateLimit.resetAt ?? null };
|
|
118
|
+
if (result.error) return { ...base, branch: null, error: result.error };
|
|
119
|
+
const branch = result.body?.default_branch;
|
|
120
|
+
if (!branch) return { ...base, branch: null, error: 'response has no default_branch' };
|
|
121
|
+
return { ...base, branch, error: null };
|
|
77
122
|
}
|
|
78
123
|
|
|
79
124
|
/** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
|
|
@@ -88,6 +133,18 @@ export async function resolveCommit({ source, ref = 'main', token, fetchImpl } =
|
|
|
88
133
|
return result.body.sha;
|
|
89
134
|
}
|
|
90
135
|
|
|
136
|
+
const TREE_SHA_RE = /^[0-9a-f]{40}$/;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* lock 的 `skillFolderHash` 形态。CLI 取不到树时(限流)会把 64 位内容哈希写进同一个字段
|
|
140
|
+
* (`add.ts:2254`),所以形态取决于**安装那一刻 API 通不通**,不是取决于来源类型。
|
|
141
|
+
* `tree` = 40 位 git tree SHA;`content` = 64 位内容哈希;`missing` = 没有这个字段。
|
|
142
|
+
*/
|
|
143
|
+
export function hashShape(value) {
|
|
144
|
+
if (!value || typeof value !== 'string') return 'missing';
|
|
145
|
+
return TREE_SHA_RE.test(value) ? 'tree' : 'content';
|
|
146
|
+
}
|
|
147
|
+
|
|
91
148
|
/** `KEY` + `/dir/SKILL.md` → `dir`(F18:`skillPath` 是文件路径,目录运算先 dirname)。 */
|
|
92
149
|
export function skillDir(skillPath) {
|
|
93
150
|
const parts = String(skillPath).split('/');
|
|
@@ -126,22 +183,41 @@ export function listSkillsFromTree({ tree, rootSha, subpath, repoName }) {
|
|
|
126
183
|
}
|
|
127
184
|
|
|
128
185
|
/**
|
|
129
|
-
* 单技能判定(L2/L3
|
|
130
|
-
*
|
|
186
|
+
* 单技能判定(L2/L3)。
|
|
187
|
+
*
|
|
188
|
+
* 基线取「第一个 40 位 tree SHA」:优先 lock 的 `skillFolderHash`,其次安装时记下的
|
|
189
|
+
* `state.installs[].treeSha`。CLI 在限流时会退化成 64 位内容哈希(`add.ts:2236`/`2254`),
|
|
190
|
+
* 那玩意儿与上游 tree SHA 不同源,直接比必然不等 —— 既不能判 `current`,也不该判
|
|
191
|
+
* `update-available`,此时改用手边记下的基线;两样都没有才给 `unknown`(老状态,行为与以前一致)。
|
|
131
192
|
*/
|
|
132
|
-
export function checkSkill({ lockHash, snapshotEntry, tree, rootSha }) {
|
|
193
|
+
export function checkSkill({ lockHash, recordedTreeSha, snapshotEntry, tree, rootSha }) {
|
|
133
194
|
if (!snapshotEntry) return { status: 'upstream-deleted', detail: '上游整树里已找不到这个 skillPath' };
|
|
134
|
-
|
|
135
|
-
|
|
195
|
+
const baseline = TREE_SHA_RE.test(String(lockHash || ''))
|
|
196
|
+
? { sha: lockHash, from: 'lock' }
|
|
197
|
+
: TREE_SHA_RE.test(String(recordedTreeSha || ''))
|
|
198
|
+
? { sha: recordedTreeSha, from: 'recorded' }
|
|
199
|
+
: null;
|
|
200
|
+
if (!baseline) {
|
|
136
201
|
return {
|
|
137
202
|
status: 'unknown',
|
|
138
|
-
detail: 'lock
|
|
203
|
+
detail: 'lock 里没有 40 位 tree SHA,安装时也未记录基线,无法按 tree 比对',
|
|
139
204
|
};
|
|
140
205
|
}
|
|
141
206
|
const target = skillTreeSha({ tree, skillPath: snapshotEntry.skillPath, rootSha });
|
|
142
207
|
if (!target) return { status: 'unknown', detail: '整树里找不到该目录的 tree 条目' };
|
|
143
|
-
if (target ===
|
|
144
|
-
|
|
208
|
+
if (target === baseline.sha) {
|
|
209
|
+
return {
|
|
210
|
+
status: 'current',
|
|
211
|
+
detail: baseline.from === 'lock' ? '与安装时的 tree SHA 一致' : '与安装时记录的 tree SHA 一致',
|
|
212
|
+
baseline: baseline.from,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
status: 'update-available',
|
|
217
|
+
detail: `tree SHA 已变化(基线来自 ${baseline.from})`,
|
|
218
|
+
targetTreeSha: target,
|
|
219
|
+
baseline: baseline.from,
|
|
220
|
+
};
|
|
145
221
|
}
|
|
146
222
|
|
|
147
223
|
/** 计划 → 应用的防换手 token:磁盘或上游在"计划"之后被改动就拒绝(设计 §7.4③)。 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morehao/dsh-skills-console",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "morehao <morehao@qq.com>",
|