dsh-github-flow 0.2.0 → 0.3.0

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.
Files changed (44) hide show
  1. package/README.en.md +68 -22
  2. package/README.md +69 -23
  3. package/docs/UPGRADE-NOTES.md +93 -0
  4. package/docs/architecture.md +76 -31
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2.png +0 -0
  7. package/docs/images/3.png +0 -0
  8. package/docs/images/4.png +0 -0
  9. package/docs/npm-publish-guide.md +27 -5
  10. package/lib/api/routes-actions.d.ts +18 -4
  11. package/lib/api/routes-actions.js +143 -19
  12. package/lib/api/routes-lifecycle.d.ts +1 -1
  13. package/lib/api/routes-lifecycle.js +53 -2
  14. package/lib/api/routes-overview.js +104 -1
  15. package/lib/client.js +991 -55
  16. package/lib/commands/gh.js +11 -2
  17. package/lib/executor.d.ts +118 -1
  18. package/lib/executor.js +479 -12
  19. package/lib/main.js +10 -0
  20. package/lib/renderers/markdown.d.ts +12 -1
  21. package/lib/renderers/markdown.js +150 -16
  22. package/lib/tools/help.js +144 -11
  23. package/lib/tools/image-utils.d.ts +79 -0
  24. package/lib/tools/image-utils.js +247 -0
  25. package/lib/tools/issue.d.ts +18 -4
  26. package/lib/tools/issue.js +161 -6
  27. package/lib/tools/pr.d.ts +12 -4
  28. package/lib/tools/pr.js +128 -7
  29. package/lib/tools/repo.d.ts +20 -4
  30. package/lib/tools/repo.js +196 -4
  31. package/lib/tools/run.d.ts +8 -0
  32. package/lib/tools/run.js +104 -3
  33. package/lib/tools/secret.d.ts +5 -0
  34. package/lib/tools/secret.js +108 -23
  35. package/lib/types.d.ts +85 -1
  36. package/lib/update-settings.d.ts +18 -0
  37. package/lib/update-settings.js +60 -0
  38. package/lib/updater.d.ts +32 -1
  39. package/lib/updater.js +117 -9
  40. package/lib/upgrade-manager.d.ts +70 -1
  41. package/lib/upgrade-manager.js +346 -46
  42. package/lib/version.d.ts +1 -1
  43. package/lib/version.js +1 -1
  44. package/package.json +1 -1
package/lib/executor.js CHANGED
@@ -1,11 +1,213 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { findMostRecentWorkspacePath, findWorkspacePathBySession, hasGitDir } from './workspace.js';
6
6
  const execFileAsync = promisify(execFile);
7
+ /**
8
+ * spawn 形态的执行通道(stdin 可选注入)。
9
+ *
10
+ * 为什么存在:execFile 的 input 选项在本机(node 22.18.0 + Windows)实测从不写入
11
+ * 子进程 stdin——既无数据也无 EOF,管道永久悬空(gh secret set 等需要 stdin 的
12
+ * 命令会一直等到超时)。最小复现:execFile+input 对 `cmd /c more` 与 node 子进程
13
+ * 均挂起;而 spawn + stdin.end(data) 全部正常。此函数即为后者的工程化封装,
14
+ * 仅在调用方显式传入 stdin 时使用,其余路径保持 execFileAsync 零改动。
15
+ *
16
+ * 为什么超时要自己管:spawn 无 timeout 选项,用 kill + SIGTERM 兜底;
17
+ * 输出按 maxBuffer 手工聚合(上限与 execFile 路径一致,15MB)。
18
+ */
19
+ function spawnWithStdin(ghPath, args, opts) {
20
+ return new Promise((resolve, reject) => {
21
+ const child = spawn(ghPath, args, {
22
+ cwd: opts.cwd,
23
+ env: opts.env,
24
+ windowsHide: true,
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ });
27
+ const outChunks = [];
28
+ const errChunks = [];
29
+ let totalBytes = 0;
30
+ let killed = false;
31
+ let settled = false;
32
+ const timer = setTimeout(() => {
33
+ killed = true;
34
+ child.kill();
35
+ }, opts.timeout);
36
+ const settle = (err) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ if (err)
42
+ reject(err);
43
+ else
44
+ resolve({ stdout: Buffer.concat(outChunks), stderr: Buffer.concat(errChunks), killed });
45
+ };
46
+ // 聚合上限:超出即杀(与 execFile maxBuffer 语义一致;gh 正常输出远小于此)
47
+ const MAX_BYTES = 15 * 1024 * 1024;
48
+ child.stdout?.on('data', (d) => {
49
+ totalBytes += d.length;
50
+ outChunks.push(d);
51
+ if (totalBytes > MAX_BYTES) {
52
+ killed = true;
53
+ child.kill();
54
+ }
55
+ });
56
+ child.stderr?.on('data', (d) => { errChunks.push(d); });
57
+ child.on('error', (err) => {
58
+ // spawn 失败(如 ENOENT)对齐 execFile 的错误形状:code 保留在 Error 上
59
+ const e = Object.assign(new Error(err.message), { code: err.code });
60
+ settle(e);
61
+ });
62
+ child.on('close', (code, signal) => {
63
+ if (killed) {
64
+ // 超时/超量被杀:对齐 execFile 的 Timeout 错误形状(err.killed + signal)
65
+ const e = Object.assign(new Error(`Command timed out after ${opts.timeout}ms`), { killed: true, signal: signal || 'SIGTERM' });
66
+ settle(e);
67
+ return;
68
+ }
69
+ if (code !== 0) {
70
+ const e = Object.assign(new Error(`Command failed with exit code ${code}`), { code, stderr: Buffer.concat(errChunks).toString('utf8').slice(0, 800) });
71
+ settle(e);
72
+ return;
73
+ }
74
+ settle();
75
+ });
76
+ // stdin 注入:end(data) 半关闭(写完即 EOF),子进程流式读取或一次性读取均可
77
+ child.stdin?.end(opts.stdin, (err) => {
78
+ if (err && !settled) {
79
+ const e = Object.assign(new Error(`stdin write failed: ${err.message}`), { code: 'EPIPE' });
80
+ settle(e);
81
+ }
82
+ });
83
+ });
84
+ }
7
85
  /** 认证状态缓存时长。用户手动 `gh auth login/logout` 后最多 30 秒内面板才会反映,属可接受代价 */
