dsh-github-flow 0.3.0 → 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.
- package/README.en.md +220 -143
- package/README.md +202 -124
- package/docs/UPGRADE-NOTES.md +14 -8
- package/docs/architecture.md +111 -129
- package/lib/api/routes-actions.js +117 -0
- package/lib/api/routes-overview.js +94 -2
- package/lib/cache.d.ts +4 -0
- package/lib/cache.js +14 -0
- package/lib/client.d.ts +13 -1
- package/lib/client.js +2906 -2475
- package/lib/executor/git-parser.d.ts +25 -0
- package/lib/executor/git-parser.js +143 -0
- package/lib/executor/platform-guide.d.ts +14 -0
- package/lib/executor/platform-guide.js +40 -0
- package/lib/executor/process-utils.d.ts +51 -0
- package/lib/executor/process-utils.js +162 -0
- package/lib/executor/version-utils.d.ts +12 -0
- package/lib/executor/version-utils.js +27 -0
- package/lib/executor.d.ts +45 -48
- package/lib/executor.js +184 -353
- package/lib/git-watcher.d.ts +36 -0
- package/lib/git-watcher.js +91 -0
- package/lib/main.js +10 -0
- package/lib/renderers/cache.d.ts +8 -0
- package/lib/renderers/cache.js +66 -0
- package/lib/renderers/common.d.ts +24 -0
- package/lib/renderers/common.js +88 -0
- package/lib/renderers/gist.d.ts +15 -0
- package/lib/renderers/gist.js +99 -0
- package/lib/renderers/issue.d.ts +4 -0
- package/lib/renderers/issue.js +70 -0
- package/lib/renderers/label.d.ts +4 -0
- package/lib/renderers/label.js +48 -0
- package/lib/renderers/markdown.d.ts +15 -32
- package/lib/renderers/markdown.js +15 -624
- package/lib/renderers/pr.d.ts +4 -0
- package/lib/renderers/pr.js +107 -0
- package/lib/renderers/release.d.ts +4 -0
- package/lib/renderers/release.js +74 -0
- package/lib/renderers/repo.d.ts +4 -0
- package/lib/renderers/repo.js +149 -0
- package/lib/renderers/ruleset.d.ts +4 -0
- package/lib/renderers/ruleset.js +74 -0
- package/lib/renderers/run.d.ts +4 -0
- package/lib/renderers/run.js +79 -0
- package/lib/renderers/skill.d.ts +4 -0
- package/lib/renderers/skill.js +61 -0
- package/lib/renderers/status.d.ts +23 -0
- package/lib/renderers/status.js +124 -0
- package/lib/tools/cache.d.ts +64 -0
- package/lib/tools/cache.js +186 -0
- package/lib/tools/gist.d.ts +155 -0
- package/lib/tools/gist.js +190 -0
- package/lib/tools/help.js +107 -6
- package/lib/tools/image-utils.d.ts +8 -0
- package/lib/tools/image-utils.js +34 -0
- package/lib/tools/issue.d.ts +8 -1
- package/lib/tools/issue.js +26 -4
- package/lib/tools/label.d.ts +80 -0
- package/lib/tools/label.js +237 -0
- package/lib/tools/pr.d.ts +8 -0
- package/lib/tools/pr.js +48 -3
- package/lib/tools/ruleset.d.ts +80 -0
- package/lib/tools/ruleset.js +177 -0
- package/lib/tools/status.d.ts +39 -0
- package/lib/tools/status.js +53 -0
- package/lib/types.d.ts +39 -0
- package/lib/ui/auth-guide-card.d.ts +4 -0
- package/lib/ui/auth-guide-card.js +161 -0
- package/lib/ui/branch-selector.d.ts +10 -0
- package/lib/ui/branch-selector.js +163 -0
- package/lib/ui/commit-modal.d.ts +8 -0
- package/lib/ui/commit-modal.js +89 -0
- package/lib/ui/icons.d.ts +7 -0
- package/lib/ui/icons.js +53 -0
- package/lib/ui/network-banner.d.ts +10 -0
- package/lib/ui/network-banner.js +54 -0
- package/lib/ui/storage.d.ts +26 -0
- package/lib/ui/storage.js +134 -0
- package/lib/ui/styles.d.ts +6 -0
- package/lib/ui/styles.js +1285 -0
- package/lib/ui/sync-settings-modal.d.ts +11 -0
- package/lib/ui/sync-settings-modal.js +350 -0
- package/lib/ui/sync-status-badge.d.ts +8 -0
- package/lib/ui/sync-status-badge.js +64 -0
- package/lib/ui/upgrade-card.d.ts +7 -0
- package/lib/ui/upgrade-card.js +197 -0
- package/lib/ui/utils.d.ts +10 -0
- package/lib/ui/utils.js +66 -0
- package/lib/ui/workspace-panel.d.ts +4 -0
- package/lib/ui/workspace-panel.js +464 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GitLocalStatus, GitCommitEntry, GitBranchesResult } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 解析 `git status -sb --porcelain` 输出为结构化状态。
|
|
4
|
+
* 抽导出纯函数(parseGhVersion 同理):三形态解析是易错边界区,单测钉死。
|
|
5
|
+
*
|
|
6
|
+
* 输出形态(实测):
|
|
7
|
+
* `## master...origin/master [ahead 2, behind 1]` 有 ahead/behind
|
|
8
|
+
* `## master...origin/master` 与远程同步
|
|
9
|
+
* `## master` / `## HEAD (no branch)` 无 upstream(ahead/behind 未知=-1)
|
|
10
|
+
* 后续每行 `<XY> <path>` 为 porcelain 变更项(XY 两字符状态码,?? = untracked)。
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseGitStatusOutput(output: string): GitLocalStatus | null;
|
|
13
|
+
/**
|
|
14
|
+
* 解析 `git log` 输出为提交条目数组。
|
|
15
|
+
* 支持两种形态:
|
|
16
|
+
* 1. 结构化带详情:%x1e 为 commit 间分隔符,%x1f 为字段间分隔符(含 fullSha/email/date/body/stat);
|
|
17
|
+
* 2. 简易管道符:%h|%an|%ar|%s 行格式(兼容旧单测输入)。
|
|
18
|
+
* 空输出返回 []。
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseGitLogLines(output: string): GitCommitEntry[];
|
|
21
|
+
/**
|
|
22
|
+
* 解析 git branch -a --sort=-committerdate 格式化输出。
|
|
23
|
+
* 格式为: %(refname:short)|%(HEAD)|%(upstream:short)|%(committerdate:relative)|%(objectname:short)|%(subject)
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseGitBranchLines(output: string): GitBranchesResult;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析 `git status -sb --porcelain` 输出为结构化状态。
|
|
3
|
+
* 抽导出纯函数(parseGhVersion 同理):三形态解析是易错边界区,单测钉死。
|
|
4
|
+
*
|
|
5
|
+
* 输出形态(实测):
|
|
6
|
+
* `## master...origin/master [ahead 2, behind 1]` 有 ahead/behind
|
|
7
|
+
* `## master...origin/master` 与远程同步
|
|
8
|
+
* `## master` / `## HEAD (no branch)` 无 upstream(ahead/behind 未知=-1)
|
|
9
|
+
* 后续每行 `<XY> <path>` 为 porcelain 变更项(XY 两字符状态码,?? = untracked)。
|
|
10
|
+
*/
|
|
11
|
+
export function parseGitStatusOutput(output) {
|
|
12
|
+
const lines = (output || '').split('\n').filter((l) => l.length > 0);
|
|
13
|
+
if (lines.length === 0 || !lines[0].startsWith('## '))
|
|
14
|
+
return null;
|
|
15
|
+
const branchLine = lines[0].slice(3); // 去掉 '## ' 前缀
|
|
16
|
+
const hasUpstream = branchLine.includes('...');
|
|
17
|
+
const branchPart = hasUpstream ? branchLine.split('...')[0] : branchLine.split(' ')[0];
|
|
18
|
+
let ahead = -1;
|
|
19
|
+
let behind = -1;
|
|
20
|
+
if (hasUpstream) {
|
|
21
|
+
// ahead/behind 段在 upstream 之后(可能与 #id 同现:origin/master #12)。
|
|
22
|
+
// git 输出形态实测:`[ahead 2]` / `[behind 1]` / `[ahead 2, behind 1]`——
|
|
23
|
+
// 单括号内逗号分隔,behind 不必然前邻 '['(正则不可锚定 '[',否则
|
|
24
|
+
// `[ahead 2, behind 1]` 形态的 behind 永远漏解析——单测抓到的真实 Bug)
|
|
25
|
+
const trackingPart = branchLine.split('...')[1] || '';
|
|
26
|
+
ahead = 0;
|
|
27
|
+
behind = 0;
|
|
28
|
+
const aheadMatch = /ahead (\d+)/.exec(trackingPart);
|
|
29
|
+
const behindMatch = /behind (\d+)/.exec(trackingPart);
|
|
30
|
+
if (aheadMatch)
|
|
31
|
+
ahead = Number(aheadMatch[1]);
|
|
32
|
+
if (behindMatch)
|
|
33
|
+
behind = Number(behindMatch[1]);
|
|
34
|
+
// 仅 ahead 无 behind 时 behind=0(反之亦然)——git 输出只列非零侧
|
|
35
|
+
}
|
|
36
|
+
const dirtyFiles = [];
|
|
37
|
+
for (let i = 1; i < lines.length && dirtyFiles.length < 20; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
if (line.length < 4)
|
|
40
|
+
continue;
|
|
41
|
+
const status = line.slice(0, 2).trim() || 'M';
|
|
42
|
+
// 路径可能含空格(git 不加引号场景);rename 形态 `R old -> new` 取 new
|
|
43
|
+
let p = line.slice(3);
|
|
44
|
+
const renameArrow = p.indexOf(' -> ');
|
|
45
|
+
if (renameArrow >= 0)
|
|
46
|
+
p = p.slice(renameArrow + 4);
|
|
47
|
+
dirtyFiles.push({ status, path: p });
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
branch: branchPart.replace(/\s*\(no branch\)$/, '') || 'HEAD',
|
|
51
|
+
ahead,
|
|
52
|
+
behind,
|
|
53
|
+
dirtyCount: Math.max(0, lines.length - 1),
|
|
54
|
+
hasUpstream,
|
|
55
|
+
dirtyFiles,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 解析 `git log` 输出为提交条目数组。
|
|
60
|
+
* 支持两种形态:
|
|
61
|
+
* 1. 结构化带详情:%x1e 为 commit 间分隔符,%x1f 为字段间分隔符(含 fullSha/email/date/body/stat);
|
|
62
|
+
* 2. 简易管道符:%h|%an|%ar|%s 行格式(兼容旧单测输入)。
|
|
63
|
+
* 空输出返回 []。
|
|
64
|
+
*/
|
|
65
|
+
export function parseGitLogLines(output) {
|
|
66
|
+
const out = (output || '').trim();
|
|
67
|
+
if (!out)
|
|
68
|
+
return [];
|
|
69
|
+
if (out.includes('\x1e') || out.includes('\x1f')) {
|
|
70
|
+
const records = out.split('\x1e').filter((c) => c.trim().length > 0);
|
|
71
|
+
return records.map((chunk) => {
|
|
72
|
+
const parts = chunk.split('\x1f');
|
|
73
|
+
return {
|
|
74
|
+
sha: (parts[0] || '').trim(),
|
|
75
|
+
fullSha: (parts[1] || '').trim() || undefined,
|
|
76
|
+
author: (parts[2] || '').trim(),
|
|
77
|
+
email: (parts[3] || '').trim() || undefined,
|
|
78
|
+
date: (parts[4] || '').trim() || undefined,
|
|
79
|
+
relative: (parts[5] || '').trim(),
|
|
80
|
+
subject: (parts[6] || '').trim(),
|
|
81
|
+
body: (parts[7] || '').trim() || undefined,
|
|
82
|
+
stat: (parts[8] || '').trim() || undefined,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
87
|
+
const parts = line.split('|');
|
|
88
|
+
return {
|
|
89
|
+
sha: parts[0] || '',
|
|
90
|
+
author: parts[1] || '',
|
|
91
|
+
relative: parts[2] || '',
|
|
92
|
+
subject: parts.slice(3).join('|'),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 解析 git branch -a --sort=-committerdate 格式化输出。
|
|
98
|
+
* 格式为: %(refname:short)|%(HEAD)|%(upstream:short)|%(committerdate:relative)|%(objectname:short)|%(subject)
|
|
99
|
+
*/
|
|
100
|
+
export function parseGitBranchLines(output) {
|
|
101
|
+
const lines = output.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
102
|
+
let current = '';
|
|
103
|
+
const branches = [];
|
|
104
|
+
const seenNames = new Set();
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const parts = line.split('|');
|
|
107
|
+
if (parts.length < 5)
|
|
108
|
+
continue;
|
|
109
|
+
const rawRef = parts[0].trim();
|
|
110
|
+
// 过滤掉指向 HEAD 的符号引用(如 origin/HEAD)
|
|
111
|
+
if (rawRef.includes('/HEAD') || rawRef === 'HEAD')
|
|
112
|
+
continue;
|
|
113
|
+
const isCurrent = parts[1].trim() === '*';
|
|
114
|
+
const upstream = parts[2].trim() || undefined;
|
|
115
|
+
const relativeTime = parts[3].trim() || '';
|
|
116
|
+
const shortSha = parts[4].trim() || '';
|
|
117
|
+
const subject = parts.slice(5).join('|').trim();
|
|
118
|
+
// 区分远程分支与本地分支
|
|
119
|
+
const isRemote = rawRef.startsWith('origin/') || rawRef.startsWith('remotes/');
|
|
120
|
+
const cleanName = rawRef.replace(/^remotes\//, '');
|
|
121
|
+
if (seenNames.has(cleanName))
|
|
122
|
+
continue;
|
|
123
|
+
seenNames.add(cleanName);
|
|
124
|
+
if (isCurrent) {
|
|
125
|
+
current = cleanName;
|
|
126
|
+
}
|
|
127
|
+
branches.push({
|
|
128
|
+
name: cleanName,
|
|
129
|
+
isCurrent,
|
|
130
|
+
isRemote,
|
|
131
|
+
upstream,
|
|
132
|
+
shortSha,
|
|
133
|
+
subject,
|
|
134
|
+
relativeTime,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// 若未从 * 解析到当前分支,默认以第一条本地分支兜底
|
|
138
|
+
if (!current && branches.length > 0) {
|
|
139
|
+
const local = branches.find((b) => !b.isRemote);
|
|
140
|
+
current = local ? local.name : branches[0].name;
|
|
141
|
+
}
|
|
142
|
+
return { current, branches };
|
|
143
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取当前平台的推荐 gh 安装引导与命令。
|
|
3
|
+
* 覆盖 Windows (winget/scoop)、macOS (Homebrew/MacPorts)、Linux (apt/dnf)。
|
|
4
|
+
*/
|
|
5
|
+
export declare function getPlatformInstallGuide(): {
|
|
6
|
+
command: string;
|
|
7
|
+
altCommand: string;
|
|
8
|
+
downloadUrl: string;
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* gh 升级命令按平台提示(复用三态状态机的平台判断口径)。
|
|
13
|
+
*/
|
|
14
|
+
export declare function getGhUpgradeHint(): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取当前平台的推荐 gh 安装引导与命令。
|
|
3
|
+
* 覆盖 Windows (winget/scoop)、macOS (Homebrew/MacPorts)、Linux (apt/dnf)。
|
|
4
|
+
*/
|
|
5
|
+
export function getPlatformInstallGuide() {
|
|
6
|
+
const p = process.platform;
|
|
7
|
+
if (p === 'win32') {
|
|
8
|
+
return {
|
|
9
|
+
command: 'winget install --id GitHub.cli',
|
|
10
|
+
altCommand: 'scoop install gh',
|
|
11
|
+
downloadUrl: 'https://cli.github.com/',
|
|
12
|
+
description: '推荐使用 Windows Package Manager (winget) 一键安装,或下载官方 .msi 安装包。',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (p === 'darwin') {
|
|
16
|
+
return {
|
|
17
|
+
command: 'brew install gh',
|
|
18
|
+
altCommand: 'port install gh',
|
|
19
|
+
downloadUrl: 'https://cli.github.com/',
|
|
20
|
+
description: '推荐使用 Homebrew 安装 GitHub CLI。',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
command: 'sudo apt install gh',
|
|
25
|
+
altCommand: 'sudo dnf install gh',
|
|
26
|
+
downloadUrl: 'https://cli.github.com/',
|
|
27
|
+
description: '推荐使用系统包管理器安装(Debian/Ubuntu 运行 apt,Fedora/CentOS 运行 dnf)。',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* gh 升级命令按平台提示(复用三态状态机的平台判断口径)。
|
|
32
|
+
*/
|
|
33
|
+
export function getGhUpgradeHint() {
|
|
34
|
+
const p = process.platform;
|
|
35
|
+
if (p === 'win32')
|
|
36
|
+
return '升级命令:`winget upgrade GitHub.cli`(或从 https://cli.github.com/ 下载最新 .msi)。';
|
|
37
|
+
if (p === 'darwin')
|
|
38
|
+
return '升级命令:`brew upgrade gh`(或从 https://cli.github.com/ 下载最新安装包)。';
|
|
39
|
+
return '升级方式:更新系统包(Debian/Ubuntu: `sudo apt update && sudo apt install gh`;Fedora: `sudo dnf upgrade gh`),或见 https://cli.github.com/。';
|
|
40
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn 形态的执行通道(stdin 可选注入)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么存在:execFile 的 input 选项在本机(node 22.18.0 + Windows)实测从不写入
|
|
5
|
+
* 子进程 stdin——既无数据也无 EOF,管道永久悬空(gh secret set 等需要 stdin 的
|
|
6
|
+
* 命令会一直等到超时)。最小复现:execFile+input 对 `cmd /c more` 与 node 子进程
|
|
7
|
+
* 均挂起;而 spawn + stdin.end(data) 全部正常。此函数即为后者的工程化封装,
|
|
8
|
+
* 仅在调用方显式传入 stdin 时使用,其余路径保持 execFileAsync 零改动。
|
|
9
|
+
*
|
|
10
|
+
* 为什么超时要自己管:spawn 无 timeout 选项,用 kill + SIGTERM 兜底;
|
|
11
|
+
* 输出按 maxBuffer 手工聚合(上限与 execFile 路径一致,15MB)。
|
|
12
|
+
*/
|
|
13
|
+
export declare function spawnWithStdin(ghPath: string, args: string[], opts: {
|
|
14
|
+
cwd?: string;
|
|
15
|
+
timeout: number;
|
|
16
|
+
env: NodeJS.ProcessEnv;
|
|
17
|
+
stdin: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
stdout: string | Buffer;
|
|
20
|
+
stderr: string | Buffer;
|
|
21
|
+
killed: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* 按码点安全截取字符串,等价于 String.prototype.slice 但对孤立的代理对(surrogate pair)做占位替换。
|
|
25
|
+
* 为什么必须这样截:本文件末尾会按 UTF-8 字节数截断,而 node:child_process 把 stdout 按字节缓冲区
|
|
26
|
+
* 切分后再 toString('utf8'),一个汉字被从中间切开时会变成 U+FFFD 替换字符。直接用 slice 会把半截
|
|
27
|
+
* 代理对留在结果里,导致下游 JSON.stringify 产出孤立转义序列、写入文件时编码报错。
|
|
28
|
+
*/
|
|
29
|
+
export declare function safeSlice(input: string, start: number, end?: number): string;
|
|
30
|
+
/**
|
|
31
|
+
* 以 UTF-8 字节数(而非字符串长度)为口径,从两端保留内容。
|
|
32
|
+
* 为什么需要:maxOutputChars 数的是字符,中文一个字占 3 字节。只按字符限长时,
|
|
33
|
+
* 中文环境下真实放行的字节量可达标称值的数倍,Token 预算保护会失真。
|
|
34
|
+
* 采用二分查找定位截断点,避免对超大输出逐字符累加造成 O(n) 反复编码。
|
|
35
|
+
*/
|
|
36
|
+
export declare function cutByUtf8Bytes(input: string, headBytes: number, tailBytes: number): {
|
|
37
|
+
head: string;
|
|
38
|
+
tail: string;
|
|
39
|
+
keptHeadBytes: number;
|
|
40
|
+
keptTailBytes: number;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* 按「字符数 + UTF-8 字节数」双口径截断输出。
|
|
45
|
+
* 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
|
|
46
|
+
* 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
|
|
47
|
+
*/
|
|
48
|
+
export declare function truncateOutput(input: string, maxChars: number, maxBytes: number): {
|
|
49
|
+
text: string;
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* spawn 形态的执行通道(stdin 可选注入)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么存在:execFile 的 input 选项在本机(node 22.18.0 + Windows)实测从不写入
|
|
6
|
+
* 子进程 stdin——既无数据也无 EOF,管道永久悬空(gh secret set 等需要 stdin 的
|
|
7
|
+
* 命令会一直等到超时)。最小复现:execFile+input 对 `cmd /c more` 与 node 子进程
|
|
8
|
+
* 均挂起;而 spawn + stdin.end(data) 全部正常。此函数即为后者的工程化封装,
|
|
9
|
+
* 仅在调用方显式传入 stdin 时使用,其余路径保持 execFileAsync 零改动。
|
|
10
|
+
*
|
|
11
|
+
* 为什么超时要自己管:spawn 无 timeout 选项,用 kill + SIGTERM 兜底;
|
|
12
|
+
* 输出按 maxBuffer 手工聚合(上限与 execFile 路径一致,15MB)。
|
|
13
|
+
*/
|
|
14
|
+
export function spawnWithStdin(ghPath, args, opts) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(ghPath, args, {
|
|
17
|
+
cwd: opts.cwd,
|
|
18
|
+
env: opts.env,
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
});
|
|
22
|
+
const outChunks = [];
|
|
23
|
+
const errChunks = [];
|
|
24
|
+
let totalBytes = 0;
|
|
25
|
+
let killed = false;
|
|
26
|
+
let settled = false;
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
killed = true;
|
|
29
|
+
child.kill();
|
|
30
|
+
}, opts.timeout);
|
|
31
|
+
const settle = (err) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
if (err)
|
|
37
|
+
reject(err);
|
|
38
|
+
else
|
|
39
|
+
resolve({ stdout: Buffer.concat(outChunks), stderr: Buffer.concat(errChunks), killed });
|
|
40
|
+
};
|
|
41
|
+
// 聚合上限:超出即杀(与 execFile maxBuffer 语义一致;gh 正常输出远小于此)
|
|
42
|
+
const MAX_BYTES = 15 * 1024 * 1024;
|
|
43
|
+
child.stdout?.on('data', (d) => {
|
|
44
|
+
totalBytes += d.length;
|
|
45
|
+
outChunks.push(d);
|
|
46
|
+
if (totalBytes > MAX_BYTES) {
|
|
47
|
+
killed = true;
|
|
48
|
+
child.kill();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
child.stderr?.on('data', (d) => { errChunks.push(d); });
|
|
52
|
+
child.on('error', (err) => {
|
|
53
|
+
// spawn 失败(如 ENOENT)对齐 execFile 的错误形状:code 保留在 Error 上
|
|
54
|
+
const e = Object.assign(new Error(err.message), { code: err.code });
|
|
55
|
+
settle(e);
|
|
56
|
+
});
|
|
57
|
+
child.on('close', (code, signal) => {
|
|
58
|
+
if (killed) {
|
|
59
|
+
// 超时/超量被杀:对齐 execFile 的 Timeout 错误形状(err.killed + signal)
|
|
60
|
+
const e = Object.assign(new Error(`Command timed out after ${opts.timeout}ms`), { killed: true, signal: signal || 'SIGTERM' });
|
|
61
|
+
settle(e);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (code !== 0) {
|
|
65
|
+
const e = Object.assign(new Error(`Command failed with exit code ${code}`), { code, stderr: Buffer.concat(errChunks).toString('utf8').slice(0, 800) });
|
|
66
|
+
settle(e);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
settle();
|
|
70
|
+
});
|
|
71
|
+
// stdin 注入:end(data) 半关闭(写完即 EOF),子进程流式读取或一次性读取均可
|
|
72
|
+
child.stdin?.end(opts.stdin, (err) => {
|
|
73
|
+
if (err && !settled) {
|
|
74
|
+
const e = Object.assign(new Error(`stdin write failed: ${err.message}`), { code: 'EPIPE' });
|
|
75
|
+
settle(e);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 按码点安全截取字符串,等价于 String.prototype.slice 但对孤立的代理对(surrogate pair)做占位替换。
|
|
82
|
+
* 为什么必须这样截:本文件末尾会按 UTF-8 字节数截断,而 node:child_process 把 stdout 按字节缓冲区
|
|
83
|
+
* 切分后再 toString('utf8'),一个汉字被从中间切开时会变成 U+FFFD 替换字符。直接用 slice 会把半截
|
|
84
|
+
* 代理对留在结果里,导致下游 JSON.stringify 产出孤立转义序列、写入文件时编码报错。
|
|
85
|
+
*/
|
|
86
|
+
export function safeSlice(input, start, end) {
|
|
87
|
+
const chars = Array.from(input);
|
|
88
|
+
const from = Math.max(0, start);
|
|
89
|
+
const to = end === undefined ? chars.length : Math.max(from, end);
|
|
90
|
+
return chars.slice(from, to).map((c) => (c.length === 1 && c.charCodeAt(0) >= 0xd800 && c.charCodeAt(0) <= 0xdfff ? '\ufffd' : c)).join('');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 以 UTF-8 字节数(而非字符串长度)为口径,从两端保留内容。
|
|
94
|
+
* 为什么需要:maxOutputChars 数的是字符,中文一个字占 3 字节。只按字符限长时,
|
|
95
|
+
* 中文环境下真实放行的字节量可达标称值的数倍,Token 预算保护会失真。
|
|
96
|
+
* 采用二分查找定位截断点,避免对超大输出逐字符累加造成 O(n) 反复编码。
|
|
97
|
+
*/
|
|
98
|
+
export function cutByUtf8Bytes(input, headBytes, tailBytes) {
|
|
99
|
+
const encoder = new TextEncoder();
|
|
100
|
+
const totalBytes = encoder.encode(input).length;
|
|
101
|
+
if (totalBytes <= headBytes + tailBytes) {
|
|
102
|
+
return { head: input, tail: '', keptHeadBytes: totalBytes, keptTailBytes: 0, truncated: false };
|
|
103
|
+
}
|
|
104
|
+
const chars = Array.from(input);
|
|
105
|
+
const atByte = (limit, fromEnd) => {
|
|
106
|
+
let lo = 0;
|
|
107
|
+
let hi = chars.length;
|
|
108
|
+
let bytes = 0;
|
|
109
|
+
while (lo < hi) {
|
|
110
|
+
const mid = (lo + hi + 1) >> 1;
|
|
111
|
+
const slice = fromEnd ? chars.slice(chars.length - mid) : chars.slice(0, mid);
|
|
112
|
+
const size = encoder.encode(slice.join('')).length;
|
|
113
|
+
if (size <= limit) {
|
|
114
|
+
lo = mid;
|
|
115
|
+
bytes = size;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
hi = mid - 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { count: lo, bytes };
|
|
122
|
+
};
|
|
123
|
+
const headPart = atByte(headBytes, false);
|
|
124
|
+
const tailPart = atByte(tailBytes, true);
|
|
125
|
+
return {
|
|
126
|
+
head: safeSlice(input, 0, headPart.count),
|
|
127
|
+
tail: safeSlice(input, chars.length - tailPart.count),
|
|
128
|
+
keptHeadBytes: headPart.bytes,
|
|
129
|
+
keptTailBytes: tailPart.bytes,
|
|
130
|
+
truncated: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 按「字符数 + UTF-8 字节数」双口径截断输出。
|
|
135
|
+
* 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
|
|
136
|
+
* 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
|
|
137
|
+
*/
|
|
138
|
+
export function truncateOutput(input, maxChars, maxBytes) {
|
|
139
|
+
// 双重口径限长:字符数守住 Token 预算,字节数守住真实传输量。
|
|
140
|
+
// 中文场景下两者会分别触发,因此两条路径的提示文案必须区分,避免用户按错误的配置项去调参。
|
|
141
|
+
if (input.length > maxChars) {
|
|
142
|
+
// 保留前 1/3 字符和尾部 1/2 字符,保留开头上下文和错误尾部
|
|
143
|
+
const headLen = Math.floor(maxChars * 0.35);
|
|
144
|
+
const tailLen = Math.floor(maxChars * 0.55);
|
|
145
|
+
const head = safeSlice(input, 0, headLen);
|
|
146
|
+
const tail = safeSlice(input, input.length - tailLen);
|
|
147
|
+
return {
|
|
148
|
+
text: `${head}\n\n... [⚠️ 提示:内容过长已由 DSH GitHub 插件截断中间部分,当前截取前 ${Math.round(headLen / 1024)}KB 与后 ${Math.round(tailLen / 1024)}KB] ...\n\n${tail}`,
|
|
149
|
+
truncated: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const byteCut = cutByUtf8Bytes(input, Math.floor(maxBytes * 0.35), Math.floor(maxBytes * 0.55));
|
|
153
|
+
if (byteCut.truncated) {
|
|
154
|
+
// 这里必须用「实际保留的字节数」而非 maxChars 推导值:字节口径触发时 maxChars 往往远大于
|
|
155
|
+
// 字节上限,早先版本照抄字符分支的算法,会报出与实际严重不符的「已保留 100KB」(实际仅数百字节)。
|
|
156
|
+
return {
|
|
157
|
+
text: `${byteCut.head}\n\n... [⚠️ 提示:内容超出 UTF-8 字节上限 (${maxBytes} bytes) 已截断中间部分,实际保留前 ${byteCut.keptHeadBytes} 字节与后 ${byteCut.keptTailBytes} 字节;如需放宽请调大配置项 maxOutputBytes] ...\n\n${byteCut.tail}`,
|
|
158
|
+
truncated: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return { text: input, truncated: false };
|
|
162
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 `gh --version` 输出中解析语义化版本号。
|
|
3
|
+
* 抽成导出纯函数的原因与 truncateOutput 相同:解析逻辑是「看似简单实则易错」的边界区,
|
|
4
|
+
* 必须能用单元测试钉死(不匹配输入返回 null 而非抛错,调用方据此走 fail-open)。
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseGhVersion(output: string): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* 数值分段比较两个 gh 版本:a>b 返回 1、a<b 返回 -1、相等返回 0。
|
|
9
|
+
* 为什么不能用字符串比较:`'2.96.0' < '2.100.0'` 在字典序下为 false('1'<'9'),
|
|
10
|
+
* 这会把 2.100 误判为低于 2.96,导致可用命令被错误降级。逐段 Number 比较是唯一正确姿势。
|
|
11
|
+
*/
|
|
12
|
+
export declare function compareGhVersions(a: string, b: string): number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 `gh --version` 输出中解析语义化版本号。
|
|
3
|
+
* 抽成导出纯函数的原因与 truncateOutput 相同:解析逻辑是「看似简单实则易错」的边界区,
|
|
4
|
+
* 必须能用单元测试钉死(不匹配输入返回 null 而非抛错,调用方据此走 fail-open)。
|
|
5
|
+
*/
|
|
6
|
+
export function parseGhVersion(output) {
|
|
7
|
+
const m = /gh version (\d+\.\d+\.\d+)/.exec(output || '');
|
|
8
|
+
return m ? m[1] : null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 数值分段比较两个 gh 版本:a>b 返回 1、a<b 返回 -1、相等返回 0。
|
|
12
|
+
* 为什么不能用字符串比较:`'2.96.0' < '2.100.0'` 在字典序下为 false('1'<'9'),
|
|
13
|
+
* 这会把 2.100 误判为低于 2.96,导致可用命令被错误降级。逐段 Number 比较是唯一正确姿势。
|
|
14
|
+
*/
|
|
15
|
+
export function compareGhVersions(a, b) {
|
|
16
|
+
const pa = a.split('.').map(Number);
|
|
17
|
+
const pb = b.split('.').map(Number);
|
|
18
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
19
|
+
const da = pa[i] ?? 0;
|
|
20
|
+
const db = pb[i] ?? 0;
|
|
21
|
+
if (da > db)
|
|
22
|
+
return 1;
|
|
23
|
+
if (da < db)
|
|
24
|
+
return -1;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
package/lib/executor.d.ts
CHANGED
|
@@ -1,45 +1,10 @@
|
|
|
1
|
-
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig, GitLocalStatus, GitCommitLog, GitCommitEntry } from './types.js';
|
|
1
|
+
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig, GitLocalStatus, GitCommitLog, GitCommitEntry, GitBranchesResult } from './types.js';
|
|
2
2
|
import type { ProxyManager } from './proxy.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export declare function parseGhVersion(output: string): string | null;
|
|
9
|
-
/**
|
|
10
|
-
* 数值分段比较两个 gh 版本:a>b 返回 1、a<b 返回 -1、相等返回 0。
|
|
11
|
-
* 为什么不能用字符串比较:`'2.96.0' < '2.100.0'` 在字典序下为 false('1'<'9'),
|
|
12
|
-
* 这会把 2.100 误判为低于 2.96,导致可用命令被错误降级。逐段 Number 比较是唯一正确姿势。
|
|
13
|
-
*/
|
|
14
|
-
export declare function compareGhVersions(a: string, b: string): number;
|
|
15
|
-
/**
|
|
16
|
-
* 解析 `git status -sb --porcelain` 输出为结构化状态。
|
|
17
|
-
* 抽导出纯函数(parseGhVersion 同理):三形态解析是易错边界区,单测钉死。
|
|
18
|
-
*
|
|
19
|
-
* 输出形态(实测):
|
|
20
|
-
* `## master...origin/master [ahead 2, behind 1]` 有 ahead/behind
|
|
21
|
-
* `## master...origin/master` 与远程同步
|
|
22
|
-
* `## master` / `## HEAD (no branch)` 无 upstream(ahead/behind 未知=-1)
|
|
23
|
-
* 后续每行 `<XY> <path>` 为 porcelain 变更项(XY 两字符状态码,?? = untracked)。
|
|
24
|
-
*/
|
|
25
|
-
export declare function parseGitStatusOutput(output: string): GitLocalStatus | null;
|
|
26
|
-
/**
|
|
27
|
-
* 解析 `git log` 输出为提交条目数组。
|
|
28
|
-
* 支持两种形态:
|
|
29
|
-
* 1. 结构化带详情:%x1e 为 commit 间分隔符,%x1f 为字段间分隔符(含 fullSha/email/date/body/stat);
|
|
30
|
-
* 2. 简易管道符:%h|%an|%ar|%s 行格式(兼容旧单测输入)。
|
|
31
|
-
* 空输出返回 []。
|
|
32
|
-
*/
|
|
33
|
-
export declare function parseGitLogLines(output: string): GitCommitEntry[];
|
|
34
|
-
/**
|
|
35
|
-
* 按「字符数 + UTF-8 字节数」双口径截断输出。
|
|
36
|
-
* 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
|
|
37
|
-
* 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
|
|
38
|
-
*/
|
|
39
|
-
export declare function truncateOutput(input: string, maxChars: number, maxBytes: number): {
|
|
40
|
-
text: string;
|
|
41
|
-
truncated: boolean;
|
|
42
|
-
};
|
|
3
|
+
import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
|
|
4
|
+
import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
|
|
5
|
+
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines } from './executor/git-parser.js';
|
|
6
|
+
import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
|
|
7
|
+
export { parseGhVersion, compareGhVersions, spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput, parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, getPlatformInstallGuide, getGhUpgradeHint, };
|
|
43
8
|
export declare class GhExecutor {
|
|
44
9
|
ghPath: string;
|
|
45
10
|
defaultTimeoutMs: number;
|
|
@@ -78,12 +43,12 @@ export declare class GhExecutor {
|
|
|
78
43
|
/** 注入代理管理器(main.ts 装配时调用一次;测试可注入 mock) */
|
|
79
44
|
attachProxyManager(manager: ProxyManager): void;
|
|
80
45
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
46
|
+
* 解析命令实际执行的工作区根目录
|
|
47
|
+
* 严格遵循数据事实源与工作区物理所有权,消除越权篡改:
|
|
48
|
+
* 1. 显式路径第一优先级:调用方只要显式指定了路径,该物理路径即为唯一事实源,原样返回自身;
|
|
49
|
+
* 绝不递归扫描子目录、绝不越权篡改;无 Git 目录则让下游探测真实反映无 Git 状态;
|
|
50
|
+
* 2. 仅在未显式指定 cwd 时,才尝试使用 sessionId 或环境变量 DSH_SESSION_ID 反查工作区;
|
|
51
|
+
* 3. 仅在完全未指定 cwd 且无会话时(全局命令行调用),才兜底回退到最近活跃的 Git 工作区或 process.cwd()。
|
|
87
52
|
*/
|
|
88
53
|
resolveWorkspaceCwd(explicitCwd?: string, explicitSessionId?: string): string;
|
|
89
54
|
/**
|
|
@@ -174,9 +139,41 @@ export declare class GhExecutor {
|
|
|
174
139
|
* 语法都不可能通过校验)。输出为 git show --stat 原文(含文件变更统计)。
|
|
175
140
|
*/
|
|
176
141
|
getGitCommitDetail(cwd: string | undefined, sha: string): Promise<string | null>;
|
|
142
|
+
/**
|
|
143
|
+
* 纯本地读取工作区的所有 Git 分支(按提交时间倒序)。
|
|
144
|
+
* 包含当前 HEAD 分支标记、关联 upstream、最新提交简述与相对时间。
|
|
145
|
+
* 零网络往返,毫秒级本地管道输出。
|
|
146
|
+
*/
|
|
147
|
+
getGitBranches(cwd?: string): Promise<GitBranchesResult | null>;
|
|
148
|
+
/**
|
|
149
|
+
* 安全切换当前工作区的 Git 分支。
|
|
150
|
+
* 三重安全防御纪律:
|
|
151
|
+
* 1. 目标工作区必须存在有效的 .git 目录;
|
|
152
|
+
* 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
|
|
153
|
+
* 3. 检查工作区干净度(dirtyCount),若有未提交或未保存的变更直接阻断,杜绝代码被破坏性覆盖。
|
|
154
|
+
*/
|
|
155
|
+
switchGitBranch(cwd: string | undefined, branchName: string): Promise<{
|
|
156
|
+
ok: boolean;
|
|
157
|
+
currentBranch?: string;
|
|
158
|
+
error?: string;
|
|
159
|
+
}>;
|
|
177
160
|
/** 生成当前操作系统平台的推荐安装引导与命令 */
|
|
178
161
|
private getPlatformInstallGuide;
|
|
179
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* 纯本地检测宿主机 GitHub CLI 配置文件中是否存在已配置的账号凭据。
|
|
164
|
+
* 零子进程、零网络往返,毫秒级读取,专供断网与网络卡顿时的 Fail-Safe 容灾降级。
|
|
165
|
+
*/
|
|
166
|
+
checkLocalGhCredential(): {
|
|
167
|
+
user?: string;
|
|
168
|
+
hasToken: boolean;
|
|
169
|
+
} | null;
|
|
170
|
+
/**
|
|
171
|
+
* 实际执行认证探测并解析结果,不含外部缓存逻辑。
|
|
172
|
+
* 解耦本地安装态、本地凭据态与网络可达态:
|
|
173
|
+
* 1. 安装态通过 getGhVersion 本地轻量确认,网络超时/断网绝不篡改 installed 结论;
|
|
174
|
+
* 2. 本地凭据通过 checkLocalGhCredential 读取,断网时仍可识别人类账号;
|
|
175
|
+
* 3. 精准分流网络超时/异常(networkError)与真正的未认证(explicitLoggedOut)。
|
|
176
|
+
*/
|
|
180
177
|
private probeAuth;
|
|
181
178
|
/**
|
|
182
179
|
* 仅从本地 .git/config 解析仓库身份,绝不发起网络请求。
|