dsh-github-flow 0.2.1 → 0.4.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 (102) hide show
  1. package/README.en.md +234 -130
  2. package/README.md +217 -112
  3. package/docs/UPGRADE-NOTES.md +15 -9
  4. package/docs/architecture.md +120 -108
  5. package/docs/images/2.png +0 -0
  6. package/docs/npm-publish-guide.md +12 -1
  7. package/lib/api/routes-actions.js +117 -0
  8. package/lib/api/routes-overview.js +198 -3
  9. package/lib/cache.d.ts +4 -0
  10. package/lib/cache.js +14 -0
  11. package/lib/client.d.ts +13 -1
  12. package/lib/client.js +3162 -2162
  13. package/lib/executor/git-parser.d.ts +25 -0
  14. package/lib/executor/git-parser.js +143 -0
  15. package/lib/executor/platform-guide.d.ts +14 -0
  16. package/lib/executor/platform-guide.js +40 -0
  17. package/lib/executor/process-utils.d.ts +51 -0
  18. package/lib/executor/process-utils.js +162 -0
  19. package/lib/executor/version-utils.d.ts +12 -0
  20. package/lib/executor/version-utils.js +27 -0
  21. package/lib/executor.d.ts +131 -17
  22. package/lib/executor.js +411 -223
  23. package/lib/git-watcher.d.ts +36 -0
  24. package/lib/git-watcher.js +91 -0
  25. package/lib/main.js +11 -0
  26. package/lib/renderers/cache.d.ts +8 -0
  27. package/lib/renderers/cache.js +66 -0
  28. package/lib/renderers/common.d.ts +24 -0
  29. package/lib/renderers/common.js +88 -0
  30. package/lib/renderers/gist.d.ts +15 -0
  31. package/lib/renderers/gist.js +99 -0
  32. package/lib/renderers/issue.d.ts +4 -0
  33. package/lib/renderers/issue.js +70 -0
  34. package/lib/renderers/label.d.ts +4 -0
  35. package/lib/renderers/label.js +48 -0
  36. package/lib/renderers/markdown.d.ts +15 -26
  37. package/lib/renderers/markdown.js +15 -517
  38. package/lib/renderers/pr.d.ts +4 -0
  39. package/lib/renderers/pr.js +107 -0
  40. package/lib/renderers/release.d.ts +4 -0
  41. package/lib/renderers/release.js +74 -0
  42. package/lib/renderers/repo.d.ts +4 -0
  43. package/lib/renderers/repo.js +149 -0
  44. package/lib/renderers/ruleset.d.ts +4 -0
  45. package/lib/renderers/ruleset.js +74 -0
  46. package/lib/renderers/run.d.ts +4 -0
  47. package/lib/renderers/run.js +79 -0
  48. package/lib/renderers/skill.d.ts +4 -0
  49. package/lib/renderers/skill.js +61 -0
  50. package/lib/renderers/status.d.ts +23 -0
  51. package/lib/renderers/status.js +124 -0
  52. package/lib/tools/cache.d.ts +64 -0
  53. package/lib/tools/cache.js +186 -0
  54. package/lib/tools/gist.d.ts +155 -0
  55. package/lib/tools/gist.js +190 -0
  56. package/lib/tools/help.js +233 -11
  57. package/lib/tools/image-utils.d.ts +8 -0
  58. package/lib/tools/image-utils.js +34 -0
  59. package/lib/tools/issue.d.ts +17 -1
  60. package/lib/tools/issue.js +114 -8
  61. package/lib/tools/label.d.ts +80 -0
  62. package/lib/tools/label.js +237 -0
  63. package/lib/tools/pr.d.ts +12 -0
  64. package/lib/tools/pr.js +96 -4
  65. package/lib/tools/repo.d.ts +8 -0
  66. package/lib/tools/repo.js +127 -3
  67. package/lib/tools/ruleset.d.ts +80 -0
  68. package/lib/tools/ruleset.js +177 -0
  69. package/lib/tools/run.d.ts +8 -0
  70. package/lib/tools/run.js +104 -3
  71. package/lib/tools/secret.d.ts +5 -0
  72. package/lib/tools/secret.js +108 -23
  73. package/lib/tools/status.d.ts +39 -0
  74. package/lib/tools/status.js +53 -0
  75. package/lib/types.d.ts +112 -0
  76. package/lib/ui/auth-guide-card.d.ts +4 -0
  77. package/lib/ui/auth-guide-card.js +161 -0
  78. package/lib/ui/branch-selector.d.ts +10 -0
  79. package/lib/ui/branch-selector.js +163 -0
  80. package/lib/ui/commit-modal.d.ts +8 -0
  81. package/lib/ui/commit-modal.js +89 -0
  82. package/lib/ui/icons.d.ts +7 -0
  83. package/lib/ui/icons.js +53 -0
  84. package/lib/ui/network-banner.d.ts +10 -0
  85. package/lib/ui/network-banner.js +54 -0
  86. package/lib/ui/storage.d.ts +26 -0
  87. package/lib/ui/storage.js +134 -0
  88. package/lib/ui/styles.d.ts +6 -0
  89. package/lib/ui/styles.js +1285 -0
  90. package/lib/ui/sync-settings-modal.d.ts +11 -0
  91. package/lib/ui/sync-settings-modal.js +350 -0
  92. package/lib/ui/sync-status-badge.d.ts +8 -0
  93. package/lib/ui/sync-status-badge.js +64 -0
  94. package/lib/ui/upgrade-card.d.ts +7 -0
  95. package/lib/ui/upgrade-card.js +197 -0
  96. package/lib/ui/utils.d.ts +10 -0
  97. package/lib/ui/utils.js +66 -0
  98. package/lib/ui/workspace-panel.d.ts +4 -0
  99. package/lib/ui/workspace-panel.js +464 -0
  100. package/lib/version.d.ts +1 -1
  101. package/lib/version.js +1 -1
  102. package/package.json +1 -1