8
86
  const AUTH_CACHE_TTL_MS = 30_000;
87
+ /** gh 版本缓存时长。与 authCache 同哲学:gh 升级是分钟级低频事件,30 秒陈旧窗口无实际影响 */
88
+ const GH_VERSION_CACHE_TTL_MS = 30_000;
89
+ /**
90
+ * 从 `gh --version` 输出中解析语义化版本号。
91
+ * 抽成导出纯函数的原因与 truncateOutput 相同:解析逻辑是「看似简单实则易错」的边界区,
92
+ * 必须能用单元测试钉死(不匹配输入返回 null 而非抛错,调用方据此走 fail-open)。
93
+ */
94
+ export function parseGhVersion(output) {
95
+ const m = /gh version (\d+\.\d+\.\d+)/.exec(output || '');
96
+ return m ? m[1] : null;
97
+ }
98
+ /**
99
+ * 数值分段比较两个 gh 版本:a>b 返回 1、a<b 返回 -1、相等返回 0。
100
+ * 为什么不能用字符串比较:`'2.96.0' < '2.100.0'` 在字典序下为 false('1'<'9'),
101
+ * 这会把 2.100 误判为低于 2.96,导致可用命令被错误降级。逐段 Number 比较是唯一正确姿势。
102
+ */
103
+ export function compareGhVersions(a, b) {
104
+ const pa = a.split('.').map(Number);
105
+ const pb = b.split('.').map(Number);
106
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
107
+ const da = pa[i] ?? 0;
108
+ const db = pb[i] ?? 0;
109
+ if (da > db)
110
+ return 1;
111
+ if (da < db)
112
+ return -1;
113
+ }
114
+ return 0;
115
+ }
116
+ /**
117
+ * 解析 `git status -sb --porcelain` 输出为结构化状态。
118
+ * 抽导出纯函数(parseGhVersion 同理):三形态解析是易错边界区,单测钉死。
119
+ *
120
+ * 输出形态(实测):
121
+ * `## master...origin/master [ahead 2, behind 1]` 有 ahead/behind
122
+ * `## master...origin/master` 与远程同步
123
+ * `## master` / `## HEAD (no branch)` 无 upstream(ahead/behind 未知=-1)
124
+ * 后续每行 `<XY> <path>` 为 porcelain 变更项(XY 两字符状态码,?? = untracked)。
125
+ */
126
+ export function parseGitStatusOutput(output) {
127
+ const lines = (output || '').split('\n').filter((l) => l.length > 0);
128
+ if (lines.length === 0 || !lines[0].startsWith('## '))
129
+ return null;
130
+ const branchLine = lines[0].slice(3); // 去掉 '## ' 前缀
131
+ const hasUpstream = branchLine.includes('...');
132
+ const branchPart = hasUpstream ? branchLine.split('...')[0] : branchLine.split(' ')[0];
133
+ let ahead = -1;
134
+ let behind = -1;
135
+ if (hasUpstream) {
136
+ // ahead/behind 段在 upstream 之后(可能与 #id 同现:origin/master #12)。
137
+ // git 输出形态实测:`[ahead 2]` / `[behind 1]` / `[ahead 2, behind 1]`——
138
+ // 单括号内逗号分隔,behind 不必然前邻 '['(正则不可锚定 '[',否则
139
+ // `[ahead 2, behind 1]` 形态的 behind 永远漏解析——单测抓到的真实 Bug)
140
+ const trackingPart = branchLine.split('...')[1] || '';
141
+ ahead = 0;
142
+ behind = 0;
143
+ const aheadMatch = /ahead (\d+)/.exec(trackingPart);
144
+ const behindMatch = /behind (\d+)/.exec(trackingPart);
145
+ if (aheadMatch)
146
+ ahead = Number(aheadMatch[1]);
147
+ if (behindMatch)
148
+ behind = Number(behindMatch[1]);
149
+ // 仅 ahead 无 behind 时 behind=0(反之亦然)——git 输出只列非零侧
150
+ }
151
+ const dirtyFiles = [];
152
+ for (let i = 1; i < lines.length && dirtyFiles.length < 20; i++) {
153
+ const line = lines[i];
154
+ if (line.length < 4)
155
+ continue;
156
+ const status = line.slice(0, 2).trim() || 'M';
157
+ // 路径可能含空格(git 不加引号场景);rename 形态 `R old -> new` 取 new
158
+ let p = line.slice(3);
159
+ const renameArrow = p.indexOf(' -> ');
160
+ if (renameArrow >= 0)
161
+ p = p.slice(renameArrow + 4);
162
+ dirtyFiles.push({ status, path: p });
163
+ }
164
+ return {
165
+ branch: branchPart.replace(/\s*\(no branch\)$/, '') || 'HEAD',
166
+ ahead,
167
+ behind,
168
+ dirtyCount: Math.max(0, lines.length - 1),
169
+ hasUpstream,
170
+ dirtyFiles,
171
+ };
172
+ }
173
+ /**
174
+ * 解析 `git log` 输出为提交条目数组。
175
+ * 支持两种形态:
176
+ * 1. 结构化带详情:%x1e 为 commit 间分隔符,%x1f 为字段间分隔符(含 fullSha/email/date/body/stat);
177
+ * 2. 简易管道符:%h|%an|%ar|%s 行格式(兼容旧单测输入)。
178
+ * 空输出返回 []。
179
+ */
180
+ export function parseGitLogLines(output) {
181
+ const out = (output || '').trim();
182
+ if (!out)
183
+ return [];
184
+ if (out.includes('\x1e') || out.includes('\x1f')) {
185
+ const records = out.split('\x1e').filter((c) => c.trim().length > 0);
186
+ return records.map((chunk) => {
187
+ const parts = chunk.split('\x1f');
188
+ return {
189
+ sha: (parts[0] || '').trim(),
190
+ fullSha: (parts[1] || '').trim() || undefined,
191
+ author: (parts[2] || '').trim(),
192
+ email: (parts[3] || '').trim() || undefined,
193
+ date: (parts[4] || '').trim() || undefined,
194
+ relative: (parts[5] || '').trim(),
195
+ subject: (parts[6] || '').trim(),
196
+ body: (parts[7] || '').trim() || undefined,
197
+ stat: (parts[8] || '').trim() || undefined,
198
+ };
199
+ });
200
+ }
201
+ return out.split('\n').filter(Boolean).map((line) => {
202
+ const parts = line.split('|');
203
+ return {
204
+ sha: parts[0] || '',
205
+ author: parts[1] || '',
206
+ relative: parts[2] || '',
207
+ subject: parts.slice(3).join('|'),
208
+ };
209
+ });
210
+ }
9
211
  /**
10
212
  * 按码点安全截取字符串,等价于 String.prototype.slice 但对孤立的代理对(surrogate pair)做占位替换。
11
213
  * 为什么必须这样截:本文件末尾会按 UTF-8 字节数截断,而 node:child_process 把 stdout 按字节缓冲区
@@ -102,6 +304,12 @@ export class GhExecutor {
102
304
  * 且热更新路径 updateConfig 能同步刷新。工具内以 `?? 20` 兜底保证旧配置兼容。
103
305
  */
