@morehao/dsh-skills-console 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * SKILL.md 的极简 frontmatter 解析:只取安装与清单需要的 `name` / `description`,
6
+ * 不引入 YAML 依赖。支持 `|` / `>` 块与单/双引号,解析失败按“没有 frontmatter”处理。
7
+ *
8
+ * Service(探测补名)与 Installer(落盘发现)共用同一份实现,避免两处规则漂移。
9
+ */
10
+ export function parseSkillFrontmatter(text) {
11
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
12
+ if (!match) return {};
13
+ const lines = match[1].split(/\r?\n/);
14
+ const fields = {};
15
+ for (let index = 0; index < lines.length; index += 1) {
16
+ const line = lines[index];
17
+ if (!line || /^\s/.test(line)) continue;
18
+ const colon = line.indexOf(':');
19
+ if (colon <= 0) continue;
20
+ const key = line.slice(0, colon).trim();
21
+ if (key !== 'name' && key !== 'description') continue;
22
+ let value = line.slice(colon + 1).trim();
23
+ const block = /^([|>])[+-]?$/.exec(value);
24
+ if (block) {
25
+ const folded = block[1] === '>';
26
+ const rows = [];
27
+ while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
28
+ rows.push(lines[index + 1].replace(/^\s+/, ''));
29
+ index += 1;
30
+ }
31
+ value = folded ? rows.join(' ') : rows.join('\n');
32
+ } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
33
+ const quote = value[0];
34
+ value = value.slice(1, -1);
35
+ if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
36
+ }
37
+ fields[key] = value.trim();
38
+ }
39
+ return fields;
40
+ }
41
+
42
+ /** 读取某目录下的 SKILL.md frontmatter;读不到或解析不出就返回空对象。 */
43
+ export function readSkillFrontmatter(skillDir) {
44
+ try {
45
+ return parseSkillFrontmatter(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'));
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
package/lib/core/trash.js CHANGED
@@ -42,13 +42,13 @@ export function newKey(skill) {
42
42
  * 备份落 `skills-console/backups/<key>/`,**绝不进 `skills/`**:
43
43
  * 任何含 `SKILL.md` 的子目录都会被原生当成技能扫到(F13)。
44
44
  */
45
- export function createBackup({ paths, skill, fromDir, hash = null }) {
45
+ export function createBackup({ paths, skill, fromDir, hash = null, prune = true }) {
46
46
  const key = newKey(skill);
47
47
  const target = path.join(paths.backupsDir, key);
48
48
  copyTree(fromDir, path.join(target, 'content'));
49
49
  const meta = { key, skill, hash, from: fromDir, createdAt: new Date().toISOString() };
50
50
  fs.writeFileSync(path.join(target, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
51
- pruneBackups({ paths });
51
+ if (prune) pruneBackups({ paths });
52
52
  return { key, dir: target, ...meta };
53
53
  }
54
54
 
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createCli } from './core/cli.js';
1
+ import { createInstaller } from './core/installer.js';
2
2
  import { createLogger } from './core/log.js';
3
3
  import { createPaths } from './core/paths.js';
4
4
  import { createRegistry } from './core/agents.js';
@@ -22,7 +22,7 @@ export function apply(ctx, config = {}) {
22
22
  if (store.readOnly) logger('state.readonly', { problem: store.problem });
23
23
 
24
24
  const registry = createRegistry({ paths, getState: () => store.state });
25
- const cli = createCli({ env, logger });
25
+ const installer = createInstaller({ paths, env, logger });
26
26
 
27
27
  const visibleProvider = async (cwd) => {
28
28
  const skills = ctx.get('skills');
@@ -64,7 +64,7 @@ export function apply(ctx, config = {}) {
64
64
  paths,
65
65
  store,
66
66
  registry,
67
- cli,
67
+ installer,
68
68
  logger,
69
69
  env,
70
70
  visibleProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
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>",
@@ -69,7 +69,7 @@
69
69
  "skills"
70
70
  ],
71
71
  "dependencies": {
72
- "skills": "1.7.4"
72
+ "tar": "7.5.22"
73
73
  },
74
74
  "devDependencies": {
75
75
  "esbuild": "0.28.2"
package/lib/core/cli.js DELETED
@@ -1,256 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { createRequire } from 'node:module';
5
-
6
- const require = createRequire(import.meta.url);
7
-
8
- export class CliUnavailableError extends Error {
9
- constructor(reason) {
10
- super(reason || 'skills CLI not found');
11
- this.code = 'error.skills.cli-missing';
12
- this.reason = reason;
13
- }
14
- }
15
-
16
- export class CliFailedError extends Error {
17
- constructor(message, detail) {
18
- super(message);
19
- this.code = detail?.timedOut ? 'error.skills.cli-timeout' : 'error.skills.cli-failed';
20
- this.detail = detail;
21
- }
22
- }
23
-
24
- function isExecutable(file) {
25
- try {
26
- fs.accessSync(file, fs.constants.X_OK);
27
- return fs.statSync(file).isFile();
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- function findInPath(command, envPath) {
34
- for (const dir of String(envPath || '').split(path.delimiter)) {
35
- if (!dir) continue;
36
- const candidate = path.join(dir, command);
37
- if (isExecutable(candidate)) return candidate;
38
- }
39
- return null;
40
- }
41
-
42
- /**
43
- * CLI 定位顺序:显式覆盖 → 本插件依赖 → 宿主 profile → PATH → npx(legacy)。
44
- * 插件依赖排在 profile 前面:版本随插件锁死,不被 PATH 或别的插件带的旧版抢占。
45
- * 每一层都记来源,诊断页要展示"这次到底跑的哪一个"。
46
- */
47
- export function locateCli({
48
- env = process.env,
49
- logger = () => {},
50
- resolvePackage = () => require.resolve('skills/package.json'),
51
- } = {}) {
52
- const override = env.DSH_SKILLS_CONSOLE_CLI;
53
- if (override && override.trim()) {
54
- const parts = override.trim().split(/\s+/);
55
- // 覆盖值可能是测试桩:仍然让 `add --help` 自己回答能力位,别替它猜
56
- return { command: parts[0], prefixArgs: parts.slice(1), source: 'env-override', jsonCapable: true };
57
- }
58
-
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 依赖(兼容旧插件 / 其他插件已经带进来的版本)。
73
- // 注意:GUI 启动的宿主进程常常拿不到 DSH_PROFILE_DIR,所以再按 DSH_HOME/profiles/* 兜一层探测。
74
- const profileDir = env.DSH_PROFILE_DIR;
75
- if (profileDir) {
76
- const bin = path.join(profileDir, 'node_modules', 'skills', 'bin', 'cli.mjs');
77
- if (fs.existsSync(bin)) {
78
- return { command: process.execPath, prefixArgs: [bin], source: 'profile-dependency', jsonCapable: true };
79
- }
80
- }
81
- const dshHome = env.DSH_HOME || path.join(env.HOME || '', '.dsh');
82
- try {
83
- const profiles = fs.readdirSync(path.join(dshHome, 'profiles'), { withFileTypes: true });
84
- for (const entry of profiles) {
85
- if (!entry.isDirectory()) continue;
86
- const bin = path.join(dshHome, 'profiles', entry.name, 'node_modules', 'skills', 'bin', 'cli.mjs');
87
- if (fs.existsSync(bin)) {
88
- return { command: process.execPath, prefixArgs: [bin], source: `profile:${entry.name}`, jsonCapable: true };
89
- }
90
- }
91
- } catch {
92
- // 没有 profiles 目录就继续往下走
93
- }
94
-
95
- const onPath = findInPath('skills', env.PATH);
96
- if (onPath) return { command: onPath, prefixArgs: [], source: 'path', jsonCapable: true };
97
-
98
- const npx = findInPath('npx', env.PATH);
99
- if (npx) {
100
- const spec = env.DSH_SKILLS_CONSOLE_NPX_SPEC || 'skills';
101
- return { command: npx, prefixArgs: ['-y', spec], source: 'npx', jsonCapable: true };
102
- }
103
-
104
- logger('cli.missing');
105
- throw new CliUnavailableError('neither `skills` nor `npx` is on PATH');
106
- }
107
-
108
- /** 子进程封装:无 shell、argv 直传、硬超时、输出上限、非交互环境注入。 */
109
- export function runCli(location, args, { timeoutMs = 120000, signal, env = process.env, maxBytes = 4 * 1024 * 1024 } = {}) {
110
- return new Promise((resolve, reject) => {
111
- const child = spawn(location.command, [...location.prefixArgs, ...args], {
112
- shell: false,
113
- stdio: ['ignore', 'pipe', 'pipe'],
114
- env: {
115
- ...env,
116
- // 桌面宿主本身就是 Electron 的 Node 模式;显式补上,避免调用方传了裁剪过的 env
117
- ...(process.versions.electron && location.command === process.execPath ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
118
- CI: '1',
119
- NO_COLOR: '1',
120
- // CLI 的遥测只看这两个变量;CI=1 不会关闭,只会在上报里打 ci=1 标记
121
- DO_NOT_TRACK: '1',
122
- DISABLE_TELEMETRY: '1',
123
- TERM: 'dumb',
124
- GIT_TERMINAL_PROMPT: '0',
125
- GIT_HTTP_LOW_SPEED_LIMIT: '1',
126
- GIT_HTTP_LOW_SPEED_TIME: '30',
127
- },
128
- });
129
-
130
- let stdout = '';
131
- let stderr = '';
132
- let timedOut = false;
133
- let truncated = false;
134
- const timer = setTimeout(() => {
135
- timedOut = true;
136
- child.kill('SIGKILL');
137
- }, timeoutMs);
138
- const onAbort = () => child.kill('SIGKILL');
139
- if (signal) signal.addEventListener('abort', onAbort, { once: true });
140
-
141
- child.stdout.on('data', (chunk) => {
142
- if (stdout.length + chunk.length > maxBytes) {
143
- truncated = true;
144
- stdout += chunk.subarray(0, Math.max(0, maxBytes - stdout.length)).toString('utf8');
145
- return;
146
- }
147
- stdout += chunk.toString('utf8');
148
- });
149
- child.stderr.on('data', (chunk) => {
150
- if (stderr.length < 65536) stderr += chunk.toString('utf8');
151
- });
152
-
153
- child.on('error', (error) => {
154
- clearTimeout(timer);
155
- if (signal) signal.removeEventListener('abort', onAbort);
156
- reject(new CliFailedError(error.message, { error }));
157
- });
158
-
159
- child.on('close', (code) => {
160
- clearTimeout(timer);
161
- if (signal) signal.removeEventListener('abort', onAbort);
162
- resolve({ code, stdout, stderr, timedOut, truncated, args });
163
- });
164
- });
165
- }
166
-
167
- export function createCli({ env = process.env, logger = () => {}, resolvePackage } = {}) {
168
- let cached = null;
169
- let capabilities = null;
170
-
171
- function location() {
172
- if (!cached) cached = locateCli({ env, logger, resolvePackage });
173
- return cached;
174
- }
175
-
176
- function call(args, options = {}) {
177
- return runCli(location(), args, { env, ...options });
178
- }
179
-
180
- const api = {
181
- location,
182
- call,
183
-
184
- async version() {
185
- const result = await call(['--version']);
186
- if (result.code !== 0) return null;
187
- const match = result.stdout.trim().match(/\d+\.\d+\.\d+[\w.-]*/);
188
- return match ? match[0] : null;
189
- },
190
-
191
- /** 能力位:`--json` 与 `--full-depth` 任一不支持都要在 UI 上禁用写操作(漏装比不可用更危险)。 */
192
- async capabilities({ force = false } = {}) {
193
- if (capabilities && !force) return capabilities;
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
- }
204
- if (!loc.jsonCapable) {
205
- capabilities = { version: null, json: false, fullDepth: false, source: loc.source, usable: false };
206
- return capabilities;
207
- }
208
- const version = await api.version();
209
- const help = await call(['add', '--help']);
210
- const text = `${help.stdout}\n${help.stderr}`;
211
- capabilities = {
212
- version,
213
- json: text.includes('--json'),
214
- fullDepth: text.includes('--full-depth'),
215
- source: loc.source,
216
- usable: Boolean(version) && text.includes('--json') && text.includes('--full-depth'),
217
- };
218
- logger('cli.capabilities', capabilities);
219
- return capabilities;
220
- },
221
-
222
- /** 列已装技能(全局)。字段只有 F20 那几项,ref/skillPath/hash 必须另读 lock。 */
223
- async listGlobal() {
224
- const result = await call(['list', '--json', '-g']);
225
- if (result.code !== 0) {
226
- throw new CliFailedError(`skills list failed`, { stderr: result.stderr, code: result.code });
227
- }
228
- try {
229
- const parsed = JSON.parse(result.stdout);
230
- return Array.isArray(parsed) ? parsed : [];
231
- } catch (error) {
232
- throw new CliFailedError(`skills list returned non-JSON output`, { error: error.message });
233
- }
234
- },
235
-
236
- /**
237
- * 安装:`-a universal` 必带(F17),`--json` 配 `-y`(F11),`--full-depth` 必带(F24)。
238
- * 技能名留空 = `--skill '*'`(整仓全部);不能留空值的 `--skill`,那会把后面的 `-a` 当成它的参数。
239
- * 永不带 `--all` / `-a '*'`。
240
- */
241
- async add({ source, skills = [], timeoutMs = env.DSH_SKILLS_CONSOLE_TIMEOUT_MS ? Number(env.DSH_SKILLS_CONSOLE_TIMEOUT_MS) : 120000 }) {
242
- const names = skills.length > 0 ? skills : ['*'];
243
- const args = ['add', source, '--skill', ...names, '-a', 'universal', '-g', '-y', '--json', '--full-depth'];
244
- const result = await call(args, { timeoutMs });
245
- return result;
246
- },
247
-
248
- /** 卸载:绝不带 `-a`(带上反而删不掉真身),没有 `--json`,结论以磁盘核对为准。 */
249
- async remove({ names = [], timeoutMs = 120000 }) {
250
- const args = ['remove', '-s', ...names, '-g', '-y'];
251
- return call(args, { timeoutMs });
252
- },
253
- };
254
-
255
- return api;
256
- }
package/lib/core/git.js DELETED
@@ -1,282 +0,0 @@
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
- }