package/lib/executor.js CHANGED
@@ -1,172 +1,20 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { findMostRecentWorkspacePath, findWorkspacePathBySession, hasGitDir } from './workspace.js';
7
+ // 子模块纯函数解耦导入并对外部 Re-export 保证 100% 向后兼容
8
+ import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
9
+ import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
10
+ import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines } from './executor/git-parser.js';
11
+ import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
12
+ export { parseGhVersion, compareGhVersions, spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput, parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, getPlatformInstallGuide, getGhUpgradeHint, };
6
13
  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
- }
85
14
  /** 认证状态缓存时长。用户手动 `gh auth login/logout` 后最多 30 秒内面板才会反映,属可接受代价 */
86
15
  const AUTH_CACHE_TTL_MS = 30_000;
87
- /**
88
- * 按码点安全截取字符串,等价于 String.prototype.slice 但对孤立的代理对(surrogate pair)做占位替换。
89
- * 为什么必须这样截:本文件末尾会按 UTF-8 字节数截断,而 node:child_process 把 stdout 按字节缓冲区
90
- * 切分后再 toString('utf8'),一个汉字被从中间切开时会变成 U+FFFD 替换字符。直接用 slice 会把半截
91
- * 代理对留在结果里,导致下游 JSON.stringify 产出孤立转义序列、写入文件时编码报错。
92
- */
93
- function safeSlice(input, start, end) {
94
- const chars = Array.from(input);
95
- const from = Math.max(0, start);
96
- const to = end === undefined ? chars.length : Math.max(from, end);
97
- return chars.slice(from, to).map((c) => (c.length === 1 && c.charCodeAt(0) >= 0xd800 && c.charCodeAt(0) <= 0xdfff ? '\ufffd' : c)).join('');
98
- }
99
- /**
100
- * 以 UTF-8 字节数(而非字符串长度)为口径,从两端保留内容。
101
- * 为什么需要:maxOutputChars 数的是字符,中文一个字占 3 字节。只按字符限长时,
102
- * 中文环境下真实放行的字节量可达标称值的数倍,Token 预算保护会失真。
103
- * 采用二分查找定位截断点,避免对超大输出逐字符累加造成 O(n) 反复编码。
104
- */
105
- function cutByUtf8Bytes(input, headBytes, tailBytes) {
106
- const encoder = new TextEncoder();
107
- const totalBytes = encoder.encode(input).length;
108
- if (totalBytes <= headBytes + tailBytes) {
109
- return { head: input, tail: '', keptHeadBytes: totalBytes, keptTailBytes: 0, truncated: false };
110
- }
111
- const chars = Array.from(input);
112
- const atByte = (limit, fromEnd) => {
113
- let lo = 0;
114
- let hi = chars.length;
115
- let bytes = 0;
116
- while (lo < hi) {
117
- const mid = (lo + hi + 1) >> 1;
118
- const slice = fromEnd ? chars.slice(chars.length - mid) : chars.slice(0, mid);
119
- const size = encoder.encode(slice.join('')).length;
120
- if (size <= limit) {
121
- lo = mid;
122
- bytes = size;
123
- }
124
- else {
125
- hi = mid - 1;
126
- }
127
- }
128
- return { count: lo, bytes };
129
- };
130
- const headPart = atByte(headBytes, false);
131
- const tailPart = atByte(tailBytes, true);
132
- return {
133
- head: safeSlice(input, 0, headPart.count),
134
- tail: safeSlice(input, chars.length - tailPart.count),
135
- keptHeadBytes: headPart.bytes,
136
- keptTailBytes: tailPart.bytes,
137
- truncated: true,
138
- };
139
- }
140
- /**
141
- * 按「字符数 + UTF-8 字节数」双口径截断输出。
142
- * 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
143
- * 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
144
- */
145
- export function truncateOutput(input, maxChars, maxBytes) {
146
- // 双重口径限长:字符数守住 Token 预算,字节数守住真实传输量。
147
- // 中文场景下两者会分别触发,因此两条路径的提示文案必须区分,避免用户按错误的配置项去调参。
148
- if (input.length > maxChars) {
149
- // 保留前 1/3 字符和尾部 1/2 字符,保留开头上下文和错误尾部
150
- const headLen = Math.floor(maxChars * 0.35);
151
- const tailLen = Math.floor(maxChars * 0.55);
152
- const head = safeSlice(input, 0, headLen);
153
- const tail = safeSlice(input, input.length - tailLen);
154
- return {
155
- text: `${head}\n\n... [⚠️ 提示:内容过长已由 DSH GitHub 插件截断中间部分,当前截取前 ${Math.round(headLen / 1024)}KB 与后 ${Math.round(tailLen / 1024)}KB] ...\n\n${tail}`,
156
- truncated: true,
157
- };
158
- }
159
- const byteCut = cutByUtf8Bytes(input, Math.floor(maxBytes * 0.35), Math.floor(maxBytes * 0.55));
160
- if (byteCut.truncated) {
161
- // 这里必须用「实际保留的字节数」而非 maxChars 推导值:字节口径触发时 maxChars 往往远大于
162
- // 字节上限,早先版本照抄字符分支的算法,会报出与实际严重不符的「已保留 100KB」(实际仅数百字节)。
163
- return {
164
- text: `${byteCut.head}\n\n... [⚠️ 提示:内容超出 UTF-8 字节上限 (${maxBytes} bytes) 已截断中间部分,实际保留前 ${byteCut.keptHeadBytes} 字节与后 ${byteCut.keptTailBytes} 字节;如需放宽请调大配置项 maxOutputBytes] ...\n\n${byteCut.tail}`,
165
- truncated: true,
166
- };
167
- }
168
- return { text: input, truncated: false };
169
- }
16
+ /** gh 版本缓存时长。与 authCache 同哲学:gh 升级是分钟级低频事件,30 秒陈旧窗口无实际影响 */
17
+ const GH_VERSION_CACHE_TTL_MS = 30_000;
170
18
  export class GhExecutor {
171
19
  ghPath;
172
20
  defaultTimeoutMs;
@@ -180,6 +28,12 @@ export class GhExecutor {
180
28
  * 且热更新路径 updateConfig 能同步刷新。工具内以 `?? 20` 兜底保证旧配置兼容。
181
29
  */
182
30
  defaultListLimit;
31
+ /**
32
+ * 是否注入 AI_AGENT='dsh'(gh 官方 agent 识别信号,默认 true)。
33
+ * 挂在 executor 而非工具参数:注入点是「每次 gh 子进程拉起」这一全局咽喉,
34
+ * 与 GH_NO_UPDATE_NOTIFIER/NO_COLOR 同级,语义上属于执行层默认值。
35
+ */
36
+ agentEnvInjection;
183
37
  /**
184
38
  * 代理管理器引用(可选依赖)。
185
39
  * 注入后每次 gh 子进程拉起都会合并代理环境变量——这是「工具调用 + Web 页面数据
@@ -189,12 +43,18 @@ export class GhExecutor {
189
43
  proxyManager;
190
44
  /** 认证状态缓存。`gh auth status` 实测 5.4 秒,而它位于全局驾驶舱关键路径的最前面 */
191
45
  authCache = null;
46
+ /**
47
+ * gh 版本号缓存。只缓存成功解析值(authCache 哲学:不确定结果不缓存)——
48
+ * 探测失败/未安装时不落缓存,用户装好 gh 后下一次探测立即生效。
49
+ */
50
+ ghVersionCache = null;
192
51
  constructor(config = {}) {
193
52
  this.ghPath = config.ghPath || 'gh';
194
53
  this.defaultTimeoutMs = config.defaultTimeoutMs || 30_000;
195
54
  this.maxOutputChars = config.maxOutputChars || 24_000;
196
55
  this.maxOutputBytes = config.maxOutputBytes || this.maxOutputChars * 4;
197
56
  this.defaultListLimit = config.defaultListLimit || 20;
57
+ this.agentEnvInjection = config.agentEnvInjection !== false;
198
58
  }
199
59
  updateConfig(config = {}) {
200
60
  if (config.ghPath)
@@ -203,6 +63,8 @@ export class GhExecutor {
203
63
  this.defaultTimeoutMs = config.defaultTimeoutMs;
204
64
  if (config.defaultListLimit)
205
65
  this.defaultListLimit = config.defaultListLimit;
66
+ if (config.agentEnvInjection !== undefined)
67
+ this.agentEnvInjection = config.agentEnvInjection;
206
68
  if (config.maxOutputChars) {
207
69
  this.maxOutputChars = config.maxOutputChars;
208
70
  if (!config.maxOutputBytes)
@@ -216,41 +78,28 @@ export class GhExecutor {
216
78
  this.proxyManager = manager;
217
79
  }
218
80
  /**
219
- * 智能解析最可靠的工作区物理路径:
220
- * 1. 优先使用显式指定的有效 cwd(若包含 .git 目录)
221
- * 2. 使用显式传入的 sessionId 或环境变量 DSH_SESSION_ID 精确反查工作区
222
- * 3. 递归探测:若传入了 explicitCwd(如聚合父目录),自动检查其直系子目录中是否包含 Git 仓库
223
- * 4. 匹配 ~/.dsh/storages/workspace.json 中最近活跃且存在 .git 的仓库工作区
224
- * 5. 回退到 process.cwd()
81
+ * 解析命令实际执行的工作区根目录
82
+ * 严格遵循数据事实源与工作区物理所有权,消除越权篡改:
83
+ * 1. 显式路径第一优先级:调用方只要显式指定了路径,该物理路径即为唯一事实源,原样返回自身;
84
+ * 绝不递归扫描子目录、绝不越权篡改;无 Git 目录则让下游探测真实反映无 Git 状态;
85
+ * 2. 仅在未显式指定 cwd 时,才尝试使用 sessionId 或环境变量 DSH_SESSION_ID 反查工作区;
86
+ * 3. 仅在完全未指定 cwd 且无会话时(全局命令行调用),才兜底回退到最近活跃的 Git 工作区或 process.cwd()。
225
87
  */
226
88
  resolveWorkspaceCwd(explicitCwd, explicitSessionId) {
227
- // 1. 显式路径优先,若有效且直接包含 .git 目录,则直接使用
228
- if (explicitCwd && hasGitDir(explicitCwd)) {
89
+ // 1. 显式路径第一优先级:只要显式指定了路径且在磁盘真实存在,原样返回该目录作为唯一事实源!
90
+ // 绝不进入子目录去“借用”子项目的 Git 仓库,杜绝在聚合目录或未绑定工作区中张冠李戴!
91
+ if (explicitCwd && fs.existsSync(explicitCwd)) {
229
92
  return explicitCwd;
230
93
  }
231
- // 2. 使用显式传入的 sessionId 或环境变量中的 DSH_SESSION_ID 反查
94
+ // 2. 仅在未显式指定 cwd 时,使用显式传入的 sessionId 或环境变量中的 DSH_SESSION_ID 反查
232
95
  const targetSessionId = explicitSessionId || process.env.DSH_SESSION_ID;
233
96
  if (targetSessionId) {
234
- const bySession = findWorkspacePathBySession(targetSessionId);
235
- if (bySession && hasGitDir(bySession))
236
- return bySession;
237
- }
238
- // 3. 递归探测:若传入了 explicitCwd(如非 Git 的父工作区目录),检查其一级子目录中是否存在 Git 仓库
239
- if (explicitCwd && fs.existsSync(explicitCwd)) {
240
- try {
241
- const entries = fs.readdirSync(explicitCwd, { withFileTypes: true });
242
- for (const entry of entries) {
243
- if (entry.isDirectory()) {
244
- const subDir = path.join(explicitCwd, entry.name);
245
- if (hasGitDir(subDir)) {
246
- return subDir;
247
- }
248
- }
249
- }
97
+ const sessionPath = findWorkspacePathBySession(targetSessionId);
98
+ if (sessionPath && fs.existsSync(sessionPath)) {
99
+ return sessionPath;
250
100
  }
251
- catch { }
252
101
  }
253
- // 4. 从工作区配置中匹配最近活跃且明确具备 .git 的仓库路径
102
+ // 3. 仅在完全没有显式指定目录与会话工作区(如无上下文的全局调用)时,才从工作区配置中匹配最近活跃且明确具备 .git 的仓库路径
254
103
  const recent = findMostRecentWorkspacePath();
255
104
  if (recent)
256
105
  return recent;
@@ -280,6 +129,11 @@ export class GhExecutor {
280
129
  // 代理环境变量注入在 options.env 之前:调用方显式传入的 env 仍可覆盖
281
130
  // 代理(保持 options.env 的既有语义优先级),正常调用方不传则代理生效。
282
131
  ...(this.proxyManager?.getEnv('gh') || {}),
132
+ // gh 官方 agent 识别信号(detect.go 最高优先级通用变量):≥2.99 切 text-only
133
+ // spinner、≥2.100 误用时打印完整 help、telemetry 归因到 dsh;<2.99 忽略之。
134
+ // 覆盖继承自宿主环境的同名变量——插件比外层环境更清楚自己的直接驱动者是
135
+ // DSH(嵌套 agent 场景归因不失真);options.env 仍可覆盖(与代理变量同语义)。
136
+ ...(this.agentEnvInjection ? { AI_AGENT: 'dsh' } : {}),
283
137
  ...options.env,
284
138
  },
285
139
  };
@@ -370,58 +224,368 @@ export class GhExecutor {
370
224
  return this.authCache.value;
371
225
  }
372
226
  const value = await this.probeAuth(cwd);
373
- // 只缓存确定的结果:出错时(未安装/未登录/网络异常)不缓存,
374
- // 否则用户 `gh auth login` 之后还要再等满一个 TTL 才能看到面板恢复。
375
- if (value.loggedIn) {
227
+ // 成功登录且无网络故障时落缓存
228
+ if (value.loggedIn && !value.networkError) {
376
229
  this.authCache = { at: now, value };
377
230
  }
378
- return value;
379
- }
380
- /** 生成当前操作系统平台的推荐安装引导与命令 */
381
- getPlatformInstallGuide() {
382
- const p = process.platform;
383
- if (p === 'win32') {
231
+ else if (value.networkError && this.authCache && this.authCache.value.loggedIn) {
232
+ // 容灾保全:若本次刷新遭遇网络波动,但之前已有确凿的有效登录缓存,
233
+ // 保全已有登录身份,仅追加网络异常标识,绝不让界面闪烁误报为「未登录」
384
234
  return {
385
- command: 'winget install --id GitHub.cli',
386
- altCommand: 'scoop install gh',
387
- downloadUrl: 'https://cli.github.com/',
388
- description: '推荐使用 Windows Package Manager (winget) 一键安装,或下载官方 .msi 安装包。',
235
+ ...this.authCache.value,
236
+ networkError: true,
237
+ networkErrorReason: value.networkErrorReason,
389
238
  };
390
239
  }
391
- if (p === 'darwin') {
240
+ return value;
241
+ }
242
+ /**
243
+ * 获取当前 gh 版本号(带 30s TTL 缓存),失败返回 null。
244
+ * 底层直连 execFileAsync 而不走 this.run():run() 会先解析工作区(可能读
245
+ * workspace.json 与目录扫描)再做截断/JSON 解析,对一次 10ms 的版本字符串
246
+ * 查询是纯开销;直连也彻底规避 runGuarded → 探测 → run 的递归风险。
247
+ */
248
+ async getGhVersion() {
249
+ const now = Date.now();
250
+ if (this.ghVersionCache && now - this.ghVersionCache.at < GH_VERSION_CACHE_TTL_MS) {
251
+ return this.ghVersionCache.value;
252
+ }
253
+ const version = parseGhVersion(await this.probeGhVersionRaw());
254
+ // 只缓存成功解析值:失败不落缓存,用户装好 gh 后下一次探测立即生效
255
+ if (version) {
256
+ this.ghVersionCache = { at: now, value: version };
257
+ }
258
+ return version;
259
+ }
260
+ /**
261
+ * 底层版本探测(独立方法而非内联:测试需要在不触发真实子进程的前提下
262
+ * 验证 getGhVersion 的缓存分支——覆写此方法即注入确定输出)。
263
+ * 直连 execFileAsync 而不走 this.run():run() 会先解析工作区(可能读
264
+ * workspace.json 与目录扫描)再做截断/JSON 解析,对一次 10ms 的版本字符串
265
+ * 查询是纯开销;直连也彻底规避 runGuarded → 探测 → run 的递归风险。
266
+ */
267
+ async probeGhVersionRaw() {
268
+ try {
269
+ const { stdout } = await execFileAsync(this.ghPath, ['--version'], {
270
+ timeout: 3_000,
271
+ windowsHide: true,
272
+ });
273
+ return String(stdout);
274
+ }
275
+ catch {
276
+ // 未安装/超时/PATH 抖动:返回空串,由 parseGhVersion 归一为 null → fail-open
277
+ return '';
278
+ }
279
+ }
280
+ /**
281
+ * 守卫执行器:按 gh 版本门槛路由「主命令 → 降级链路 → 升级指引」三态。
282
+ *
283
+ * 为什么是「检查+执行」一体的方法而非纯探测接口:检查与执行分离会在两者之间
284
+ * 留下竞隙(版本可能在间隙变化),且降级标注逻辑会被迫散落到每个工具的 if/else。
285
+ *
286
+ * fail-open 纪律:版本探测自身失败(null)时直接执行主命令——守卫是路由优化
287
+ * 不是安全边界,探测故障不应拦截业务;gh 未安装场景由主命令产出既有 ENOENT
288
+ * 安装引导,这里不重复造错误路径。
289
+ */
290
+ async runGuarded(args, options = {}) {
291
+ const { minVersion, capability, fallback, ...runOptions } = options;
292
+ if (!minVersion) {
293
+ return this.run(args, runOptions);
294
+ }
295
+ const current = await this.getGhVersion();
296
+ // 探测失败 fail-open:直接执行主命令(见方法注释),错误路径交由主命令的既有处理
297
+ if (current === null || compareGhVersions(current, minVersion) >= 0) {
298
+ return this.run(args, runOptions);
299
+ }
300
+ if (fallback) {
301
+ const res = await fallback();
392
302
  return {
393
- command: 'brew install gh',
394
- altCommand: 'port install gh',
395
- downloadUrl: 'https://cli.github.com/',
396
- description: '推荐使用 Homebrew 安装 GitHub CLI。',
303
+ ...res,
304
+ degraded: true,
305
+ degradedReason: `当前 gh ${current} 低于 ${capability || minVersion} 所需版本 ${minVersion},已切换 API 降级链路`,
397
306
  };
398
307
  }
399
308
  return {
400
- command: 'sudo apt install gh',
401
- altCommand: 'sudo dnf install gh',
402
- downloadUrl: 'https://cli.github.com/',
403
- description: '推荐使用系统包管理器安装(Debian/Ubuntu 运行 apt,Fedora/CentOS 运行 dnf)。',
309
+ ok: false,
310
+ error: `此能力需要 gh ≥ ${minVersion}(当前 ${current})。${this.getGhUpgradeHint()}`,
404
311
  };
405
312
  }
406
- /** 实际执行 `gh auth status` 并解析结果,不含缓存逻辑 */
313
+ /** gh 升级命令按平台提示(复用三态状态机的平台判断口径) */
314
+ getGhUpgradeHint() {
315
+ return getGhUpgradeHint();
316
+ }
317
+ /**
318
+ * 本地 Git 状态只读探测(白名单通道)。
319
+ *
320
+ * 这是本插件首次执行 git(此前仅 gh)——git 存在大量写命令,风险等级高于 gh,
321
+ * 因此白名单不落在「命令名过滤」而落在「命令形态常量」:spawn 参数是下方
322
+ * 硬编码的数组字面量,无任何动态拼接路径,写操作物理不可达(cwd 之外零输入,
323
+ * 而 cwd 本身也是插件解析的工作区路径)。
324
+ *
325
+ * 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
326
+ * 符合 global-overview 端点「工作区探测不发网络」的性能红线。
327
+ */
328
+ async getLocalGitStatus(cwd) {
329
+ try {
330
+ // 纯本地毫秒级;3s 超时兜底异常文件系统
331
+ const { stdout } = await execFileAsync('git', ['status', '-sb', '--porcelain'], {
332
+ cwd: cwd || undefined,
333
+ timeout: 3_000,
334
+ windowsHide: true,
335
+ });
336
+ return parseGitStatusOutput(String(stdout));
337
+ }
338
+ catch {
339
+ // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方不渲染该行)
340
+ return null;
341
+ }
342
+ }
343
+ /**
344
+ * 本地 Git 提交记录(三段一次返回:本地领先 / 远程领先 / 最近提交)。
345
+ *
346
+ * 白名单纪律与 getLocalGitStatus 同源:下方两个命令形态常量硬编码
347
+ * (log + 修订范围语法,range 段由 hasUpstream 守卫后才拼接且仅两个
348
+ * 固定形态 @{u}..HEAD / HEAD..@{u}——不含任何用户输入路径)。
349
+ * 领先/落后列表基于 remote-tracking ref(上次 fetch 快照),与 status
350
+ * 的 ahead/behind 计数同一数据口径;格式 %h|%an|%ar|%s 实测可结构化解析。
351
+ */
352
+ async getGitCommitLog(cwd, hasUpstream = true, limit = 15) {
353
+ const empty = { aheadCommits: [], behindCommits: [], recent: [] };
354
+ const logFmt = '--pretty=format:%x1e%h%x1f%H%x1f%an%x1f%ae%x1f%ad%x1f%ar%x1f%s%x1f%b%x1f';
355
+ try {
356
+ const recentRes = await execFileAsync('git', ['log', `-${Math.max(1, limit)}`, logFmt, '--stat', '--no-decorate'], {
357
+ cwd: cwd || undefined,
358
+ timeout: 4_000,
359
+ windowsHide: true,
360
+ });
361
+ empty.recent = parseGitLogLines(String(recentRes.stdout));
362
+ try {
363
+ const countRes = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], {
364
+ cwd: cwd || undefined,
365
+ timeout: 2_000,
366
+ windowsHide: true,
367
+ });
368
+ const cnt = parseInt(String(countRes.stdout).trim(), 10);
369
+ if (!isNaN(cnt))
370
+ empty.totalCount = cnt;
371
+ }
372
+ catch { }
373
+ if (!hasUpstream)
374
+ return empty; // 无 upstream:领先/落后无从谈起,仅最近提交
375
+ const aheadRes = await execFileAsync('git', ['log', '@{u}..HEAD', logFmt, '--stat', '--no-decorate'], {
376
+ cwd: cwd || undefined,
377
+ timeout: 3_000,
378
+ windowsHide: true,
379
+ });
380
+ empty.aheadCommits = parseGitLogLines(String(aheadRes.stdout));
381
+ const behindRes = await execFileAsync('git', ['log', 'HEAD..@{u}', logFmt, '--stat', '--no-decorate'], {
382
+ cwd: cwd || undefined,
383
+ timeout: 3_000,
384
+ windowsHide: true,
385
+ });
386
+ empty.behindCommits = parseGitLogLines(String(behindRes.stdout));
387
+ return empty;
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }
393
+ /**
394
+ * 分页获取最近提交记录(用于 Git 面板下滑触底继续加载)。
395
+ */
396
+ async getGitRecentLog(cwd, limit = 15, skip = 0) {
397
+ const logFmt = '--pretty=format:%x1e%h%x1f%H%x1f%an%x1f%ae%x1f%ad%x1f%ar%x1f%s%x1f%b%x1f';
398
+ const safeLimit = Math.min(Math.max(1, limit), 50);
399
+ const safeSkip = Math.max(0, skip);
400
+ const args = ['log', `-${safeLimit}`, logFmt, '--stat', '--no-decorate'];
401
+ if (safeSkip > 0) {
402
+ args.push('--skip', String(safeSkip));
403
+ }
404
+ try {
405
+ const { stdout } = await execFileAsync('git', args, {
406
+ cwd: cwd || undefined,
407
+ timeout: 5_000,
408
+ windowsHide: true,
409
+ });
410
+ const commits = parseGitLogLines(String(stdout));
411
+ let totalCount;
412
+ try {
413
+ const countRes = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], {
414
+ cwd: cwd || undefined,
415
+ timeout: 2_000,
416
+ windowsHide: true,
417
+ });
418
+ const cnt = parseInt(String(countRes.stdout).trim(), 10);
419
+ if (!isNaN(cnt))
420
+ totalCount = cnt;
421
+ }
422
+ catch { }
423
+ return { commits, totalCount };
424
+ }
425
+ catch {
426
+ return { commits: [] };
427
+ }
428
+ }
429
+ /**
430
+ * 单条提交详情(点击提交行弹窗的数据源)。
431
+ * 白名单纪律延续:show 命令形态常量硬编码;sha 参数经 ^[0-9a-f]{4,40}$
432
+ * 十六进制校验后才拼接——这是 git 白名单通道首次接受「外部传入的单值」,
433
+ * 正则把值域锁死在纯十六进制(注入面为零:任何 shell 元字符/子命令
434
+ * 语法都不可能通过校验)。输出为 git show --stat 原文(含文件变更统计)。
435
+ */
436
+ async getGitCommitDetail(cwd, sha) {
437
+ if (!/^[0-9a-f]{4,40}$/i.test(sha))
438
+ return null; // 形态校验:纯十六进制短/全 sha
439
+ try {
440
+ const { stdout } = await execFileAsync('git', ['show', '--stat', '--pretty=format:%H%n%an <%ae>%n%ad%n%s%n%b', sha], {
441
+ cwd: cwd || undefined,
442
+ timeout: 3_000,
443
+ windowsHide: true,
444
+ });
445
+ return String(stdout);
446
+ }
447
+ catch {
448
+ return null;
449
+ }
450
+ }
451
+ /**
452
+ * 纯本地读取工作区的所有 Git 分支(按提交时间倒序)。
453
+ * 包含当前 HEAD 分支标记、关联 upstream、最新提交简述与相对时间。
454
+ * 零网络往返,毫秒级本地管道输出。
455
+ */
456
+ async getGitBranches(cwd) {
457
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
458
+ if (!hasGitDir(targetCwd)) {
459
+ return null;
460
+ }
461
+ const fmt = '%(refname:short)|%(HEAD)|%(upstream:short)|%(committerdate:relative)|%(objectname:short)|%(subject)';
462
+ try {
463
+ const { stdout } = await execFileAsync('git', ['branch', '-a', '--sort=-committerdate', `--format=${fmt}`], {
464
+ cwd: targetCwd,
465
+ timeout: 4_000,
466
+ windowsHide: true,
467
+ });
468
+ return parseGitBranchLines(String(stdout));
469
+ }
470
+ catch {
471
+ return null;
472
+ }
473
+ }
474
+ /**
475
+ * 安全切换当前工作区的 Git 分支。
476
+ * 三重安全防御纪律:
477
+ * 1. 目标工作区必须存在有效的 .git 目录;
478
+ * 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
479
+ * 3. 检查工作区干净度(dirtyCount),若有未提交或未保存的变更直接阻断,杜绝代码被破坏性覆盖。
480
+ */
481
+ async switchGitBranch(cwd, branchName) {
482
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
483
+ if (!hasGitDir(targetCwd)) {
484
+ return { ok: false, error: '当前工作区不是有效的 Git 仓库' };
485
+ }
486
+ const cleanBranch = (branchName || '').trim();
487
+ // 安全白名单校验:只允许字母、数字、点号、斜杠、减号和下划线,禁止以 - 开头(防 flag 注入),禁止包含连续点号 ..
488
+ if (!cleanBranch || cleanBranch.startsWith('-') || cleanBranch.includes('..') || cleanBranch.includes('\\') || !/^[a-zA-Z0-9_\/.-]+$/.test(cleanBranch)) {
489
+ return { ok: false, error: '非法分支名称,仅允许字母、数字、短横线、下划线与斜杠组合' };
490
+ }
491
+ // 防线二:工作区未提交变更阻断(Dirty File Guard)
492
+ const gitSt = await this.getLocalGitStatus(targetCwd);
493
+ if (gitSt && gitSt.dirtyCount > 0) {
494
+ return {
495
+ ok: false,
496
+ error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再切换分支。`,
497
+ };
498
+ }
499
+ try {
500
+ let args = ['checkout', cleanBranch];
501
+ let targetName = cleanBranch;
502
+ if (cleanBranch.startsWith('origin/')) {
503
+ targetName = cleanBranch.replace(/^origin\//, '');
504
+ // 若本地已有同名分支,直接切本地;若无则通过 --track 建立追踪分支
505
+ const branches = await this.getGitBranches(targetCwd);
506
+ const hasLocal = branches?.branches.some((b) => !b.isRemote && b.name === targetName);
507
+ args = hasLocal ? ['checkout', targetName] : ['checkout', '--track', cleanBranch];
508
+ }
509
+ await execFileAsync('git', args, {
510
+ cwd: targetCwd,
511
+ timeout: 5_000,
512
+ windowsHide: true,
513
+ });
514
+ return { ok: true, currentBranch: targetName };
515
+ }
516
+ catch (err) {
517
+ const stderr = err?.stderr?.toString().trim();
518
+ const msg = stderr || err?.message || '切换分支失败';
519
+ return { ok: false, error: msg };
520
+ }
521
+ }
522
+ /** 生成当前操作系统平台的推荐安装引导与命令 */
523
+ getPlatformInstallGuide() {
524
+ return getPlatformInstallGuide();
525
+ }
526
+ /**
527
+ * 纯本地检测宿主机 GitHub CLI 配置文件中是否存在已配置的账号凭据。
528
+ * 零子进程、零网络往返,毫秒级读取,专供断网与网络卡顿时的 Fail-Safe 容灾降级。
529
+ */
530
+ checkLocalGhCredential() {
531
+ try {
532
+ const candidates = [];
533
+ if (process.platform === 'win32') {
534
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
535
+ candidates.push(path.join(appData, 'GitHub CLI', 'hosts.yml'));
536
+ }
537
+ candidates.push(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'));
538
+ for (const file of candidates) {
539
+ if (fs.existsSync(file)) {
540
+ const content = fs.readFileSync(file, 'utf8');
541
+ // 匹配 hosts.yml 中的 user: xxx 或 users:\n xxx:
542
+ const userMatch = content.match(/user:\s*([A-Za-z0-9_-]+)/i) || content.match(/users:\s*[\r\n]+\s+([A-Za-z0-9_-]+):/i);
543
+ const hasOauth = content.includes('oauth_token') || content.includes('users:');
544
+ if (userMatch || hasOauth) {
545
+ return {
546
+ user: userMatch ? userMatch[1] : undefined,
547
+ hasToken: true,
548
+ };
549
+ }
550
+ }
551
+ }
552
+ }
553
+ catch { }
554
+ return null;
555
+ }
556
+ /**
557
+ * 实际执行认证探测并解析结果,不含外部缓存逻辑。
558
+ * 解耦本地安装态、本地凭据态与网络可达态:
559
+ * 1. 安装态通过 getGhVersion 本地轻量确认,网络超时/断网绝不篡改 installed 结论;
560
+ * 2. 本地凭据通过 checkLocalGhCredential 读取,断网时仍可识别人类账号;
561
+ * 3. 精准分流网络超时/异常(networkError)与真正的未认证(explicitLoggedOut)。
562
+ */
407
563
  async probeAuth(cwd) {
564
+ // 1. 本地安装态先行探测(纯本地进程调用,零网络依赖)
565
+ const version = await this.getGhVersion().catch(() => null);
566
+ const isInstalledLocally = Boolean(version);
567
+ // 2. 本地已存登录凭据探测(纯本地文件读取,零网络依赖)
568
+ const localCred = this.checkLocalGhCredential();
569
+ // 3. 执行 gh auth status 校验远端有效性
408
570
  const res = await this.run(['auth', 'status'], { cwd, timeoutMs: 10_000, rawText: true });
409
571
  const raw = (res.rawOutput || '') + '\n' + (res.error || '');
410
- // 多重维度强健判定登录态 (容错 Windows Unicode 乱码、Keyring 告警与不同版本 CLI 输出差分)
572
+ // 4. 精准网络异常特征检测(超时、DNS解析失败、代理拒绝、连接重置)
573
+ const isTimeout = Boolean(res.error && (res.error.includes('执行超时') || res.error.includes('timed out')));
574
+ const isNetworkError = isTimeout || /dial\s+tcp|connectex|connection\s+refused|lookup\s+api\.github\.com|getaddrinfow|TLS\s+handshake|proxyconnect|network\s+is\s+unreachable|i\/o\s+timeout/i.test(raw);
575
+ // 5. 显式未登录特征(必须排除网络错误干扰,仅在无网络问题且 CLI 明确指示未登录时成立)
576
+ const isExplicitlyLoggedOut = !isNetworkError && (/not\s+logged\s+in/i.test(raw) ||
577
+ raw.includes('gh auth login') ||
578
+ raw.includes('authentication required') ||
579
+ raw.includes('No accounts found'));
580
+ // 6. 成功登录特征判定
411
581
  const hasLoggedInFlag = /logged\s+in\s+to/i.test(raw) || raw.includes('✓') || raw.includes('√');
412
582
  const hasActiveFlag = /active\s+account:\s*true/i.test(raw);
413
583
  const hasTokenFlag = raw.includes('Token:') || raw.includes('Token scopes:');
414
584
  const accountMatch = raw.match(/account\s+([A-Za-z0-9_-]+)/i);
415
585
  const hasAccount = Boolean(accountMatch && accountMatch[1] && accountMatch[1].toLowerCase() !== 'none');
416
- // 显式未登录特征排查
417
- const isExplicitlyLoggedOut = /not\s+logged\s+in/i.test(raw) ||
418
- raw.includes('gh auth login') ||
419
- raw.includes('authentication required') ||
420
- raw.includes('No accounts found');
421
- const loggedIn = !isExplicitlyLoggedOut && (hasLoggedInFlag || hasActiveFlag || (hasTokenFlag && hasAccount));
422
- // 只要已登录或有常规输出,则必然安装了 gh
423
- const isNotInstalled = Boolean(res.error && (res.error.includes('系统未检测到 GitHub CLI') || res.error.includes('ENOENT')));
424
- const installed = loggedIn || !isNotInstalled;
586
+ // 7. 安装状态结论:若本地版本已确认,或者命令返回了非 ENOENT 的输出,必然已安装
587
+ const isNotInstalled = !isInstalledLocally && Boolean(res.error && (res.error.includes('系统未检测到 GitHub CLI') || res.error.includes('ENOENT')));
588
+ const installed = isInstalledLocally || !isNotInstalled;
425
589
  if (!installed) {
426
590
  return {
427
591
  installed: false,
@@ -431,6 +595,25 @@ export class GhExecutor {
431
595
  raw: res.error,
432
596
  };
433
597
  }
598
+ // 8. 网络异常处理:绝不能把网络超时/断网归咎为“未登录”或“未安装”
599
+ if (isNetworkError) {
600
+ const errorReason = isTimeout
601
+ ? '连接 GitHub API 超时 (10 秒)。请检查网络连通性或代理配置。'
602
+ : '无法连接至 GitHub 服务器(网络波动或被系统代理阻断)。';
603
+ return {
604
+ installed: true,
605
+ // 若本地确认有配置好的账号,保持 loggedIn 为 true(Fail-Safe 容灾),并标记 networkError
606
+ loggedIn: Boolean(localCred?.hasToken || hasLoggedInFlag),
607
+ user: localCred?.user || (accountMatch ? accountMatch[1] : undefined),
608
+ host: 'github.com',
609
+ platform: process.platform,
610
+ networkError: true,
611
+ networkErrorReason: errorReason,
612
+ hasLocalToken: Boolean(localCred?.hasToken),
613
+ raw,
614
+ };
615
+ }
616
+ const loggedIn = !isExplicitlyLoggedOut && (hasLoggedInFlag || hasActiveFlag || (hasTokenFlag && hasAccount));
434
617
  if (!loggedIn) {
435
618
  return {
436
619
  installed: true,
@@ -440,7 +623,7 @@ export class GhExecutor {
440
623
  };
441
624
  }
442
625
  // 解析用户名
443
- let user;
626
+ let user = localCred?.user;
444
627
  if (accountMatch) {
445
628
  user = accountMatch[1];
446
629
  }
@@ -506,6 +689,11 @@ export class GhExecutor {
506
689
  */
507
690
  async getRepoMetadata(cwd, sessionId) {
508
691
  const targetCwd = this.resolveWorkspaceCwd(cwd, sessionId);
692
+ // 绝对前置门禁:当前目录若自身不存在 .git 目录,铁证如山判定为未关联本地 Git 仓库
693
+ // 直接返回 null,杜绝一切在非 Git 目录误报关联的可能!
694
+ if (!hasGitDir(targetCwd)) {
695
+ return null;
696
+ }
509
697
  // 1. 优先尝试由 gh 直接根据当前上下文识别
510
698
  const res = await this.run(['repo', 'view', '--json', 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url'], { cwd: targetCwd, timeoutMs: 10_000 });
511
699
  if (res.ok && res.data && res.data.nameWithOwner) {