104
306
  defaultListLimit;
307
+ /**
308
+ * 是否注入 AI_AGENT='dsh'(gh 官方 agent 识别信号,默认 true)。
309
+ * 挂在 executor 而非工具参数:注入点是「每次 gh 子进程拉起」这一全局咽喉,
310
+ * 与 GH_NO_UPDATE_NOTIFIER/NO_COLOR 同级,语义上属于执行层默认值。
311
+ */
312
+ agentEnvInjection;
105
313
  /**
106
314
  * 代理管理器引用(可选依赖)。
107
315
  * 注入后每次 gh 子进程拉起都会合并代理环境变量——这是「工具调用 + Web 页面数据
@@ -111,12 +319,18 @@ export class GhExecutor {
111
319
  proxyManager;
112
320
  /** 认证状态缓存。`gh auth status` 实测 5.4 秒,而它位于全局驾驶舱关键路径的最前面 */
113
321
  authCache = null;
322
+ /**
323
+ * gh 版本号缓存。只缓存成功解析值(authCache 哲学:不确定结果不缓存)——
324
+ * 探测失败/未安装时不落缓存,用户装好 gh 后下一次探测立即生效。
325
+ */
326
+ ghVersionCache = null;
114
327
  constructor(config = {}) {
115
328
  this.ghPath = config.ghPath || 'gh';
116
329
  this.defaultTimeoutMs = config.defaultTimeoutMs || 30_000;
117
330
  this.maxOutputChars = config.maxOutputChars || 24_000;
118
331
  this.maxOutputBytes = config.maxOutputBytes || this.maxOutputChars * 4;
119
332
  this.defaultListLimit = config.defaultListLimit || 20;
333
+ this.agentEnvInjection = config.agentEnvInjection !== false;
120
334
  }
