@morehao/dsh-skills-console 0.2.1 → 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 +18 -39
- package/client.js +195 -17
- package/lib/core/cli.js +3 -1
- package/lib/core/git.js +282 -0
- package/lib/core/links.js +1 -1
- package/lib/core/service.js +620 -87
- package/lib/core/updates.js +100 -12
- package/lib/routes.js +3 -0
- package/package.json +1 -1
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/links.js
CHANGED
|
@@ -55,7 +55,7 @@ export function inspectTarget({ linkPath, canonicalPath }) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* 建链:同层用相对路径(可跨机器复用),算不出相对就退回绝对路径并标记。
|
|
59
59
|
* Windows 用 junction + 绝对路径(对齐 CLI 的 createSymlink,F2)。
|
|
60
60
|
*/
|
|
61
61
|
export function createLink({ linkPath, targetPath, platform = process.platform, fsImpl = fs }) {
|