@morehao/dsh-skills-console 0.2.2 → 0.2.4
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 +22 -4
- package/lib/core/cli.js +38 -16
- package/lib/core/git.js +282 -0
- package/lib/core/paths.js +6 -2
- package/lib/core/service.js +367 -71
- package/lib/core/updates.js +92 -16
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
|
|
|
41
41
|
|
|
42
42
|
## 安装
|
|
43
43
|
|
|
44
|
+
插件依赖里自带 `skills@1.7.4`,**不需要用户预装 Node / npm / npx**:CLI 由宿主自带的 Node 启动,装插件时随依赖一起落盘。安装技能仍需要**本机有 git**(CLI 会 `git clone` 仓库);首次装插件需要联网拉取依赖。
|
|
45
|
+
|
|
44
46
|
在 Harness 里装本地 link:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
@@ -55,7 +57,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
55
57
|
name: '@morehao/dsh-skills-console'
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 /
|
|
60
|
+
客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。本地 `link:` 开发时,先在仓库跑一次 `npm install`(装上 `skills`),否则 CLI 依赖层不会命中。
|
|
59
61
|
|
|
60
62
|
## 快速上手
|
|
61
63
|
|
|
@@ -66,7 +68,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
66
68
|
|
|
67
69
|
## 可靠性
|
|
68
70
|
|
|
69
|
-
- **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json`
|
|
71
|
+
- **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`)的增删改只经 `skills` CLI,插件不直接写;探测 / 更新检查会把仓库临时 clone 到 `os.tmpdir()`(只读、用完即删),结果绝不落进 canonical 或 lock。
|
|
70
72
|
- **只动自己的东西**:只处理本插件创建的 `managed` 链接,以及已记账的 `managed-external`;`real` 目录和 `foreign-link` 永不改写,删除前体检直接中止。
|
|
71
73
|
- **启停不改正文**:`mode` 只增删 DSH 侧链接,避免本地修改被下一次 `add` 覆盖。
|
|
72
74
|
- **可回滚**:删除进回收站(7 天);更新前快照到 `backups/`,失败自动还原(接口另提供手动回滚)。
|
|
@@ -78,13 +80,29 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
78
80
|
| 项 | 路径 | 归属 |
|
|
79
81
|
| --- | --- | --- |
|
|
80
82
|
| canonical 真身 | `~/.agents/skills` | `skills` CLI 独占 |
|
|
81
|
-
| CLI lock | `~/.agents/.skill-lock.json` | `skills` CLI 独占(本插件只读) |
|
|
83
|
+
| CLI lock | `~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`) | `skills` CLI 独占(本插件只读) |
|
|
82
84
|
| DSH 全局根 | `~/.dsh/skills` | 本插件写软链 |
|
|
83
85
|
| 插件状态 | `$DSH_HOME/skills-console/state.json` | 本插件(`DSH_SKILLS_CONSOLE_HOME` 可覆盖) |
|
|
84
86
|
| 备份 / 回收站 | `$DSH_HOME/skills-console/{backups,trash}` | 本插件 |
|
|
85
87
|
| 事件日志 | `$DSH_HOME/skills-console/dsh-skills-console.log` | 本插件(JSONL,1MB 滚动) |
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
### 环境变量
|
|
90
|
+
|
|
91
|
+
| 变量 | 默认 | 作用 |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `DSH_SKILLS_CONSOLE_HOME` | `$DSH_HOME/skills-console` | 状态、备份、回收站与事件日志的根 |
|
|
94
|
+
| `DSH_SKILLS_CONSOLE_CLI` | 自动定位 | 覆盖 `skills` CLI(测试桩也走这里) |
|
|
95
|
+
| `DSH_SKILLS_CONSOLE_NPX_SPEC` | `skills` | 仅 legacy `npx` 兜底用;桌面端没有 npx,正常应显示 `dependency` |
|
|
96
|
+
| `DSH_SKILLS_CONSOLE_TIMEOUT_MS` | `120000` | 单次 CLI 调用超时 |
|
|
97
|
+
| `DSH_SKILLS_CONSOLE_GIT` | `auto` | 设为 `off` 关闭 git 通道,强制只走 GitHub REST |
|
|
98
|
+
| `DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS` | `60000` | git 单次操作(`ls-remote` / `clone`)超时 |
|
|
99
|
+
| `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时 |
|
|
100
|
+
| `XDG_STATE_HOME` | 未设置 | 设置后 CLI lock 移到 `$XDG_STATE_HOME/skills/.skill-lock.json`,插件跟随读取 |
|
|
101
|
+
|
|
102
|
+
关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403。本插件默认让探测、更新检查与 git 兜底走系统 `git`(`ls-remote` + `clone --filter=blob:none --no-checkout`),不占 REST 配额、也不需要 token;git 不可用时才回退 REST,所以原本能用的环境不会因为这条路径变坏。限流且 git 也不可用时,错误文案会给出恢复时间,并提示设置 `GITHUB_TOKEN`。
|
|
103
|
+
|
|
104
|
+
> git 通道只在 `os.tmpdir()` 里读一份临时仓库,用完即删 —— 不写 canonical、不写 CLI lock,落盘始终只交给 `skills` CLI。
|
|
105
|
+
|
|
88
106
|
|
|
89
107
|
## 开发与发布
|
|
90
108
|
|
package/lib/core/cli.js
CHANGED
|
@@ -40,10 +40,15 @@ function findInPath(command, envPath) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* CLI
|
|
43
|
+
* CLI 定位顺序:显式覆盖 → 本插件依赖 → 宿主 profile → PATH → npx(legacy)。
|
|
44
|
+
* 插件依赖排在 profile 前面:版本随插件锁死,不被 PATH 或别的插件带的旧版抢占。
|
|
44
45
|
* 每一层都记来源,诊断页要展示"这次到底跑的哪一个"。
|
|
45
46
|
*/
|
|
46
|
-
export function locateCli({
|
|
47
|
+
export function locateCli({
|
|
48
|
+
env = process.env,
|
|
49
|
+
logger = () => {},
|
|
50
|
+
resolvePackage = () => require.resolve('skills/package.json'),
|
|
51
|
+
} = {}) {
|
|
47
52
|
const override = env.DSH_SKILLS_CONSOLE_CLI;
|
|
48
53
|
if (override && override.trim()) {
|
|
49
54
|
const parts = override.trim().split(/\s+/);
|
|
@@ -51,7 +56,20 @@ export function locateCli({ env = process.env, logger = () => {} } = {}) {
|
|
|
51
56
|
return { command: parts[0], prefixArgs: parts.slice(1), source: 'env-override', jsonCapable: true };
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
//
|
|
59
|
+
// 插件自己的 runtime 依赖:装插件时由宿主 pnpm 一起装好,用户不需要 Node/npm/npx。
|
|
60
|
+
try {
|
|
61
|
+
const pkg = resolvePackage();
|
|
62
|
+
const bin = path.join(path.dirname(pkg), 'bin', 'cli.mjs');
|
|
63
|
+
if (fs.existsSync(bin)) {
|
|
64
|
+
return { command: process.execPath, prefixArgs: [bin], source: 'dependency', jsonCapable: true };
|
|
65
|
+
}
|
|
66
|
+
// 包在但入口丢了,说明安装不完整;交给后面的层兜底,但要留痕
|
|
67
|
+
logger('cli.dependency.bin-missing', { pkg, bin });
|
|
68
|
+
} catch {
|
|
69
|
+
// 没装在本插件可见范围内(例如 link: 开发仓库还没 npm install),继续往下走
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 宿主 profile 里的 skills 依赖(兼容旧插件 / 其他插件已经带进来的版本)。
|
|
55
73
|
// 注意:GUI 启动的宿主进程常常拿不到 DSH_PROFILE_DIR,所以再按 DSH_HOME/profiles/* 兜一层探测。
|
|
56
74
|
const profileDir = env.DSH_PROFILE_DIR;
|
|
57
75
|
if (profileDir) {
|
|
@@ -74,16 +92,6 @@ export function locateCli({ env = process.env, logger = () => {} } = {}) {
|
|
|
74
92
|
// 没有 profiles 目录就继续往下走
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
try {
|
|
78
|
-
const pkg = require.resolve('skills/package.json');
|
|
79
|
-
const bin = path.join(path.dirname(pkg), 'bin', 'cli.mjs');
|
|
80
|
-
if (fs.existsSync(bin)) {
|
|
81
|
-
return { command: process.execPath, prefixArgs: [bin], source: 'dependency', jsonCapable: true };
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
// 没有作为依赖装在本插件可见范围内,继续往下走
|
|
85
|
-
}
|
|
86
|
-
|
|
87
95
|
const onPath = findInPath('skills', env.PATH);
|
|
88
96
|
if (onPath) return { command: onPath, prefixArgs: [], source: 'path', jsonCapable: true };
|
|
89
97
|
|
|
@@ -105,8 +113,13 @@ export function runCli(location, args, { timeoutMs = 120000, signal, env = proce
|
|
|
105
113
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
106
114
|
env: {
|
|
107
115
|
...env,
|
|
116
|
+
// 桌面宿主本身就是 Electron 的 Node 模式;显式补上,避免调用方传了裁剪过的 env
|
|
117
|
+
...(process.versions.electron && location.command === process.execPath ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
|
108
118
|
CI: '1',
|
|
109
119
|
NO_COLOR: '1',
|
|
120
|
+
// CLI 的遥测只看这两个变量;CI=1 不会关闭,只会在上报里打 ci=1 标记
|
|
121
|
+
DO_NOT_TRACK: '1',
|
|
122
|
+
DISABLE_TELEMETRY: '1',
|
|
110
123
|
TERM: 'dumb',
|
|
111
124
|
GIT_TERMINAL_PROMPT: '0',
|
|
112
125
|
GIT_HTTP_LOW_SPEED_LIMIT: '1',
|
|
@@ -151,12 +164,12 @@ export function runCli(location, args, { timeoutMs = 120000, signal, env = proce
|
|
|
151
164
|
});
|
|
152
165
|
}
|
|
153
166
|
|
|
154
|
-
export function createCli({ env = process.env, logger = () => {} } = {}) {
|
|
167
|
+
export function createCli({ env = process.env, logger = () => {}, resolvePackage } = {}) {
|
|
155
168
|
let cached = null;
|
|
156
169
|
let capabilities = null;
|
|
157
170
|
|
|
158
171
|
function location() {
|
|
159
|
-
if (!cached) cached = locateCli({ env, logger });
|
|
172
|
+
if (!cached) cached = locateCli({ env, logger, resolvePackage });
|
|
160
173
|
return cached;
|
|
161
174
|
}
|
|
162
175
|
|
|
@@ -178,7 +191,16 @@ export function createCli({ env = process.env, logger = () => {} } = {}) {
|
|
|
178
191
|
/** 能力位:`--json` 与 `--full-depth` 任一不支持都要在 UI 上禁用写操作(漏装比不可用更危险)。 */
|
|
179
192
|
async capabilities({ force = false } = {}) {
|
|
180
193
|
if (capabilities && !force) return capabilities;
|
|
181
|
-
|
|
194
|
+
let loc;
|
|
195
|
+
try {
|
|
196
|
+
loc = location();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (!(error instanceof CliUnavailableError)) throw error;
|
|
199
|
+
// 不缓存:用户补好依赖 / PATH 后,下一次 /state 就能恢复,不必重启宿主;
|
|
200
|
+
// 同时清掉旧的成功缓存,避免 force 探测到 missing 后又回落到上一轮的 usable。
|
|
201
|
+
capabilities = null;
|
|
202
|
+
return { version: null, json: false, fullDepth: false, source: 'missing', usable: false };
|
|
203
|
+
}
|
|
182
204
|
if (!loc.jsonCapable) {
|
|
183
205
|
capabilities = { version: null, json: false, fullDepth: false, source: loc.source, usable: false };
|
|
184
206
|
return capabilities;
|
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/paths.js
CHANGED
|
@@ -12,6 +12,10 @@ export function createPaths(env = process.env, overrides = {}) {
|
|
|
12
12
|
const dshHome = overrides.dshHome ?? env.DSH_HOME ?? path.join(home, '.dsh');
|
|
13
13
|
const stateDir =
|
|
14
14
|
overrides.stateDir ?? env.DSH_SKILLS_CONSOLE_HOME ?? path.join(dshHome, 'skills-console');
|
|
15
|
+
// 与 skills CLI 的 getSkillLockPath 对齐:设了 XDG_STATE_HOME 时 lock 在 XDG 下
|
|
16
|
+
const lockFile = env.XDG_STATE_HOME
|
|
17
|
+
? path.join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json')
|
|
18
|
+
: path.join(home, '.agents', '.skill-lock.json');
|
|
15
19
|
|
|
16
20
|
return {
|
|
17
21
|
home,
|
|
@@ -20,8 +24,8 @@ export function createPaths(env = process.env, overrides = {}) {
|
|
|
20
24
|
stateDir,
|
|
21
25
|
/** canonical 真身:CLI 唯一的落点(F1) */
|
|
22
26
|
canonical: path.join(home, '.agents', 'skills'),
|
|
23
|
-
/** CLI 全局 lock(F4
|
|
24
|
-
lockFile
|
|
27
|
+
/** CLI 全局 lock(F4);XDG_STATE_HOME 优先,与 CLI 一致 */
|
|
28
|
+
lockFile,
|
|
25
29
|
/** DSH 原生 user-dsh 根(rank 400) */
|
|
26
30
|
dshGlobalSkills: path.join(dshHome, 'skills'),
|
|
27
31
|
stateFile: path.join(stateDir, 'state.json'),
|
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,62 @@ function nowIso() {
|
|
|
31
33
|
return new Date().toISOString();
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// skills CLI 对任意 GitHub 仓库走 git clone;没装 git 时它把错误打在 stdout(spinner)里
|
|
37
|
+
const GIT_MISSING_RE = /spawn git ENOENT|git: command not found|not recognized as an internal or external command/i;
|
|
38
|
+
|
|
39
|
+
/** 把 CLI 的 add 失败翻译成可执行错误;git 缺失时不要只给一句 spawn ENOENT。 */
|
|
40
|
+
function cliAddFailure(result, action) {
|
|
41
|
+
const detail = {
|
|
42
|
+
code: result.code,
|
|
43
|
+
stderr: (result.stderr || '').slice(-2000),
|
|
44
|
+
stdout: (result.stdout || '').slice(-2000),
|
|
45
|
+
};
|
|
46
|
+
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
47
|
+
if (GIT_MISSING_RE.test(output)) {
|
|
48
|
+
return new ServiceError(
|
|
49
|
+
'error.skills.git-missing',
|
|
50
|
+
'安装/更新技能需要本机有 git(skills CLI 会 clone 仓库);请安装 git 后重试',
|
|
51
|
+
detail,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return new ServiceError('error.skills.cli-failed', `skills ${action} 失败`, detail);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 本机时区的 `HH:mm`。手写而不是 `toLocaleTimeString`:不受 ICU / locale 差异影响。 */
|
|
58
|
+
function formatClock(ms) {
|
|
59
|
+
const at = new Date(ms);
|
|
60
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
61
|
+
return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 上游失败 → 可执行错误(T1)。四类必须互不相同,否则用户只知道「读不到」,
|
|
66
|
+
* 不知道该等、该改来源,还是该查网络。
|
|
67
|
+
*/
|
|
68
|
+
function upstreamError(source, failure) {
|
|
69
|
+
const status = failure?.status ?? null;
|
|
70
|
+
const resetAt = failure?.resetAt ?? null;
|
|
71
|
+
const detail = { source, status, resetAt, error: failure?.error ?? null };
|
|
72
|
+
if (failure?.rateLimited) {
|
|
73
|
+
const resume = resetAt ? `约 ${formatClock(resetAt)} 后自动恢复;` : '';
|
|
74
|
+
return new ServiceError(
|
|
75
|
+
'error.skills.rate-limited',
|
|
76
|
+
`GitHub 接口已限流(匿名 60 次/小时,按出口 IP 共享)。${resume}设置 GITHUB_TOKEN 可提到 5000 次/小时`,
|
|
77
|
+
detail,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (status === 404) {
|
|
81
|
+
return new ServiceError('error.skills.repo-not-found', `读不到 ${source}:仓库不存在或为私有`, detail);
|
|
82
|
+
}
|
|
83
|
+
if (status === 401 || status === 403) {
|
|
84
|
+
return new ServiceError('error.skills.repo-forbidden', `读取 ${source} 被拒绝(HTTP ${status})`, detail);
|
|
85
|
+
}
|
|
86
|
+
if (status) {
|
|
87
|
+
return new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:HTTP ${status}`, detail);
|
|
88
|
+
}
|
|
89
|
+
return new ServiceError('error.skills.network', '无法连接 GitHub(网络或代理问题)', detail);
|
|
90
|
+
}
|
|
91
|
+
|
|
34
92
|
/** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
|
|
35
93
|
function installedNamesFrom(stdout) {
|
|
36
94
|
try {
|
|
@@ -141,6 +199,7 @@ function createQueue(logger) {
|
|
|
141
199
|
export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
|
|
142
200
|
const enqueue = createQueue(logger);
|
|
143
201
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
202
|
+
const git = createGit({ env, logger });
|
|
144
203
|
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
145
204
|
const probeCache = new Map();
|
|
146
205
|
const PROBE_TTL_MS = 60_000;
|
|
@@ -154,6 +213,19 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
154
213
|
if (store.readOnly) throw new StateReadOnlyError(store.problem);
|
|
155
214
|
}
|
|
156
215
|
|
|
216
|
+
/** 所有会调 CLI 的写操作共用;缺失时给 400,不要把裸 CliUnavailableError 漏成 500。 */
|
|
217
|
+
async function requireUsableCli() {
|
|
218
|
+
const capabilities = await cli.capabilities();
|
|
219
|
+
if (!capabilities.usable) {
|
|
220
|
+
throw new ServiceError(
|
|
221
|
+
'error.skills.cli-missing',
|
|
222
|
+
'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用',
|
|
223
|
+
capabilities,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return capabilities;
|
|
227
|
+
}
|
|
228
|
+
|
|
157
229
|
function detectedTargets() {
|
|
158
230
|
return registry.detected();
|
|
159
231
|
}
|
|
@@ -203,44 +275,51 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
203
275
|
return { created, skipped, failed };
|
|
204
276
|
}
|
|
205
277
|
|
|
206
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。
|
|
280
|
+
* 返回 `{ branch, failure }`:`failure` 原样透出 `fetchDefaultBranch` 的结构化失败,
|
|
281
|
+
* 由 `readSnapshot` 决定报哪种错误(T1)。
|
|
282
|
+
*/
|
|
207
283
|
async function branchOf(subscription, { persist = false } = {}) {
|
|
208
|
-
if (subscription.ref) return subscription.ref;
|
|
209
|
-
let
|
|
284
|
+
if (subscription.ref) return { branch: subscription.ref, failure: null };
|
|
285
|
+
let resolved;
|
|
210
286
|
try {
|
|
211
|
-
|
|
287
|
+
resolved = await fetchDefaultBranch({
|
|
212
288
|
source: subscription.source,
|
|
213
289
|
token: tokenOf,
|
|
214
290
|
fetchImpl,
|
|
215
291
|
});
|
|
216
|
-
} catch {
|
|
217
|
-
branch
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { branch: null, failure: { status: null, rateLimited: false, resetAt: null, error: error?.message || String(error) } };
|
|
218
294
|
}
|
|
219
|
-
|
|
295
|
+
const branch = resolved?.branch || null;
|
|
296
|
+
if (!branch) return { branch: null, failure: resolved || null };
|
|
220
297
|
if (persist && !store.readOnly) {
|
|
221
298
|
store.update((draft) => {
|
|
222
299
|
const row = draft.subscriptions.find((item) => item.id === subscription.id);
|
|
223
300
|
if (row && !row.ref) row.ref = branch;
|
|
224
301
|
});
|
|
225
302
|
}
|
|
226
|
-
return branch;
|
|
303
|
+
return { branch, failure: null };
|
|
227
304
|
}
|
|
228
305
|
|
|
229
306
|
/**
|
|
230
|
-
* 读一次订阅快照:只有网络与纯计算,**不写 store
|
|
307
|
+
* 读一次订阅快照:只有网络与纯计算,**不写 store**(`persistRef` 例外,只补订阅的 ref)。
|
|
231
308
|
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
232
309
|
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
310
|
+
*
|
|
311
|
+
* T4:REST 因**限流**或 **404** 失败时先试 git(零配额);git 也不行才抛 T1 的错误。
|
|
233
312
|
*/
|
|
234
|
-
async function readSnapshot(subscription, { force = false, withCommit = true } = {}) {
|
|
313
|
+
async function readSnapshot(subscription, { force = false, withCommit = true, persistRef = false } = {}) {
|
|
235
314
|
const source = subscription.source;
|
|
236
315
|
try {
|
|
237
|
-
const branch = await branchOf(subscription);
|
|
316
|
+
const { branch, failure } = await branchOf(subscription, { persist: persistRef });
|
|
238
317
|
if (!branch) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
);
|
|
318
|
+
if (failure?.rateLimited || failure?.status === 404) {
|
|
319
|
+
const viaGit = await gitSnapshot(subscription, { ref: subscription.ref || null });
|
|
320
|
+
if (viaGit) return viaGit;
|
|
321
|
+
}
|
|
322
|
+
throw upstreamError(source, failure);
|
|
244
323
|
}
|
|
245
324
|
const result = await fetchTree({
|
|
246
325
|
source,
|
|
@@ -250,6 +329,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
250
329
|
fetchImpl,
|
|
251
330
|
});
|
|
252
331
|
if (result.error) {
|
|
332
|
+
// 元数据已经通了,树再 404 说明是 ref 没了(不是仓库不存在);只有限流值得转 git
|
|
333
|
+
if (result.rateLimited) {
|
|
334
|
+
const viaGit = await gitSnapshot(subscription, { ref: branch });
|
|
335
|
+
if (viaGit) return viaGit;
|
|
336
|
+
throw upstreamError(source, {
|
|
337
|
+
status: result.status ?? null,
|
|
338
|
+
rateLimited: true,
|
|
339
|
+
resetAt: result.resetAt ?? null,
|
|
340
|
+
error: result.error,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
253
343
|
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
254
344
|
source,
|
|
255
345
|
ref: branch,
|
|
@@ -277,6 +367,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
277
367
|
return {
|
|
278
368
|
branch,
|
|
279
369
|
changed,
|
|
370
|
+
via: 'rest',
|
|
371
|
+
tree: result.tree,
|
|
372
|
+
rootSha: result.rootSha,
|
|
280
373
|
snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
|
|
281
374
|
};
|
|
282
375
|
} catch (error) {
|
|
@@ -287,8 +380,162 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
287
380
|
}
|
|
288
381
|
}
|
|
289
382
|
|
|
290
|
-
|
|
291
|
-
|
|
383
|
+
/**
|
|
384
|
+
* T4:用 git 组装一份与 REST 同形的快照。`etag` 恒为 `null` —— 下次刷新会完整读一次,
|
|
385
|
+
* 绝不会拿旧 ETag 去冒充 304。失败返回 `null`,由调用方抛 T1 的错误。
|
|
386
|
+
*/
|
|
387
|
+
async function gitSnapshot(subscription, { ref = null, purpose = 'snapshot' } = {}) {
|
|
388
|
+
const source = subscription.source;
|
|
389
|
+
const event = purpose === 'probe' ? 'git.probe' : 'git.snapshot';
|
|
390
|
+
if (!git.enabled()) return null;
|
|
391
|
+
const startedAt = Date.now();
|
|
392
|
+
let url;
|
|
393
|
+
try {
|
|
394
|
+
url = git.url(source);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
logger('git.fallback', { source, kind: error.kind || 'invalid-url', purpose });
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if (!(await git.isAvailable())) {
|
|
400
|
+
logger('git.fallback', { source, kind: 'git-missing', purpose });
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
let branch = ref || null;
|
|
405
|
+
if (!branch) {
|
|
406
|
+
const head = await git.lsRemote({ url });
|
|
407
|
+
branch = head.branch || 'main';
|
|
408
|
+
}
|
|
409
|
+
const tree = await git.tree({ url, ref: branch });
|
|
410
|
+
const entries = listSkillsFromTree({
|
|
411
|
+
tree: tree.entries,
|
|
412
|
+
rootSha: tree.rootSha,
|
|
413
|
+
subpath: subscription.subpath,
|
|
414
|
+
repoName: source.split('/').pop(),
|
|
415
|
+
});
|
|
416
|
+
const changed =
|
|
417
|
+
!subscription.snapshot ||
|
|
418
|
+
subscription.snapshot.rootSha !== tree.rootSha ||
|
|
419
|
+
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
420
|
+
logger(event, {
|
|
421
|
+
source,
|
|
422
|
+
ref: branch,
|
|
423
|
+
commit: tree.commit,
|
|
424
|
+
entries: entries.length,
|
|
425
|
+
changed,
|
|
426
|
+
ms: Date.now() - startedAt,
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
branch,
|
|
430
|
+
changed,
|
|
431
|
+
via: 'git',
|
|
432
|
+
tree: tree.entries,
|
|
433
|
+
rootSha: tree.rootSha,
|
|
434
|
+
snapshot: { commit: tree.commit, rootSha: tree.rootSha, entries, fetchedAt: nowIso(), etag: null },
|
|
435
|
+
};
|
|
436
|
+
} catch (error) {
|
|
437
|
+
logger('git.fallback', {
|
|
438
|
+
source,
|
|
439
|
+
kind: error.kind || 'clone-failed',
|
|
440
|
+
message: error.message,
|
|
441
|
+
purpose,
|
|
442
|
+
ms: Date.now() - startedAt,
|
|
443
|
+
});
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* T4 commit 短路:订阅快照的 commit 与远端 HEAD 一致 → 没变化,不必请求 REST。
|
|
450
|
+
* 只对「跟着默认分支」的订阅生效:盯 tag / 其他分支时 HEAD 不代表它。
|
|
451
|
+
*/
|
|
452
|
+
async function remoteCommitUnchanged(subscription) {
|
|
453
|
+
const commit = subscription.snapshot?.commit;
|
|
454
|
+
if (!commit || !git.enabled()) return false;
|
|
455
|
+
if (!(await git.isAvailable())) return false;
|
|
456
|
+
try {
|
|
457
|
+
const head = await git.lsRemote({ url: git.url(subscription.source) });
|
|
458
|
+
if (subscription.ref && head.branch && subscription.ref !== head.branch) return false;
|
|
459
|
+
return head.commit === commit;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
logger('git.fallback', {
|
|
462
|
+
source: subscription.source,
|
|
463
|
+
kind: error.kind || 'clone-failed',
|
|
464
|
+
purpose: 'refresh',
|
|
465
|
+
});
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 探测走 git:`ls-remote` 拿默认分支,`clone --filter=blob:none --no-checkout` 拿整树。
|
|
472
|
+
* 不占 GitHub REST 配额,也不需要 token / gh —— 出口 IP 匿名配额被耗尽时这条路仍然通。
|
|
473
|
+
* 失败一律返回 `null`,由调用方回退现有 REST 路径(默认开 = 只增不减)。
|
|
474
|
+
*/
|
|
475
|
+
async function probeViaGit({ source, ref, subpath }) {
|
|
476
|
+
const result = await gitSnapshot({ source, subpath, snapshot: null }, { ref, purpose: 'probe' });
|
|
477
|
+
if (!result) return null;
|
|
478
|
+
return { branch: result.branch, via: 'git', snapshot: result.snapshot };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* T3.7 安装后基线补录。CLI 在拿不到树时把 64 位内容哈希写进 lock(`add.ts:2254`),
|
|
483
|
+
* 「手填技能名」这条安装路径又没有快照可依 —— 装上之后用 git 把目录 tree SHA 补进
|
|
484
|
+
* `installs[].treeSha`,让 T2 的判定对这条路径同样生效。
|
|
485
|
+
*
|
|
486
|
+
* best effort:只读临时目录;任何失败只记日志,**不改安装结果**,`treeSha` 留 null。
|
|
487
|
+
*/
|
|
488
|
+
async function backfillBaselines({ names, source, lock, ref }) {
|
|
489
|
+
if (!git.enabled() || names.length === 0) return [];
|
|
490
|
+
const targets = [];
|
|
491
|
+
for (const name of names) {
|
|
492
|
+
const lockEntry = lock.skills?.[name];
|
|
493
|
+
if (hashShape(lockEntry?.skillFolderHash || null) !== 'content') continue;
|
|
494
|
+
const install = store.findInstall(name);
|
|
495
|
+
if (!install || install.treeSha) continue;
|
|
496
|
+
targets.push({ name, skillPath: lockEntry?.skillPath || install.skillPath || null });
|
|
497
|
+
}
|
|
498
|
+
if (targets.length === 0) return [];
|
|
499
|
+
if (!(await git.isAvailable())) {
|
|
500
|
+
logger('git.error', { source, kind: 'git-missing', purpose: 'baseline' });
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
const startedAt = Date.now();
|
|
504
|
+
try {
|
|
505
|
+
const url = git.url(source);
|
|
506
|
+
let useRef = ref || null;
|
|
507
|
+
if (!useRef) useRef = (await git.lsRemote({ url })).commit;
|
|
508
|
+
// 与探测共用进程内缓存:同一 (url, ref) 只 clone 一次
|
|
509
|
+
const tree = await git.tree({ url, ref: useRef });
|
|
510
|
+
const updated = [];
|
|
511
|
+
for (const target of targets) {
|
|
512
|
+
const treeSha = target.skillPath
|
|
513
|
+
? skillTreeSha({ tree: tree.entries, skillPath: target.skillPath, rootSha: tree.rootSha })
|
|
514
|
+
: null;
|
|
515
|
+
if (!treeSha) {
|
|
516
|
+
logger('git.error', { name: target.name, kind: 'no-tree-entry', purpose: 'baseline' });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
store.update((draft) => {
|
|
520
|
+
const row = draft.installs.find((item) => item.name === target.name);
|
|
521
|
+
if (row && !row.treeSha) row.treeSha = treeSha;
|
|
522
|
+
});
|
|
523
|
+
updated.push({ name: target.name, treeSha });
|
|
524
|
+
}
|
|
525
|
+
if (updated.length > 0) logger('git.baseline', { source, ref: useRef, updated, ms: Date.now() - startedAt });
|
|
526
|
+
return updated;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
logger('git.error', {
|
|
529
|
+
source,
|
|
530
|
+
kind: error.kind || 'clone-failed',
|
|
531
|
+
message: error.message,
|
|
532
|
+
purpose: 'baseline',
|
|
533
|
+
});
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function recordLink(name, target, linkPath, mode, created) { if (store.readOnly) return;
|
|
292
539
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
293
540
|
if (existing) return;
|
|
294
541
|
if (!created) return;
|
|
@@ -369,6 +616,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
369
616
|
skillPath: record.skillPath,
|
|
370
617
|
ref: record.ref,
|
|
371
618
|
commit: null,
|
|
619
|
+
treeSha: null,
|
|
372
620
|
installedHash: record.installedHash,
|
|
373
621
|
installedAt: nowIso(),
|
|
374
622
|
updatedAt: nowIso(),
|
|
@@ -617,10 +865,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
617
865
|
|
|
618
866
|
async install({ source, subscriptionId, skills = [], commit, targets, scopeProjects = [] }) {
|
|
619
867
|
assertWritable();
|
|
620
|
-
|
|
621
|
-
if (!capabilities.usable) {
|
|
622
|
-
throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
|
|
623
|
-
}
|
|
868
|
+
await requireUsableCli();
|
|
624
869
|
// 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
|
|
625
870
|
const requested = (skills || []).filter(Boolean);
|
|
626
871
|
|
|
@@ -639,12 +884,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
639
884
|
|
|
640
885
|
const result = await cli.add({ source: sourceArg, skills: planned });
|
|
641
886
|
if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
|
|
642
|
-
if (result.code !== 0)
|
|
643
|
-
throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
|
|
644
|
-
code: result.code,
|
|
645
|
-
stderr: result.stderr.slice(-2000),
|
|
646
|
-
});
|
|
647
|
-
}
|
|
887
|
+
if (result.code !== 0) throw cliAddFailure(result, 'add');
|
|
648
888
|
// 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
|
|
649
889
|
const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
|
|
650
890
|
if (names.length === 0) {
|
|
@@ -667,12 +907,16 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
667
907
|
warnings.push({ name, warning: 'lock 里没有该技能的记录(来源形态不支持记账)' });
|
|
668
908
|
}
|
|
669
909
|
const expected = snapshot?.entries?.find((entry) => entry.name === name);
|
|
670
|
-
|
|
910
|
+
const lockHash = lockEntry?.skillFolderHash || null;
|
|
911
|
+
const shape = hashShape(lockHash);
|
|
912
|
+
// 只有两边都是 40 位 tree SHA 才有可比性。限流安装时 CLI 写的是 64 位内容哈希
|
|
913
|
+
// (add.ts:2254),拿它比 tree SHA 必然不等 —— 那是形态问题,不是装错了。
|
|
914
|
+
if (expected?.treeSha && shape === 'tree' && hashShape(expected.treeSha) === 'tree' && lockHash !== expected.treeSha) {
|
|
671
915
|
warnings.push({
|
|
672
916
|
name,
|
|
673
917
|
warning: 'lock 的 skillFolderHash 与快照 treeSha 不一致(可能装到了别的 commit)',
|
|
674
918
|
expected: expected.treeSha,
|
|
675
|
-
actual:
|
|
919
|
+
actual: lockHash,
|
|
676
920
|
});
|
|
677
921
|
}
|
|
678
922
|
const installedHash = fs.statSync(canonicalPath).isDirectory() ? contentHash(canonicalPath) : null;
|
|
@@ -682,6 +926,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
682
926
|
skillPath: lockEntry?.skillPath || expected?.skillPath || `${name}/SKILL.md`,
|
|
683
927
|
ref: pin,
|
|
684
928
|
commit: snapshot?.commit || null,
|
|
929
|
+
// 安装那一刻的 tree 基线:lock 被写成内容哈希时,更新判定靠它(T2)
|
|
930
|
+
treeSha: expected?.treeSha || null,
|
|
685
931
|
installedHash,
|
|
686
932
|
installedAt: nowIso(),
|
|
687
933
|
updatedAt: nowIso(),
|
|
@@ -694,7 +940,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
694
940
|
if (index >= 0) draft.installs[index] = { ...draft.installs[index], ...record, installedAt: draft.installs[index].installedAt };
|
|
695
941
|
else draft.installs.push(record);
|
|
696
942
|
});
|
|
697
|
-
installed.push({ name, lockHash:
|
|
943
|
+
installed.push({ name, lockHash, lockHashShape: shape, installedHash });
|
|
698
944
|
}
|
|
699
945
|
|
|
700
946
|
const linkTargets = Array.isArray(targets) && targets.length > 0 ? targets : defaultTargets(state());
|
|
@@ -706,6 +952,14 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
706
952
|
linkResult.failed.push(...one.failed);
|
|
707
953
|
}
|
|
708
954
|
|
|
955
|
+
// T3.7:lock 被写成 64 位内容哈希形态(限流安装)时用 git 补一条 tree 基线,
|
|
956
|
+
// 否则「手填技能名」装出来的技能在更新检查里只能是 unknown。
|
|
957
|
+
const baselines = await backfillBaselines({ names, source: resolvedSource, lock, ref: pin });
|
|
958
|
+
for (const item of installed) {
|
|
959
|
+
const filled = baselines.find((row) => row.name === item.name);
|
|
960
|
+
if (filled) item.treeSha = filled.treeSha;
|
|
961
|
+
}
|
|
962
|
+
|
|
709
963
|
logger('install', { source: sourceArg, skills: names, installed: installed.length });
|
|
710
964
|
return {
|
|
711
965
|
ok: true,
|
|
@@ -719,6 +973,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
719
973
|
|
|
720
974
|
async remove({ name }) {
|
|
721
975
|
assertWritable();
|
|
976
|
+
await requireUsableCli(); // 快照与体检之前就拒绝,避免留下孤儿 trash
|
|
722
977
|
return enqueue(`remove:${name}`, async () => {
|
|
723
978
|
const canonicalPath = path.join(paths.canonical, name);
|
|
724
979
|
if (!fs.existsSync(canonicalPath)) {
|
|
@@ -793,19 +1048,30 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
793
1048
|
if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
|
|
794
1049
|
return cached.pending || cached.value;
|
|
795
1050
|
}
|
|
796
|
-
const pending =
|
|
797
|
-
|
|
798
|
-
|
|
1051
|
+
const pending = (async () => {
|
|
1052
|
+
// 优先 git(零配额);不启用 / 不可用 / 失败都回落到原 REST 路径
|
|
1053
|
+
const viaGit = await probeViaGit({
|
|
799
1054
|
source: canonicalSource,
|
|
800
1055
|
ref: requestedRef,
|
|
801
1056
|
subpath: requestedSubpath,
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1057
|
+
});
|
|
1058
|
+
const probe =
|
|
1059
|
+
viaGit ||
|
|
1060
|
+
(await readSnapshot(
|
|
1061
|
+
{
|
|
1062
|
+
id: null,
|
|
1063
|
+
source: canonicalSource,
|
|
1064
|
+
ref: requestedRef,
|
|
1065
|
+
subpath: requestedSubpath,
|
|
1066
|
+
snapshot: null,
|
|
1067
|
+
},
|
|
1068
|
+
{ force: true, withCommit: false },
|
|
1069
|
+
));
|
|
1070
|
+
// 描述优先按 commit 抓(不可变、可缓存),没有 commit 再退回分支名
|
|
1071
|
+
const enrichRef = probe.snapshot?.commit || probe.branch;
|
|
806
1072
|
const enriched = await enrichSkillEntries({
|
|
807
1073
|
source: canonicalSource,
|
|
808
|
-
ref:
|
|
1074
|
+
ref: enrichRef,
|
|
809
1075
|
entries: probe.snapshot.entries,
|
|
810
1076
|
token: tokenOf,
|
|
811
1077
|
fetchImpl,
|
|
@@ -817,10 +1083,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
817
1083
|
ok: true,
|
|
818
1084
|
source: canonicalSource,
|
|
819
1085
|
ref: probe.branch,
|
|
1086
|
+
via: probe.via || 'rest',
|
|
820
1087
|
skills,
|
|
821
1088
|
skipped: enriched.length - skills.length,
|
|
822
1089
|
};
|
|
823
|
-
});
|
|
1090
|
+
})();
|
|
824
1091
|
probeCache.set(key, { pending, at: Date.now() });
|
|
825
1092
|
try {
|
|
826
1093
|
const value = await pending;
|
|
@@ -930,6 +1197,22 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
930
1197
|
assertWritable();
|
|
931
1198
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
932
1199
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
1200
|
+
// T4 短路:最常见的情况是「什么都没变」。先用 ls-remote 比 commit,一致就一次 REST 都不发。
|
|
1201
|
+
if (await remoteCommitUnchanged(subscription)) {
|
|
1202
|
+
const at = nowIso();
|
|
1203
|
+
store.update((draft) => {
|
|
1204
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
1205
|
+
if (row) row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
1206
|
+
});
|
|
1207
|
+
logger('subscription.refresh', { id, notModified: true, via: 'git' });
|
|
1208
|
+
return {
|
|
1209
|
+
ok: true,
|
|
1210
|
+
changed: false,
|
|
1211
|
+
notModified: true,
|
|
1212
|
+
via: 'git',
|
|
1213
|
+
subscription: state().subscriptions.find((item) => item.id === id),
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
933
1216
|
let probe;
|
|
934
1217
|
try {
|
|
935
1218
|
probe = await readSnapshot(subscription, { force });
|
|
@@ -961,6 +1244,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
961
1244
|
ok: true,
|
|
962
1245
|
changed: !probe.notModified && probe.changed,
|
|
963
1246
|
...(probe.notModified ? { notModified: true } : {}),
|
|
1247
|
+
via: probe.via || 'rest',
|
|
964
1248
|
subscription: state().subscriptions.find((item) => item.id === id),
|
|
965
1249
|
};
|
|
966
1250
|
},
|
|
@@ -983,22 +1267,24 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
983
1267
|
|
|
984
1268
|
const results = [];
|
|
985
1269
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
token: tokenOf,
|
|
993
|
-
fetchImpl,
|
|
994
|
-
});
|
|
995
|
-
if (tree.error) {
|
|
1270
|
+
// T4:与 readSnapshot 同一套语义 —— 限流 / 404 时先试 git,都失败才逐个标 unknown
|
|
1271
|
+
// withCommit: false:判定只需要树,别为 check 多付一次 /commits 请求
|
|
1272
|
+
let probe;
|
|
1273
|
+
try {
|
|
1274
|
+
probe = await readSnapshot(subscription, { withCommit: false, persistRef: true });
|
|
1275
|
+
} catch (error) {
|
|
996
1276
|
for (const install of groupInstalls) {
|
|
997
|
-
results.push({
|
|
1277
|
+
results.push({
|
|
1278
|
+
name: install.name,
|
|
1279
|
+
status: 'unknown',
|
|
1280
|
+
detail: error.message,
|
|
1281
|
+
code: error.code || null,
|
|
1282
|
+
checkedAt: nowIso(),
|
|
1283
|
+
});
|
|
998
1284
|
}
|
|
999
1285
|
continue;
|
|
1000
1286
|
}
|
|
1001
|
-
if (
|
|
1287
|
+
if (probe.notModified) {
|
|
1002
1288
|
for (const install of groupInstalls) {
|
|
1003
1289
|
const previousCheck = state().checks?.[install.name];
|
|
1004
1290
|
results.push({
|
|
@@ -1012,16 +1298,17 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1012
1298
|
}
|
|
1013
1299
|
for (const install of groupInstalls) {
|
|
1014
1300
|
const skillPath = lock.skills?.[install.name]?.skillPath || install.skillPath;
|
|
1015
|
-
const entry =
|
|
1301
|
+
const entry = probe.tree.some((item) => item.type === 'blob' && item.path === skillPath)
|
|
1016
1302
|
? { skillPath }
|
|
1017
1303
|
: null;
|
|
1018
1304
|
const verdict = checkSkill({
|
|
1019
1305
|
lockHash: lock.skills?.[install.name]?.skillFolderHash || null,
|
|
1306
|
+
recordedTreeSha: install.treeSha ?? null,
|
|
1020
1307
|
snapshotEntry: entry,
|
|
1021
|
-
tree:
|
|
1022
|
-
rootSha:
|
|
1308
|
+
tree: probe.tree,
|
|
1309
|
+
rootSha: probe.rootSha,
|
|
1023
1310
|
});
|
|
1024
|
-
results.push({ name: install.name, ...verdict, checkedAt: nowIso() });
|
|
1311
|
+
results.push({ name: install.name, ...verdict, via: probe.via, checkedAt: nowIso() });
|
|
1025
1312
|
}
|
|
1026
1313
|
}
|
|
1027
1314
|
|
|
@@ -1049,20 +1336,15 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1049
1336
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
1050
1337
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
1051
1338
|
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);
|
|
1339
|
+
// 与 checkUpdates 共用一套取数语义:限流时同样能靠 git 出计划(否则「查得到更新却更不了」)
|
|
1340
|
+
const probe = await readSnapshot(subscription, { force: true });
|
|
1341
|
+
const ref = probe.branch;
|
|
1060
1342
|
const lock = readLock(paths);
|
|
1061
1343
|
const skillPath = lock.skills?.[name]?.skillPath || install.skillPath;
|
|
1062
|
-
const entry =
|
|
1344
|
+
const entry = probe.tree.find((item) => item.type === 'blob' && item.path === skillPath);
|
|
1063
1345
|
if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
|
|
1064
|
-
const targetTreeSha = skillTreeSha({ tree:
|
|
1065
|
-
const targetCommit =
|
|
1346
|
+
const targetTreeSha = skillTreeSha({ tree: probe.tree, skillPath, rootSha: probe.rootSha });
|
|
1347
|
+
const targetCommit = probe.snapshot?.commit ?? null;
|
|
1066
1348
|
const token = planToken({
|
|
1067
1349
|
subscriptionId: subscription.id,
|
|
1068
1350
|
from: install.commit || null,
|
|
@@ -1086,6 +1368,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1086
1368
|
|
|
1087
1369
|
async applyUpdate({ name, token, overwrite = false }) {
|
|
1088
1370
|
assertWritable();
|
|
1371
|
+
await requireUsableCli();
|
|
1089
1372
|
return enqueue(`apply:${name}`, async () => {
|
|
1090
1373
|
const plan = await this.planUpdate({ name });
|
|
1091
1374
|
if (token && token !== plan.token) {
|
|
@@ -1116,12 +1399,21 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1116
1399
|
|
|
1117
1400
|
if (result.code !== 0 || !present || !verified) {
|
|
1118
1401
|
if (backup) restoreBackup({ backup, toDir: canonicalPath });
|
|
1402
|
+
const failure = result.code !== 0 ? cliAddFailure(result, 'add') : null;
|
|
1403
|
+
const gitMissing = failure?.code === 'error.skills.git-missing';
|
|
1119
1404
|
logger('updates.apply.failed', { name, code: result.code, verified, present });
|
|
1405
|
+
const suffix = backup ? ';已回滚到上一版' : ';没有可回滚的备份';
|
|
1120
1406
|
return {
|
|
1121
1407
|
ok: false,
|
|
1122
|
-
code: 'error.skills.update-failed',
|
|
1123
|
-
message:
|
|
1124
|
-
detail: {
|
|
1408
|
+
code: gitMissing ? failure.code : 'error.skills.update-failed',
|
|
1409
|
+
message: gitMissing ? `${failure.message}${suffix}` : `更新失败${suffix}`,
|
|
1410
|
+
detail: {
|
|
1411
|
+
cliCode: result.code,
|
|
1412
|
+
verified,
|
|
1413
|
+
present,
|
|
1414
|
+
stderr: (result.stderr || '').slice(-2000),
|
|
1415
|
+
...(failure?.detail || {}),
|
|
1416
|
+
},
|
|
1125
1417
|
backup: backup?.key || null,
|
|
1126
1418
|
};
|
|
1127
1419
|
}
|
|
@@ -1131,9 +1423,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1131
1423
|
const row = draft.installs.find((item) => item.name === name);
|
|
1132
1424
|
if (!row) return;
|
|
1133
1425
|
row.commit = plan.toCommit;
|
|
1426
|
+
// 更新后基线跟着走:lock 若仍是内容哈希形态,下一次判定要用新的 tree SHA
|
|
1427
|
+
row.treeSha = plan.targetTreeSha || row.treeSha || null;
|
|
1134
1428
|
row.installedHash = installedHash;
|
|
1135
1429
|
row.updatedAt = nowIso();
|
|
1136
|
-
row.backup = backup ? { key: backup.key, hash: install.installedHash || null } : row.backup;
|
|
1430
|
+
row.backup = backup ? { key: backup.key, hash: install.installedHash || null, treeSha: install.treeSha ?? null } : row.backup;
|
|
1137
1431
|
draft.checks[name] = { name, status: 'current', detail: null, wouldCommit: null, checkedAt: nowIso(), installCommit: plan.toCommit };
|
|
1138
1432
|
});
|
|
1139
1433
|
|
|
@@ -1160,6 +1454,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
1160
1454
|
row.installedHash = installedHash;
|
|
1161
1455
|
row.updatedAt = nowIso();
|
|
1162
1456
|
row.commit = null;
|
|
1457
|
+
// 回滚回去的是备份那一刻的内容,tree 基线也得跟着退回去(不知道就置空,绝不沿用新版)
|
|
1458
|
+
row.treeSha = install.backup.treeSha ?? null;
|
|
1163
1459
|
draft.checks[name] = { name, status: 'unknown', detail: '已回滚,需重新检查', checkedAt: nowIso() };
|
|
1164
1460
|
});
|
|
1165
1461
|
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.4",
|
|
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>",
|
|
@@ -68,6 +68,9 @@
|
|
|
68
68
|
"dsh-plugin",
|
|
69
69
|
"skills"
|
|
70
70
|
],
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"skills": "1.7.4"
|
|
73
|
+
},
|
|
71
74
|
"devDependencies": {
|
|
72
75
|
"esbuild": "0.28.2"
|
|
73
76
|
}
|