121
335
  updateConfig(config = {}) {
122
336
  if (config.ghPath)
@@ -125,6 +339,8 @@ export class GhExecutor {
125
339
  this.defaultTimeoutMs = config.defaultTimeoutMs;
126
340
  if (config.defaultListLimit)
127
341
  this.defaultListLimit = config.defaultListLimit;
342
+ if (config.agentEnvInjection !== undefined)
343
+ this.agentEnvInjection = config.agentEnvInjection;
128
344
  if (config.maxOutputChars) {
129
345
  this.maxOutputChars = config.maxOutputChars;
130
346
  if (!config.maxOutputBytes)
@@ -185,11 +401,16 @@ export class GhExecutor {
185
401
  async run(args, options = {}) {
186
402
  const cwd = this.resolveWorkspaceCwd(options.cwd, options.sessionId);
187
403
  const timeout = options.timeoutMs || this.defaultTimeoutMs;
404
+ // 二进制模式(如 gh api 下载图片字节流):stdout 保持 Buffer,绕过 UTF-8
405
+ // 字符串转换(PNG 等二进制经 utf8 解码必然损坏),也不做截断(截断只面向
406
+ // 文本 Token 预算)。显式开启才生效,既有调用方零感知。
407
+ const binary = options.binary === true;
188
408
  try {
189
- const { stdout, stderr } = await execFileAsync(this.ghPath, args, {
409
+ // stdin 显式传入时走 spawn 通道:execFile 的 input 选项在本机从不真正写入
410
+ // (详见 spawnWithStdin 注释),gh secret set 等命令会挂到超时。其余调用
411
+ // 保持 execFileAsync 原路径零改动。
412
+ const commonOpts = {
190
413
  cwd,
191
- timeout,
192
- maxBuffer: 15 * 1024 * 1024, // 15MB 进程缓冲
193
414
  env: {
194
415
  ...process.env,
195
416
  GH_NO_UPDATE_NOTIFIER: '1', // 禁用 CLI 版本更新检查,避免污染 stdout
@@ -197,16 +418,48 @@ export class GhExecutor {
197
418
  // 代理环境变量注入在 options.env 之前:调用方显式传入的 env 仍可覆盖
198
419
  // 代理(保持 options.env 的既有语义优先级),正常调用方不传则代理生效。
199
420
  ...(this.proxyManager?.getEnv('gh') || {}),
421
+ // gh 官方 agent 识别信号(detect.go 最高优先级通用变量):≥2.99 切 text-only
422
+ // spinner、≥2.100 误用时打印完整 help、telemetry 归因到 dsh;<2.99 忽略之。
423
+ // 覆盖继承自宿主环境的同名变量——插件比外层环境更清楚自己的直接驱动者是
424
+ // DSH(嵌套 agent 场景归因不失真);options.env 仍可覆盖(与代理变量同语义)。
425
+ ...(this.agentEnvInjection ? { AI_AGENT: 'dsh' } : {}),
200
426
  ...options.env,
201
427
  },
202
- // 仅在显式传入 stdin 时附加 input 键:promisified execFile 会把 input
203
- // 写入子进程 stdin 后立即结束流。不传则保持既有行为(继承/null 句柄),
204
- // 确保对全部既有调用方零破坏。
205
- ...(options.stdin !== undefined ? { input: options.stdin } : {}),
206
- });
207
- let rawOutput = (stdout || '').trim();
208
- if (!rawOutput && stderr) {
209
- rawOutput = stderr.trim();
428
+ };
429
+ let stdout;
430
+ let stderr;
431
+ if (options.stdin !== undefined) {
432
+ const r = await spawnWithStdin(this.ghPath, args, { ...commonOpts, timeout, stdin: options.stdin });
433
+ stdout = r.stdout;
434
+ stderr = r.stderr;
435
+ }
436
+ else {
437
+ const r = await execFileAsync(this.ghPath, args, {
438
+ ...commonOpts,
439
+ timeout,
440
+ maxBuffer: 15 * 1024 * 1024, // 15MB 进程缓冲
441
+ ...(binary ? { encoding: 'buffer' } : {}),
442
+ });
443
+ stdout = r.stdout;
444
+ stderr = r.stderr;
445
+ }
446
+ // 二进制通道:Buffer 原样返回(调用方自行写文件/校验魔数),stderr 仅作
447
+ // 错误线索不做输出回退——把错误提示字节当图片保存毫无意义。
448
+ // TS 无法从 options 展开的 encoding 键收窄 stdout 类型,这里显式归一化。
449
+ if (binary) {
450
+ const buf = Buffer.isBuffer(stdout) ? stdout : Buffer.from(String(stdout ?? ''));
451
+ const errBuf = Buffer.isBuffer(stderr) ? stderr : (stderr ? Buffer.from(String(stderr)) : null);
452
+ if (!buf.length) {
453
+ const errMsg = errBuf ? errBuf.toString('utf8').trim() : '';
454
+ return { ok: false, error: errMsg || '二进制输出为空' };
455
+ }
456
+ return { ok: true, data: buf, binaryBytes: buf.length, truncated: false };
457
+ }
458
+ const outText = Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout ?? '');
459
+ const errText = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : String(stderr ?? '');
460
+ let rawOutput = outText.trim();
461
+ if (!rawOutput && errText) {
462
+ rawOutput = errText.trim();
210
463
  }
211
464
  const outcome = truncateOutput(rawOutput, this.maxOutputChars, this.maxOutputBytes);
212
465
  rawOutput = outcome.text;
@@ -267,6 +520,220 @@ export class GhExecutor {
267
520
  }
268
521
  return value;
269
522
  }
523
+ /**
524
+ * 获取当前 gh 版本号(带 30s TTL 缓存),失败返回 null。
525
+ * 底层直连 execFileAsync 而不走 this.run():run() 会先解析工作区(可能读
526
+ * workspace.json 与目录扫描)再做截断/JSON 解析,对一次 10ms 的版本字符串
527
+ * 查询是纯开销;直连也彻底规避 runGuarded → 探测 → run 的递归风险。
528
+ */
529
+ async getGhVersion() {
530
+ const now = Date.now();
531
+ if (this.ghVersionCache && now - this.ghVersionCache.at < GH_VERSION_CACHE_TTL_MS) {
532
+ return this.ghVersionCache.value;
533
+ }
534
+ const version = parseGhVersion(await this.probeGhVersionRaw());
535
+ // 只缓存成功解析值:失败不落缓存,用户装好 gh 后下一次探测立即生效
536
+ if (version) {
537
+ this.ghVersionCache = { at: now, value: version };
538
+ }
539
+ return version;
540
+ }
541
+ /**
542
+ * 底层版本探测(独立方法而非内联:测试需要在不触发真实子进程的前提下
543
+ * 验证 getGhVersion 的缓存分支——覆写此方法即注入确定输出)。
544
+ * 直连 execFileAsync 而不走 this.run():run() 会先解析工作区(可能读
545
+ * workspace.json 与目录扫描)再做截断/JSON 解析,对一次 10ms 的版本字符串
546
+ * 查询是纯开销;直连也彻底规避 runGuarded → 探测 → run 的递归风险。
547
+ */
548
+ async probeGhVersionRaw() {
549
+ try {
550
+ const { stdout } = await execFileAsync(this.ghPath, ['--version'], {
551
+ timeout: 3_000,
552
+ windowsHide: true,
553
+ });
554
+ return String(stdout);
555
+ }
556
+ catch {
557
+ // 未安装/超时/PATH 抖动:返回空串,由 parseGhVersion 归一为 null → fail-open
558
+ return '';
559
+ }
560
+ }
561
+ /**
562
+ * 守卫执行器:按 gh 版本门槛路由「主命令 → 降级链路 → 升级指引」三态。
563
+ *
564
+ * 为什么是「检查+执行」一体的方法而非纯探测接口:检查与执行分离会在两者之间
565
+ * 留下竞隙(版本可能在间隙变化),且降级标注逻辑会被迫散落到每个工具的 if/else。
566
+ *
567
+ * fail-open 纪律:版本探测自身失败(null)时直接执行主命令——守卫是路由优化
568
+ * 不是安全边界,探测故障不应拦截业务;gh 未安装场景由主命令产出既有 ENOENT
569
+ * 安装引导,这里不重复造错误路径。
570
+ */
571
+ async runGuarded(args, options = {}) {
572
+ const { minVersion, capability, fallback, ...runOptions } = options;
573
+ if (!minVersion) {
574
+ return this.run(args, runOptions);
575
+ }
576
+ const current = await this.getGhVersion();
577
+ // 探测失败 fail-open:直接执行主命令(见方法注释),错误路径交由主命令的既有处理
578
+ if (current === null || compareGhVersions(current, minVersion) >= 0) {
579
+ return this.run(args, runOptions);
580
+ }
581
+ if (fallback) {
582
+ const res = await fallback();
583
+ return {
584
+ ...res,
585
+ degraded: true,
586
+ degradedReason: `当前 gh ${current} 低于 ${capability || minVersion} 所需版本 ${minVersion},已切换 API 降级链路`,
587
+ };
588
+ }
589
+ return {
590
+ ok: false,
591
+ error: `此能力需要 gh ≥ ${minVersion}(当前 ${current})。${this.getGhUpgradeHint()}`,
592
+ };
593
+ }
594
+ /** gh 升级命令按平台提示(复用三态状态机的平台判断口径) */
595
+ getGhUpgradeHint() {
596
+ const p = process.platform;
597
+ if (p === 'win32')
598
+ return '升级命令:`winget upgrade GitHub.cli`(或从 https://cli.github.com/ 下载最新 .msi)。';
599
+ if (p === 'darwin')
600
+ return '升级命令:`brew upgrade gh`(或从 https://cli.github.com/ 下载最新安装包)。';
601
+ return '升级方式:更新系统包(Debian/Ubuntu: `sudo apt update && sudo apt install gh`;Fedora: `sudo dnf upgrade gh`),或见 https://cli.github.com/。';
602
+ }
603
+ /**
604
+ * 本地 Git 状态只读探测(白名单通道)。
605
+ *
606
+ * 这是本插件首次执行 git(此前仅 gh)——git 存在大量写命令,风险等级高于 gh,
607
+ * 因此白名单不落在「命令名过滤」而落在「命令形态常量」:spawn 参数是下方
608
+ * 硬编码的数组字面量,无任何动态拼接路径,写操作物理不可达(cwd 之外零输入,
609
+ * 而 cwd 本身也是插件解析的工作区路径)。
610
+ *
611
+ * 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
612
+ * 符合 global-overview 端点「工作区探测不发网络」的性能红线。
613
+ */
614
+ async getLocalGitStatus(cwd) {
615
+ try {
616
+ // 纯本地毫秒级;3s 超时兜底异常文件系统
617
+ const { stdout } = await execFileAsync('git', ['status', '-sb', '--porcelain'], {
618
+ cwd: cwd || undefined,
619
+ timeout: 3_000,
620
+ windowsHide: true,
621
+ });
622
+ return parseGitStatusOutput(String(stdout));
623
+ }
624
+ catch {
625
+ // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方不渲染该行)
626
+ return null;
627
+ }
628
+ }
629
+ /**
630
+ * 本地 Git 提交记录(三段一次返回:本地领先 / 远程领先 / 最近提交)。
631
+ *
632
+ * 白名单纪律与 getLocalGitStatus 同源:下方两个命令形态常量硬编码
633
+ * (log + 修订范围语法,range 段由 hasUpstream 守卫后才拼接且仅两个
634
+ * 固定形态 @{u}..HEAD / HEAD..@{u}——不含任何用户输入路径)。
635
+ * 领先/落后列表基于 remote-tracking ref(上次 fetch 快照),与 status
636
+ * 的 ahead/behind 计数同一数据口径;格式 %h|%an|%ar|%s 实测可结构化解析。
637
+ */
638
+ async getGitCommitLog(cwd, hasUpstream = true, limit = 15) {
639
+ const empty = { aheadCommits: [], behindCommits: [], recent: [] };
640
+ const logFmt = '--pretty=format:%x1e%h%x1f%H%x1f%an%x1f%ae%x1f%ad%x1f%ar%x1f%s%x1f%b%x1f';
641
+ try {
642
+ const recentRes = await execFileAsync('git', ['log', `-${Math.max(1, limit)}`, logFmt, '--stat', '--no-decorate'], {
643
+ cwd: cwd || undefined,
644
+ timeout: 4_000,
645
+ windowsHide: true,
646
+ });
647
+ empty.recent = parseGitLogLines(String(recentRes.stdout));
648
+ try {
649
+ const countRes = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], {
650
+ cwd: cwd || undefined,
651
+ timeout: 2_000,
652
+ windowsHide: true,
653
+ });
654
+ const cnt = parseInt(String(countRes.stdout).trim(), 10);
655
+ if (!isNaN(cnt))
656
+ empty.totalCount = cnt;
657
+ }
658
+ catch { }
659
+ if (!hasUpstream)
660
+ return empty; // 无 upstream:领先/落后无从谈起,仅最近提交
661
+ const aheadRes = await execFileAsync('git', ['log', '@{u}..HEAD', logFmt, '--stat', '--no-decorate'], {
662
+ cwd: cwd || undefined,
663
+ timeout: 3_000,
664
+ windowsHide: true,
665
+ });
666
+ empty.aheadCommits = parseGitLogLines(String(aheadRes.stdout));
667
+ const behindRes = await execFileAsync('git', ['log', 'HEAD..@{u}', logFmt, '--stat', '--no-decorate'], {
668
+ cwd: cwd || undefined,
669
+ timeout: 3_000,
670
+ windowsHide: true,
671
+ });
672
+ empty.behindCommits = parseGitLogLines(String(behindRes.stdout));
673
+ return empty;
674
+ }
675
+ catch {
676
+ return null;
677
+ }
678
+ }
679
+ /**
680
+ * 分页获取最近提交记录(用于 Git 面板下滑触底继续加载)。
681
+ */
682
+ async getGitRecentLog(cwd, limit = 15, skip = 0) {
683
+ const logFmt = '--pretty=format:%x1e%h%x1f%H%x1f%an%x1f%ae%x1f%ad%x1f%ar%x1f%s%x1f%b%x1f';
684
+ const safeLimit = Math.min(Math.max(1, limit), 50);
685
+ const safeSkip = Math.max(0, skip);
686
+ const args = ['log', `-${safeLimit}`, logFmt, '--stat', '--no-decorate'];
687
+ if (safeSkip > 0) {
688
+ args.push('--skip', String(safeSkip));
689
+ }
690
+ try {
691
+ const { stdout } = await execFileAsync('git', args, {
692
+ cwd: cwd || undefined,
693
+ timeout: 5_000,
694
+ windowsHide: true,
695
+ });
696
+ const commits = parseGitLogLines(String(stdout));
697
+ let totalCount;
698
+ try {
699
+ const countRes = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], {
700
+ cwd: cwd || undefined,
701
+ timeout: 2_000,
702
+ windowsHide: true,
703
+ });
704
+ const cnt = parseInt(String(countRes.stdout).trim(), 10);
705
+ if (!isNaN(cnt))
706
+ totalCount = cnt;
707
+ }
708
+ catch { }
709
+ return { commits, totalCount };
710
+ }
711
+ catch {
712
+ return { commits: [] };
713
+ }
714
+ }
715
+ /**
716
+ * 单条提交详情(点击提交行弹窗的数据源)。
717
+ * 白名单纪律延续:show 命令形态常量硬编码;sha 参数经 ^[0-9a-f]{4,40}$
718
+ * 十六进制校验后才拼接——这是 git 白名单通道首次接受「外部传入的单值」,
719
+ * 正则把值域锁死在纯十六进制(注入面为零:任何 shell 元字符/子命令
720
+ * 语法都不可能通过校验)。输出为 git show --stat 原文(含文件变更统计)。
721
+ */
722
+ async getGitCommitDetail(cwd, sha) {
723
+ if (!/^[0-9a-f]{4,40}$/i.test(sha))
724
+ return null; // 形态校验:纯十六进制短/全 sha
725
+ try {
726
+ const { stdout } = await execFileAsync('git', ['show', '--stat', '--pretty=format:%H%n%an <%ae>%n%ad%n%s%n%b', sha], {
727
+ cwd: cwd || undefined,
728
+ timeout: 3_000,
729
+ windowsHide: true,
730
+ });
731
+ return String(stdout);
732
+ }
733
+ catch {
734
+ return null;
735
+ }
736
+ }
270
737
  /** 生成当前操作系统平台的推荐安装引导与命令 */
271
738
  getPlatformInstallGuide() {
272
739
  const p = process.platform;
package/lib/main.js CHANGED
@@ -14,6 +14,7 @@ import { UpdateChecker } from './updater.js';
14
14
  import { UpgradeManager } from './upgrade-manager.js';
15
15
  import { ProxyManager } from './proxy.js';
16
16
  import { NPM_PACKAGE_URL } from './version.js';
17
+ import { loadUpdateSettings } from './update-settings.js';
17
18
  // 引用即生效:加载本模块以挂上 declare module '@deepseek-ai/cordis' 的 ctx.github 类型声明,
18
19
  // 并随下面 `export * from './github-service.js'` 一起传给消费方。
19
20
  import './github-service.js';
@@ -44,6 +45,7 @@ export const Config = {
44
45
  cacheTtlMs: typeof cfg.cacheTtlMs === 'number' ? cfg.cacheTtlMs : 15_000,
45
46
  defaultListLimit: typeof cfg.defaultListLimit === 'number' ? cfg.defaultListLimit : 20,
46
47
  checkUpdate: typeof cfg.checkUpdate === 'boolean' ? cfg.checkUpdate : true,
48
+ agentEnvInjection: cfg.agentEnvInjection !== false,
47
49
  updateCheckIntervalMs: typeof cfg.updateCheckIntervalMs === 'number' ? cfg.updateCheckIntervalMs : 86_400_000,
48
50
  registryUrl: typeof cfg.registryUrl === 'string' ? cfg.registryUrl : undefined,
49
51
  proxyUrl: typeof cfg.proxyUrl === 'string' ? cfg.proxyUrl : undefined,
@@ -62,10 +64,18 @@ export function apply(ctx, config = {}) {
62
64
  const updateChecker = new UpdateChecker({
63
65
  intervalMs: config.updateCheckIntervalMs,
64
66
  customRegistry: config.registryUrl,
67
+ // checkUpdate: false 熔断全部探测入口(路由/命令/启动横幅共用此实例)
68
+ enabled: config.checkUpdate !== false,
65
69
  });
70
+ // 更新源设置(UI 持久化 store 优先于 Cordis 静态配置):
71
+ // 启动时读取并应用到 checker;UI 保存时经路由直接调 applySettings 热切换。
72
+ const updateMode = loadUpdateSettings();
73
+ updateChecker.applySettings(updateMode);
66
74
  updateChecker.applyProxy(proxyManager);
67
75
  const upgradeManager = new UpgradeManager();
68
76
  upgradeManager.attachProxyManager(proxyManager);
77
+ // 注入查询函数而非静态值:用户切源后,下一次一键升级的 registry 实时跟随
78
+ upgradeManager.attachUpdateModeQuery(() => updateChecker.getSourceMode());
69
79
  // 1. 兑现生态服务契约:将 GitHub 执行引擎作为服务暴露给其他 DSH 插件 (ctx.github)
70
80
  if (ctx.provide && typeof ctx.provide === 'function') {
71
81
  ctx.provide('github', executor);
@@ -11,10 +11,21 @@
11
11
  export declare function sanitizeCell(text: any, maxLen?: number): string;
12
12
  /** 格式化 PR / Issue 状态药丸徽章 */
13
13
  export declare function formatStateBadge(state?: string, isDraft?: boolean): string;
14
- /** 格式化 CI 工作流状态徽章 */
14
+ /**
15
+ * 格式化 CI 工作流状态徽章。
16
+ * 第二参数按调用域语义不同:run 域传 conclusion(success/failure/cancelled/skipped),
17
+ * pr checks 域传 bucket(pass/fail/pending/skipping/cancel)——取值族不同但映射到
18
+ * 同一组徽章;state 参数兜底(in_progress/queued 等进行中态)。
19
+ */
15
20
  export declare function formatCiBadge(status?: string, conclusion?: string): string;
16
21
  /** 格式化合并冲突状态 */
17
22
  export declare function formatConflictBadge(status?: string): string;
23
+ /**
24
+ * 降级链路可见标注(runGuarded 契约的渲染兑现)。
25
+ * 为什么必须可见:降级若是静默的,Agent 就无法感知能力边界(同一 action 在
26
+ * 不同 gh 版本上走不同链路);一条 blockquote 注释成本一行,换链路透明。
27
+ */
28
+ export declare function appendDegradedNote(value: any): string;
18
29
  export declare function renderPrMarkdown(args: any, value: any): string;
19
30
  export declare function renderIssueMarkdown(args: any, value: any): string;
20
31
  export declare function renderRunMarkdown(args: any, value: any): string;