dsh-github-flow 0.1.8 → 0.2.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.
Files changed (69) hide show
  1. package/README.en.md +162 -58
  2. package/README.md +167 -60
  3. package/docs/UPGRADE-NOTES.md +93 -0
  4. package/docs/architecture.md +47 -17
  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 +26 -3
  10. package/lib/api/route-helpers.d.ts +47 -0
  11. package/lib/api/route-helpers.js +134 -0
  12. package/lib/api/routes-actions.d.ts +23 -0
  13. package/lib/api/routes-actions.js +239 -0
  14. package/lib/api/routes-lifecycle.d.ts +9 -0
  15. package/lib/api/routes-lifecycle.js +126 -0
  16. package/lib/api/routes-overview.d.ts +11 -0
  17. package/lib/api/routes-overview.js +227 -0
  18. package/lib/api/routes-proxy.d.ts +18 -0
  19. package/lib/api/routes-proxy.js +63 -0
  20. package/lib/api/routes.d.ts +16 -6
  21. package/lib/api/routes.js +14 -504
  22. package/lib/client.js +785 -90
  23. package/lib/commands/gh.js +11 -2
  24. package/lib/executor.d.ts +17 -0
  25. package/lib/executor.js +146 -8
  26. package/lib/main.d.ts +1 -0
  27. package/lib/main.js +49 -9
  28. package/lib/proxy.d.ts +87 -0
  29. package/lib/proxy.js +275 -0
  30. package/lib/renderers/markdown.d.ts +29 -0
  31. package/lib/renderers/markdown.js +520 -0
  32. package/lib/tools/api.d.ts +4 -0
  33. package/lib/tools/api.js +14 -4
  34. package/lib/tools/help.d.ts +31 -0
  35. package/lib/tools/help.js +224 -0
  36. package/lib/tools/image-utils.d.ts +79 -0
  37. package/lib/tools/image-utils.js +247 -0
  38. package/lib/tools/issue.d.ts +40 -4
  39. package/lib/tools/issue.js +167 -19
  40. package/lib/tools/pr.d.ts +29 -4
  41. package/lib/tools/pr.js +144 -18
  42. package/lib/tools/release.d.ts +89 -0
  43. package/lib/tools/release.js +219 -0
  44. package/lib/tools/repo.d.ts +24 -4
  45. package/lib/tools/repo.js +114 -14
  46. package/lib/tools/run.d.ts +22 -1
  47. package/lib/tools/run.js +74 -14
  48. package/lib/tools/secret.d.ts +60 -0
  49. package/lib/tools/secret.js +175 -0
  50. package/lib/tools/skill.d.ts +63 -0
  51. package/lib/tools/skill.js +146 -0
  52. package/lib/types.d.ts +24 -1
  53. package/lib/update-settings.d.ts +18 -0
  54. package/lib/update-settings.js +60 -0
  55. package/lib/updater.d.ts +40 -1
  56. package/lib/updater.js +126 -9
  57. package/lib/upgrade-manager.d.ts +74 -1
  58. package/lib/upgrade-manager.js +356 -46
  59. package/lib/version.d.ts +1 -1
  60. package/lib/version.js +1 -1
  61. package/package.json +3 -2
  62. package/lib/client/icons.d.ts +0 -13
  63. package/lib/client/icons.js +0 -41
  64. package/lib/client/index.d.ts +0 -3
  65. package/lib/client/index.js +0 -36
  66. package/lib/client/panel.d.ts +0 -1
  67. package/lib/client/panel.js +0 -102
  68. package/lib/client/style.d.ts +0 -1
  69. package/lib/client/style.js +0 -555
@@ -0,0 +1,47 @@
1
+ import type { IssueCategory, IssueStats } from '../types.js';
2
+ /**
3
+ * 依据当前登录账号判定 Issue 的归属身份维度 (可多选):
4
+ * - reported: 用户提起的 (属于当前账号名下仓库,但作者不是本人)
5
+ * - assigned: 被分配的 (指派列表中包含当前账号)
6
+ * - created: 我创建的 (作者是当前账号)
7
+ *
8
+ * 为什么这样划分:
9
+ * 在真实工程实践中,Maintainer 不仅要关注自己创建的任务,更要对社区/用户提交的 Bug 反馈负责,
10
+ * 同时还要处理他人指派的待办任务。这三个维度互补且可能重叠。
11
+ */
12
+ export declare function resolveIssueCategories(issue: {
13
+ author?: {
14
+ login?: string;
15
+ } | null;
16
+ assignees?: Array<{
17
+ login?: string;
18
+ }> | null;
19
+ repository?: {
20
+ nameWithOwner?: string;
21
+ } | null;
22
+ }, currentUser?: string, explicitRepoOwner?: string): IssueCategory[];
23
+ export declare function calculateIssueStats(issues: Array<{
24
+ categories?: IssueCategory[];
25
+ }>): IssueStats;
26
+ /** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
27
+ export declare function basenameSafe(p: string): string;
28
+ /**
29
+ * 由用户配置推导全局驾驶舱的缓存时长。
30
+ * 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
31
+ */
32
+ export declare function globalCacheTtlMs(cacheTtlMs: number): number;
33
+ /** open_browser 只允许 http/https,杜绝 file:// 与自定义协议被当作打开目标 */
34
+ export declare function isSafeExternalUrl(raw: unknown): raw is string;
35
+ /** JSON 响应统一出口:no-store 防代理/浏览器缓存干扰面板数据新鲜度语义 */
36
+ export declare function sendJson(res: any, status: number, data: any): void;
37
+ /**
38
+ * 读取并解析请求 JSON body(带默认 256KB 上限防护)。
39
+ * 集中处理超限/畸形 JSON 的错误语义,避免每个 handler 复制流收集样板。
40
+ */
41
+ export declare function readJsonBody(req: any, maxBytes?: number): Promise<any>;
42
+ /** 各路由分组共享的依赖包类型:注册器装配时一次性注入,分组内不再触碰全局 */
43
+ export interface RouteDeps {
44
+ bindRoute: (route: any) => void;
45
+ sendJson: typeof sendJson;
46
+ readJsonBody: typeof readJsonBody;
47
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * API 路由共享工具层。
3
+ *
4
+ * 为什么从 routes.ts 拆出:这些工具被全部路由分组文件消费(sendJson/readJsonBody/
5
+ * issue 聚合/TTL 推导),放在任何一个分组文件里都会造成分组之间的横向依赖;
6
+ * 独立成无状态模块后,各分组只依赖它,注册器只装配。
7
+ */
8
+ import path from 'node:path';
9
+ /**
10
+ * 依据当前登录账号判定 Issue 的归属身份维度 (可多选):
11
+ * - reported: 用户提起的 (属于当前账号名下仓库,但作者不是本人)
12
+ * - assigned: 被分配的 (指派列表中包含当前账号)
13
+ * - created: 我创建的 (作者是当前账号)
14
+ *
15
+ * 为什么这样划分:
16
+ * 在真实工程实践中,Maintainer 不仅要关注自己创建的任务,更要对社区/用户提交的 Bug 反馈负责,
17
+ * 同时还要处理他人指派的待办任务。这三个维度互补且可能重叠。
18
+ */
19
+ export function resolveIssueCategories(issue, currentUser, explicitRepoOwner) {
20
+ const categories = [];
21
+ if (!currentUser)
22
+ return categories;
23
+ const currentLower = currentUser.toLowerCase();
24
+ const authorLogin = issue.author?.login?.toLowerCase();
25
+ const isAuthor = Boolean(authorLogin && authorLogin === currentLower);
26
+ const isAssignee = Array.isArray(issue.assignees) &&
27
+ issue.assignees.some((a) => a.login?.toLowerCase() === currentLower);
28
+ // 仓库 owner 判定:优先从 repository.nameWithOwner 提取,兜底使用 explicitRepoOwner
29
+ let repoOwner = explicitRepoOwner?.toLowerCase();
30
+ if (!repoOwner && issue.repository?.nameWithOwner) {
31
+ repoOwner = issue.repository.nameWithOwner.split('/')[0]?.toLowerCase();
32
+ }
33
+ const isOwnerRepo = Boolean(repoOwner && repoOwner === currentLower);
34
+ if (isAuthor)
35
+ categories.push('created');
36
+ if (isAssignee)
37
+ categories.push('assigned');
38
+ // 如果是当前用户维护的仓库,且作者不是本人,则明确归为「用户提起的」
39
+ if (isOwnerRepo && !isAuthor)
40
+ categories.push('reported');
41
+ return categories;
42
+ }
43
+ export function calculateIssueStats(issues) {
44
+ let reported = 0;
45
+ let assigned = 0;
46
+ let created = 0;
47
+ for (const item of issues) {
48
+ const cats = item.categories || [];
49
+ if (cats.includes('reported'))
50
+ reported++;
51
+ if (cats.includes('assigned'))
52
+ assigned++;
53
+ if (cats.includes('created'))
54
+ created++;
55
+ }
56
+ return {
57
+ total: issues.length,
58
+ reported,
59
+ assigned,
60
+ created,
61
+ };
62
+ }
63
+ /** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
64
+ export function basenameSafe(p) {
65
+ try {
66
+ return path.basename(p) || p;
67
+ }
68
+ catch {
69
+ return p;
70
+ }
71
+ }
72
+ /**
73
+ * 全局驾驶舱的缓存时长下限。
74
+ * 这个端点要 spawn 十几次 gh,比单仓库概览重得多,因此不直接沿用 cacheTtlMs(默认 15s)。
75
+ * 定在 5 分钟的依据:它展示的是「我的仓库 / 待办 PR / Issues」这类分钟级不变的数据,
76
+ * 而每次刷新本机实测要 8-13 秒。缓存太短等于让用户反复等这段延迟——实测 60 秒缓存下,
77
+ * 用户 65 秒后再打开面板仍需等 9.7 秒。仍随 cacheTtlMs 缩放,用户调大时这里同步变长。
78
+ */
79
+ const GLOBAL_CACHE_MIN_TTL_MS = 300_000;
80
+ /**
81
+ * 由用户配置推导全局驾驶舱的缓存时长。
82
+ * 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
83
+ */
84
+ export function globalCacheTtlMs(cacheTtlMs) {
85
+ const base = Number.isFinite(cacheTtlMs) && cacheTtlMs >= 0 ? cacheTtlMs : 15_000;
86
+ return Math.max(base * 20, GLOBAL_CACHE_MIN_TTL_MS);
87
+ }
88
+ /** open_browser 只允许 http/https,杜绝 file:// 与自定义协议被当作打开目标 */
89
+ export function isSafeExternalUrl(raw) {
90
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048)
91
+ return false;
92
+ try {
93
+ const parsed = new URL(raw);
94
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /** JSON 响应统一出口:no-store 防代理/浏览器缓存干扰面板数据新鲜度语义 */
101
+ export function sendJson(res, status, data) {
102
+ res.statusCode = status;
103
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
104
+ res.setHeader('Cache-Control', 'no-store');
105
+ res.end(JSON.stringify(data));
106
+ }
107
+ /**
108
+ * 读取并解析请求 JSON body(带默认 256KB 上限防护)。
109
+ * 集中处理超限/畸形 JSON 的错误语义,避免每个 handler 复制流收集样板。
110
+ */
111
+ export function readJsonBody(req, maxBytes = 256 * 1024) {
112
+ return new Promise((resolve, reject) => {
113
+ let body = '';
114
+ let size = 0;
115
+ req.on('data', (chunk) => {
116
+ size += chunk.length;
117
+ if (size > maxBytes) {
118
+ reject(new Error(`请求体超过 ${maxBytes} 字节上限`));
119
+ req.destroy();
120
+ return;
121
+ }
122
+ body += chunk;
123
+ });
124
+ req.on('end', () => {
125
+ try {
126
+ resolve(body ? JSON.parse(body) : {});
127
+ }
128
+ catch {
129
+ reject(new Error('请求体不是合法 JSON'));
130
+ }
131
+ });
132
+ req.on('error', reject);
133
+ });
134
+ }
@@ -0,0 +1,23 @@
1
+ import type { GhExecutor } from '../executor.js';
2
+ import { TtlCache } from '../cache.js';
3
+ import type { GitHubOverviewData, GlobalOverviewData } from '../types.js';
4
+ import { type RouteDeps } from './route-helpers.js';
5
+ /**
6
+ * 构造「在新终端窗口运行命令」的平台命令(execFile 数组形态,不经 shell)。
7
+ *
8
+ * Issue #2 修复要点:
9
+ * - Windows:cmd.exe(显式 .exe,规避 GUI 父进程 PATH 解析差异)+ start 的窗口标题
10
+ * 占位 "DSH GitHub"——start 的首个带引号参数会被解析为标题而非程序,占位后
11
+ * powershell.exe 才能被稳定识别为目标程序;
12
+ * - macOS:osascript 驱动 Terminal.app,命令经引号转义拼入 AppleScript;
13
+ * - Linux:发行版终端二进制与参数形态各异(gnome-terminal 用 `--`、konsole 用
14
+ * `-e`、xfce4-terminal 用 `-x`),单一硬编码在主流桌面大概率不存在——由
15
+ * detectLinuxTerminal 按探测结果返回对应形态。
16
+ */
17
+ export declare function buildTerminalCommand(command: string): {
18
+ bin: string;
19
+ args: string[];
20
+ } | null;
21
+ /** 测试辅助:重置 Linux 终端探测缓存(便于单测注入不同探测环境) */
22
+ export declare function resetLinuxTerminalCache(): void;
23
+ export declare function registerActionRoutes(deps: RouteDeps, executor: GhExecutor, cacheMap: TtlCache<GitHubOverviewData>, globalCache: TtlCache<GlobalOverviewData>): void;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * 动作类路由:缓存强制刷新 + 右侧栏快捷操作(create_pr/create_issue/open_browser/open_terminal)。
3
+ * 共同特征:POST 触发、读少写多、无缓存语义,与概览类路由的演进方向不同。
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import { isSafeExternalUrl, sendJson } from './route-helpers.js';
7
+ /**
8
+ * 构造「在新终端窗口运行命令」的平台命令(execFile 数组形态,不经 shell)。
9
+ *
10
+ * Issue #2 修复要点:
11
+ * - Windows:cmd.exe(显式 .exe,规避 GUI 父进程 PATH 解析差异)+ start 的窗口标题
12
+ * 占位 "DSH GitHub"——start 的首个带引号参数会被解析为标题而非程序,占位后
13
+ * powershell.exe 才能被稳定识别为目标程序;
14
+ * - macOS:osascript 驱动 Terminal.app,命令经引号转义拼入 AppleScript;
15
+ * - Linux:发行版终端二进制与参数形态各异(gnome-terminal 用 `--`、konsole 用
16
+ * `-e`、xfce4-terminal 用 `-x`),单一硬编码在主流桌面大概率不存在——由
17
+ * detectLinuxTerminal 按探测结果返回对应形态。
18
+ */
19
+ export function buildTerminalCommand(command) {
20
+ if (process.platform === 'win32') {
21
+ return {
22
+ bin: 'cmd.exe',
23
+ // -NoProfile 是 Windows 兼容基线:powershell.exe 是全量自带的 5.1,其默认执行
24
+ // 策略常为 Restricted——加载用户 profile(如 conda init 写入的)会就地报
25
+ // UnauthorizedAccess 红错(实测:winget 安装仍正常,但启动噪音吓人)。
26
+ // 白名单命令(gh/winget/brew/apt)均不依赖用户 profile 环境;pwsh 7 是个人
27
+ // 安装不作依赖,-NoProfile 在 5.1/7 语义一致,一并消除该类报错并加速启动。
28
+ args: ['/c', 'start', 'DSH GitHub', 'powershell.exe', '-NoExit', '-NoProfile', '-Command', command],
29
+ };
30
+ }
31
+ if (process.platform === 'darwin') {
32
+ // AppleScript 字符串内的双引号以 \" 转义;白名单命令虽无引号,仍统一转义防未来扩展踩坑
33
+ const escaped = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
34
+ return {
35
+ bin: 'osascript',
36
+ args: [
37
+ '-e', `tell application "Terminal" to do script "${escaped}"`,
38
+ '-e', 'tell application "Terminal" to activate',
39
+ ],
40
+ };
41
+ }
42
+ if (process.platform === 'linux') {
43
+ const terminal = detectLinuxTerminal();
44
+ if (!terminal)
45
+ return null;
46
+ // 各终端「执行命令并保持窗口」的参数形态不同:
47
+ // - gnome-terminal -- <sh> -c:新版 -e 已弃用,-- 后直接跟命令行
48
+ // - konsole/xfce4-terminal/xterm:-e/-x 执行,sh -c + exec 保交互语义
49
+ // (命令结束后 exec sh 落回交互 shell,窗口不闪退)
50
+ const shArgs = ['sh', '-c', `${command}; exec sh`];
51
+ switch (terminal) {
52
+ case 'gnome-terminal': return { bin: 'gnome-terminal', args: ['--', ...shArgs] };
53
+ case 'konsole': return { bin: 'konsole', args: ['-e', ...shArgs] };
54
+ case 'xfce4-terminal': return { bin: 'xfce4-terminal', args: ['-x', ...shArgs] };
55
+ case 'x-terminal-emulator': return { bin: 'x-terminal-emulator', args: ['-e', ...shArgs] };
56
+ case 'xterm': return { bin: 'xterm', args: ['-e', ...shArgs] };
57
+ default: return null;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /** Linux 终端探测结果缓存(进程级):which 探测有子进程成本,会话内只做一次 */
63
+ let cachedLinuxTerminal;
64
+ /**
65
+ * 探测当前 Linux 桌面可用的终端模拟器(按桌面占有率排序)。
66
+ * 返回 null 表示全部不可用——调用方据此走失败分支而非硬编码赌一个。
67
+ */
68
+ function detectLinuxTerminal() {
69
+ if (cachedLinuxTerminal !== undefined)
70
+ return cachedLinuxTerminal;
71
+ const candidates = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'x-terminal-emulator', 'xterm'];
72
+ try {
73
+ const { execFileSync } = require('node:child_process');
74
+ for (const name of candidates) {
75
+ try {
76
+ execFileSync('which', [name], { stdio: 'ignore', timeout: 2_000 });
77
+ cachedLinuxTerminal = name;
78
+ return name;
79
+ }
80
+ catch { /* 该候选不存在,试下一个 */ }
81
+ }
82
+ }
83
+ catch { /* which 本身不可用(极端环境),视为无可用终端 */ }
84
+ cachedLinuxTerminal = null;
85
+ return null;
86
+ }
87
+ /** 测试辅助:重置 Linux 终端探测缓存(便于单测注入不同探测环境) */
88
+ export function resetLinuxTerminalCache() {
89
+ cachedLinuxTerminal = undefined;
90
+ }
91
+ /**
92
+ * 执行终端唤起并等待真实结果(Issue #2 核心修复:不再先斩后奏报假成功)。
93
+ * start/osascript 场景下命令随宿主立即退出,close 即代表唤起动作用的成败;
94
+ * err 与 stderr 原样上抛给调用方决定 HTTP 语义。
95
+ */
96
+ function launchTerminalAndWait(command) {
97
+ return new Promise((resolve) => {
98
+ const built = buildTerminalCommand(command);
99
+ if (!built) {
100
+ resolve({ ok: false, error: '未找到可用的系统终端(Linux 下请先安装 gnome-terminal / konsole 等任一终端模拟器)' });
101
+ return;
102
+ }
103
+ // stdio 必须忽略:start 唤起的交互式窗口会继承 execFile 的 stdout/stderr 管道句柄,
104
+ // -NoExit 窗口存活期间管道永不关闭——回调要等满 10s 超时甚至不触发(实测复现:
105
+ // 唤起成功的窗口让 HTTP 响应挂起 10s+)。交互终端本就不该捕获输出;stdio ignore
106
+ // 后等待语义回归 cmd.exe 自身退出(start 创建窗口后立即返回,亚秒级)。
107
+ // 改用原生 spawn:execFile 的类型与实现都强制 stdout 管道捕获,而交互终端唤起
108
+ // 场景需要的恰恰是「不捕获」——spawn 的 stdio:'ignore' 是官方支持的干净形态。
109
+ const child = spawn(built.bin, built.args, {
110
+ stdio: 'ignore',
111
+ detached: false,
112
+ });
113
+ // 10s 兜底超时:正常路径 cmd.exe start 毫秒级返回;超时视为唤起故障并如实回传
114
+ let timedOut = false;
115
+ const killTimer = setTimeout(() => { timedOut = true; child.kill(); }, 10_000);
116
+ child.on('error', (err) => {
117
+ clearTimeout(killTimer);
118
+ resolve({ ok: false, error: err.message });
119
+ });
120
+ child.on('close', (code) => {
121
+ clearTimeout(killTimer);
122
+ if (code === 0) {
123
+ resolve({ ok: true });
124
+ }
125
+ else if (timedOut) {
126
+ // kill 触发的 close 是 code=null——如实归因为超时而非"退出码 null"
127
+ resolve({ ok: false, error: '终端启动命令 10 秒未返回(可能被安全软件拦截)' });
128
+ }
129
+ else {
130
+ resolve({ ok: false, error: `终端启动命令退出码 ${code}` });
131
+ }
132
+ });
133
+ });
134
+ }
135
+ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
136
+ const { bindRoute } = deps;
137
+ // 1. 强制刷新缓存
138
+ bindRoute({
139
+ kind: 'exact',
140
+ path: '/api/github/refresh',
141
+ handler: async (req, res) => {
142
+ cacheMap.clear();
143
+ globalCache.clear();
144
+ sendJson(res, 200, { ok: true, message: '缓存已清空,下次读取将拉取最新数据' });
145
+ },
146
+ });
147
+ // 2. 右侧栏快捷操作代理接口
148
+ bindRoute({
149
+ kind: 'exact',
150
+ path: '/api/github/action',
151
+ handler: async (req, res) => {
152
+ if (req.method !== 'POST') {
153
+ sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
154
+ return;
155
+ }
156
+ let body = '';
157
+ req.on('data', (chunk) => { body += chunk; });
158
+ req.on('end', async () => {
159
+ try {
160
+ const payload = body ? JSON.parse(body) : {};
161
+ const cwd = payload.cwd || process.cwd();
162
+ switch (payload.action) {
163
+ case 'create_pr': {
164
+ const cmd = ['pr', 'create', '--title', payload.title || 'Update', '--body', payload.body || 'Created from DSH Rightbar'];
165
+ if (payload.draft)
166
+ cmd.push('--draft');
167
+ const r = await executor.run(cmd, { cwd });
168
+ if (!r.ok)
169
+ throw new Error(r.error);
170
+ sendJson(res, 200, { ok: true, message: 'PR 创建成功', url: r.rawOutput });
171
+ break;
172
+ }
173
+ case 'create_issue': {
174
+ const cmd = ['issue', 'create', '--title', payload.title || 'New Issue', '--body', payload.body || 'Created from DSH Rightbar'];
175
+ const r = await executor.run(cmd, { cwd });
176
+ if (!r.ok)
177
+ throw new Error(r.error);
178
+ sendJson(res, 200, { ok: true, message: 'Issue 创建成功', url: r.rawOutput });
179
+ break;
180
+ }
181
+ case 'open_browser': {
182
+ // 原实现把 payload.url 直接拼进 shell 字符串再交给 exec(),
183
+ // 形如 https://x" & calc & " 的 URL 可闭合引号并追加任意命令——本机任意页面
184
+ // 都能通过 POST 这个端点触发命令执行。
185
+ // 现在改为:先用 URL 解析白名单协议,再用 execFile 参数数组传递,全程不经过 shell。
186
+ if (!isSafeExternalUrl(payload.url)) {
187
+ throw new Error('url 参数无效:只允许 http/https 开头的完整地址');
188
+ }
189
+ const { execFile } = await import('node:child_process');
190
+ const [bin, args] = process.platform === 'win32'
191
+ ? ['cmd', ['/c', 'start', '', payload.url]]
192
+ : process.platform === 'darwin'
193
+ ? ['open', [payload.url]]
194
+ : ['xdg-open', [payload.url]];
195
+ // 不 await,也不让打开失败反向影响接口语义;仅记录以便排查
196
+ execFile(bin, args, (err) => {
197
+ if (err)
198
+ console.warn('[dsh-github-flow] 打开浏览器失败:', err?.message || err);
199
+ });
200
+ sendJson(res, 200, { ok: true, message: '已在浏览器打开' });
201
+ break;
202
+ }
203
+ case 'open_terminal': {
204
+ const command = typeof payload.command === 'string' ? payload.command.trim() : 'gh auth login';
205
+ // 严格白名单过滤,严防任意命令注入
206
+ const allowedCommands = [
207
+ 'gh auth login',
208
+ 'winget install --id GitHub.cli',
209
+ 'brew install gh',
210
+ 'sudo apt install gh',
211
+ 'sudo dnf install gh',
212
+ ];
213
+ if (!allowedCommands.includes(command)) {
214
+ throw new Error(`不支持唤起该指令: ${command}`);
215
+ }
216
+ // Issue #2 修复:等待真实唤起结果再回传——旧实现 execFile 异步回调吞错、
217
+ // 路由先行返回 ok:true,导致终端弹不弹都提示成功。
218
+ const launch = await launchTerminalAndWait(command);
219
+ if (!launch.ok) {
220
+ sendJson(res, 500, {
221
+ ok: false,
222
+ message: `唤起终端失败:${launch.error}。请手动打开系统终端执行:${command}`,
223
+ });
224
+ return;
225
+ }
226
+ sendJson(res, 200, { ok: true, message: '已唤起系统终端' });
227
+ break;
228
+ }
229
+ default:
230
+ sendJson(res, 400, { ok: false, message: `未知的操作类型: ${payload.action}` });
231
+ }
232
+ }
233
+ catch (err) {
234
+ sendJson(res, 500, { ok: false, error: err.message || '操作执行失败' });
235
+ }
236
+ });
237
+ },
238
+ });
239
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 生命周期类路由:版本检查 / 一键升级 / 升级状态轮询 / 认证轻量探针 / 更新源设置。
3
+ * 共同特征:服务插件自身状态而非 GitHub 业务数据,故障语义独立于概览链路。
4
+ */
5
+ import type { GhExecutor } from '../executor.js';
6
+ import type { UpdateChecker } from '../updater.js';
7
+ import type { UpgradeManager } from '../upgrade-manager.js';
8
+ import { type RouteDeps } from './route-helpers.js';
9
+ export declare function registerLifecycleRoutes(deps: RouteDeps, executor: GhExecutor, checker: UpdateChecker, upgrader: UpgradeManager): void;
@@ -0,0 +1,126 @@
1
+ import { loadUpdateSettings, saveUpdateSettings, isValidUpdateMode, describeUpdateMode } from '../update-settings.js';
2
+ import { sendJson, readJsonBody } from './route-helpers.js';
3
+ export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
4
+ const { bindRoute } = deps;
5
+ // 1. 闲时静默检查更新端点(前端在 gh 业务数据完全拉取就绪后,由 idle 延时静默触发)
6
+ bindRoute({
7
+ kind: 'exact',
8
+ path: '/api/github/check-update',
9
+ handler: async (req, res) => {
10
+ if (req.method !== 'GET') {
11
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
12
+ return;
13
+ }
14
+ try {
15
+ const url = new URL(req.url || '', 'http://127.0.0.1');
16
+ const force = url.searchParams.get('force') === 'true';
17
+ const info = await checker.check(force);
18
+ sendJson(res, 200, { ok: true, data: info });
19
+ }
20
+ catch (err) {
21
+ sendJson(res, 500, { ok: false, error: err.message || '检查更新失败' });
22
+ }
23
+ },
24
+ });
25
+ // 2. 一键自动升级触发端点(用户点击操作横幅按钮后发起)
26
+ bindRoute({
27
+ kind: 'exact',
28
+ path: '/api/github/upgrade',
29
+ handler: async (req, res) => {
30
+ if (req.method !== 'POST') {
31
+ sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
32
+ return;
33
+ }
34
+ try {
35
+ // 一键升级是用户显式动作,不受 checkUpdate 配置熔断:check() 在禁用时
36
+ // 会返回 latestVersion 缺失的兜底态,此时回退 'latest' 让 pnpm 自行解析
37
+ // 最新版(与 UpgradeManager 自身的 targetVersion 可选语义一致)。
38
+ const info = await checker.check(false);
39
+ const target = info.latestVersion || 'latest';
40
+ const result = await upgrader.executeUpgrade(target);
41
+ sendJson(res, result.ok ? 200 : 500, result);
42
+ }
43
+ catch (err) {
44
+ sendJson(res, 500, { ok: false, message: err.message || '执行自动升级异常' });
45
+ }
46
+ },
47
+ });
48
+ // 3. 升级状态与实时日志查询端点
49
+ bindRoute({
50
+ kind: 'exact',
51
+ path: '/api/github/upgrade-status',
52
+ handler: async (req, res) => {
53
+ if (req.method !== 'GET') {
54
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
55
+ return;
56
+ }
57
+ sendJson(res, 200, { ok: true, data: upgrader.getStatus() });
58
+ },
59
+ });
60
+ // 5. 更新源设置:GET 读取(store 权威,缺省 official)/ POST 保存并立即用新源验证
61
+ bindRoute({
62
+ kind: 'exact',
63
+ path: '/api/github/update-settings',
64
+ handler: async (req, res) => {
65
+ if (req.method === 'GET') {
66
+ const mode = loadUpdateSettings();
67
+ sendJson(res, 200, { ok: true, data: { mode, description: describeUpdateMode(mode) } });
68
+ return;
69
+ }
70
+ if (req.method !== 'POST') {
71
+ sendJson(res, 405, { ok: false, message: '仅支持 GET / POST 请求' });
72
+ return;
73
+ }
74
+ try {
75
+ const body = await readJsonBody(req);
76
+ const mode = body?.mode;
77
+ if (!isValidUpdateMode(mode)) {
78
+ sendJson(res, 400, { ok: false, message: `非法更新源模式: ${String(mode)}(允许 official / mirror / off)` });
79
+ return;
80
+ }
81
+ // 写盘失败不阻断内存态生效(save 内部已 warn),返回携带 error 提示重启后需重设
82
+ const saved = saveUpdateSettings(mode);
83
+ checker.applySettings(mode);
84
+ // 非 off 模式:立即用新源真探一次,把结果带回前端展示(缓存已被 applySettings 失效)
85
+ let probe = null;
86
+ if (mode !== 'off') {
87
+ const info = await checker.check(true);
88
+ probe = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate, checkedAt: info.checkedAt };
89
+ }
90
+ sendJson(res, 200, {
91
+ ok: true,
92
+ data: {
93
+ mode,
94
+ description: describeUpdateMode(mode),
95
+ persisted: saved.ok,
96
+ persistError: saved.error,
97
+ probe,
98
+ },
99
+ });
100
+ }
101
+ catch (err) {
102
+ sendJson(res, 500, { ok: false, message: err.message || '保存更新源设置失败' });
103
+ }
104
+ },
105
+ });
106
+ // 4. 快速认证状态轻量探针 (耗时 < 500ms,专供登录自愈检测与快速恢复)
107
+ bindRoute({
108
+ kind: 'exact',
109
+ path: '/api/github/auth',
110
+ handler: async (req, res) => {
111
+ if (req.method !== 'GET') {
112
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
113
+ return;
114
+ }
115
+ try {
116
+ const url = new URL(req.url || '', 'http://127.0.0.1');
117
+ const force = url.searchParams.get('force') === 'true';
118
+ const auth = await executor.checkAuth(undefined, force);
119
+ sendJson(res, 200, { ok: true, auth });
120
+ }
121
+ catch (err) {
122
+ sendJson(res, 500, { ok: false, error: err.message || '认证检测失败' });
123
+ }
124
+ },
125
+ });
126
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 概览类路由:单仓库 overview + 全局驾驶舱 global-overview。
3
+ * 这两个端点是面板首屏关键路径(也是项目里最重的两个 handler),单独成组
4
+ * 便于按「读密集」特征统一演进(缓存策略、字段投影、并发控制)。
5
+ */
6
+ import type { GhExecutor } from '../executor.js';
7
+ import { TtlCache } from '../cache.js';
8
+ import type { UpdateChecker } from '../updater.js';
9
+ import type { GitHubOverviewData, GlobalOverviewData } from '../types.js';
10
+ import { type RouteDeps } from './route-helpers.js';
11
+ export declare function registerOverviewRoutes(deps: RouteDeps, executor: GhExecutor, cacheMap: TtlCache<GitHubOverviewData>, globalCache: TtlCache<GlobalOverviewData>, checker: UpdateChecker): void;