dsh-github-flow 0.1.3 → 0.1.5
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 +34 -7
- package/README.md +33 -7
- package/cordis.patch.yml +2 -2
- package/docs/architecture.md +14 -8
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/lib/api/routes.d.ts +6 -1
- package/lib/api/routes.js +147 -94
- package/lib/cache.d.ts +21 -0
- package/lib/cache.js +48 -0
- package/lib/client.js +129 -38
- package/lib/commands/gh.js +3 -1
- package/lib/executor.d.ts +38 -1
- package/lib/executor.js +190 -26
- package/lib/github-service.d.ts +26 -0
- package/lib/github-service.js +1 -0
- package/lib/index.d.ts +2 -4
- package/lib/index.js +2 -30
- package/lib/main.d.ts +19 -1
- package/lib/main.js +42 -6
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +23 -4
- package/lib/tools/pr.d.ts +12 -0
- package/lib/tools/pr.js +30 -3
- package/lib/tools/repo.js +8 -2
- package/lib/types.d.ts +4 -0
- package/lib/workspace.d.ts +43 -0
- package/lib/workspace.js +136 -0
- package/package.json +2 -1
package/lib/client.js
CHANGED
|
@@ -304,6 +304,80 @@ body[data-ds-dark-theme] .dsh-github-action-btn:hover {
|
|
|
304
304
|
color: #58a6ff;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/* 组合式快捷操作按钮 (AI 智能装填主操作 + 官方外链直达) */
|
|
308
|
+
.dsh-github-split-btn {
|
|
309
|
+
display: flex;
|
|
310
|
+
align-items: stretch;
|
|
311
|
+
border-radius: 6px;
|
|
312
|
+
border: 1px solid var(--dsw-alias-border-l2, #e1e4e8);
|
|
313
|
+
background: var(--dsw-alias-bg-module-platform, #f6f8fa);
|
|
314
|
+
overflow: hidden;
|
|
315
|
+
transition: all 0.15s ease;
|
|
316
|
+
}
|
|
317
|
+
.dsh-github-split-btn:hover {
|
|
318
|
+
border-color: var(--dsw-alias-link, #0969da);
|
|
319
|
+
}
|
|
320
|
+
body[data-ds-dark-theme] .dsh-github-split-btn {
|
|
321
|
+
border-color: var(--dsw-alias-border-l1, #444);
|
|
322
|
+
background: var(--dsw-alias-bg-layer-2, #28282b);
|
|
323
|
+
}
|
|
324
|
+
body[data-ds-dark-theme] .dsh-github-split-btn:hover {
|
|
325
|
+
border-color: #58a6ff;
|
|
326
|
+
}
|
|
327
|
+
.dsh-github-split-main {
|
|
328
|
+
flex: 1;
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
justify-content: center;
|
|
332
|
+
gap: 5px;
|
|
333
|
+
padding: 8px 8px;
|
|
334
|
+
border: 0;
|
|
335
|
+
background: transparent;
|
|
336
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
337
|
+
font-size: 12px;
|
|
338
|
+
font-weight: 500;
|
|
339
|
+
cursor: pointer;
|
|
340
|
+
transition: all 0.15s ease;
|
|
341
|
+
white-space: nowrap;
|
|
342
|
+
}
|
|
343
|
+
.dsh-github-split-main:hover {
|
|
344
|
+
background: var(--dsw-alias-interactive-bg-hover, #ebeef2);
|
|
345
|
+
color: var(--dsw-alias-link, #0969da);
|
|
346
|
+
}
|
|
347
|
+
body[data-ds-dark-theme] .dsh-github-split-main {
|
|
348
|
+
color: var(--dsw-alias-label-primary, #fff);
|
|
349
|
+
}
|
|
350
|
+
body[data-ds-dark-theme] .dsh-github-split-main:hover {
|
|
351
|
+
background: var(--dsw-alias-interactive-bg-hover, #343438);
|
|
352
|
+
color: #58a6ff;
|
|
353
|
+
}
|
|
354
|
+
.dsh-github-split-link {
|
|
355
|
+
display: flex;
|
|
356
|
+
align-items: center;
|
|
357
|
+
justify-content: center;
|
|
358
|
+
padding: 0 8px;
|
|
359
|
+
border-left: 1px solid var(--dsw-alias-border-l2, #e1e4e8);
|
|
360
|
+
background: transparent;
|
|
361
|
+
color: var(--dsw-alias-label-tertiary, #656d76);
|
|
362
|
+
font-size: 12px;
|
|
363
|
+
font-weight: bold;
|
|
364
|
+
text-decoration: none !important;
|
|
365
|
+
cursor: pointer;
|
|
366
|
+
transition: all 0.15s ease;
|
|
367
|
+
}
|
|
368
|
+
.dsh-github-split-link:hover {
|
|
369
|
+
background: var(--dsw-alias-interactive-bg-hover, #ebeef2);
|
|
370
|
+
color: var(--dsw-alias-link, #0969da);
|
|
371
|
+
}
|
|
372
|
+
body[data-ds-dark-theme] .dsh-github-split-link {
|
|
373
|
+
border-left-color: var(--dsw-alias-border-l1, #444);
|
|
374
|
+
color: var(--dsw-alias-label-secondary, #9ca3af);
|
|
375
|
+
}
|
|
376
|
+
body[data-ds-dark-theme] .dsh-github-split-link:hover {
|
|
377
|
+
background: var(--dsw-alias-interactive-bg-hover, #343438);
|
|
378
|
+
color: #58a6ff;
|
|
379
|
+
}
|
|
380
|
+
|
|
307
381
|
/* 9. 模块分区与列表 */
|
|
308
382
|
.dsh-github-section {
|
|
309
383
|
margin-bottom: 20px;
|
|
@@ -609,23 +683,6 @@ body[data-ds-dark-theme] .dsh-github-subheading {
|
|
|
609
683
|
onClick: onRefresh,
|
|
610
684
|
disabled: loading,
|
|
611
685
|
}, loading ? "刷新中..." : "🔄 刷新"));
|
|
612
|
-
// 操作执行函数
|
|
613
|
-
const runAction = async (action, extra = {}) => {
|
|
614
|
-
try {
|
|
615
|
-
const res = await fetch("/api/github/action", {
|
|
616
|
-
method: "POST",
|
|
617
|
-
headers: { "Content-Type": "application/json" },
|
|
618
|
-
body: JSON.stringify({ action, ...extra }),
|
|
619
|
-
});
|
|
620
|
-
const json = await res.json();
|
|
621
|
-
alert(json.ok ? `✅ ${json.message || "操作已完成"}` : `❌ ${json.error || "操作失败"}`);
|
|
622
|
-
if (json.ok)
|
|
623
|
-
onRefresh();
|
|
624
|
-
}
|
|
625
|
-
catch (err) {
|
|
626
|
-
alert(`网络异常: ${err.message}`);
|
|
627
|
-
}
|
|
628
|
-
};
|
|
629
686
|
// 仓库基础卡片
|
|
630
687
|
var repoCardEl = h("div", { className: "dsh-github-card" }, h("div", { className: "dsh-github-card-title" }, "当前工作区 GitHub 仓库"), h("div", { className: "dsh-github-card-value" }, repo
|
|
631
688
|
? h("a", { href: repo.url, target: "_blank", className: "dsh-github-repo-link" }, repo.nameWithOwner)
|
|
@@ -636,40 +693,46 @@ body[data-ds-dark-theme] .dsh-github-subheading {
|
|
|
636
693
|
: null, !repo
|
|
637
694
|
? h("div", { className: "dsh-github-card-subtext" }, "若本地已有 git 仓库,请运行 `git remote add github <url>` 或 `git remote add origin <url>` 关联远程仓库。")
|
|
638
695
|
: null);
|
|
639
|
-
// 快捷操作栏
|
|
640
|
-
var actionsBarEl = h("div", { className: "dsh-github-quick-actions" },
|
|
696
|
+
// 快捷操作栏 (方案 1: 官方黄金功能直达)
|
|
697
|
+
var actionsBarEl = h("div", { className: "dsh-github-quick-actions" },
|
|
698
|
+
// 1. 浏览器打开仓库主页
|
|
699
|
+
h("button", {
|
|
641
700
|
className: "dsh-github-action-btn",
|
|
701
|
+
title: "在系统默认浏览器中打开当前仓库主页",
|
|
642
702
|
onClick: () => {
|
|
643
703
|
if (repo?.url) {
|
|
644
704
|
window.open(repo.url, "_blank", "noopener,noreferrer");
|
|
645
705
|
}
|
|
646
|
-
else {
|
|
647
|
-
alert("当前工作区尚未关联 GitHub 仓库");
|
|
648
|
-
}
|
|
649
706
|
},
|
|
650
|
-
}, "🌐 浏览器打开"),
|
|
707
|
+
}, "🌐 浏览器打开"),
|
|
708
|
+
// 2. 查看所有 PRs
|
|
709
|
+
h("button", {
|
|
651
710
|
className: "dsh-github-action-btn",
|
|
711
|
+
title: "在系统默认浏览器中查看所有 Pull Requests",
|
|
652
712
|
onClick: () => {
|
|
653
713
|
if (repo?.url) {
|
|
654
714
|
window.open(`${repo.url}/pulls`, "_blank", "noopener,noreferrer");
|
|
655
715
|
}
|
|
656
|
-
else {
|
|
657
|
-
alert("当前工作区尚未关联 GitHub 仓库");
|
|
658
|
-
}
|
|
659
716
|
},
|
|
660
|
-
}, "🔀 查看所有 PRs"),
|
|
717
|
+
}, "🔀 查看所有 PRs"),
|
|
718
|
+
// 3. 🚀 快速创建 PR (官方对比与创建页直达)
|
|
719
|
+
h("button", {
|
|
661
720
|
className: "dsh-github-action-btn",
|
|
721
|
+
title: "在系统默认浏览器中打开 GitHub 官方对比与创建 Pull Request 页面",
|
|
662
722
|
onClick: () => {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
723
|
+
if (repo?.url) {
|
|
724
|
+
window.open(`${repo.url}/compare`, "_blank", "noopener,noreferrer");
|
|
725
|
+
}
|
|
666
726
|
},
|
|
667
|
-
}, "🚀 快速创建 PR"),
|
|
727
|
+
}, "🚀 快速创建 PR"),
|
|
728
|
+
// 4. 📝 新建 Issue (官方 Issue 模板页直达)
|
|
729
|
+
h("button", {
|
|
668
730
|
className: "dsh-github-action-btn",
|
|
731
|
+
title: "在系统默认浏览器中打开 GitHub 官方新建 Issue 页面",
|
|
669
732
|
onClick: () => {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
733
|
+
if (repo?.url) {
|
|
734
|
+
window.open(`${repo.url}/issues/new`, "_blank", "noopener,noreferrer");
|
|
735
|
+
}
|
|
673
736
|
},
|
|
674
737
|
}, "📝 新建 Issue"));
|
|
675
738
|
// PR 列表
|
|
@@ -720,6 +783,11 @@ body[data-ds-dark-theme] .dsh-github-subheading {
|
|
|
720
783
|
url += "?" + params.join("&");
|
|
721
784
|
}
|
|
722
785
|
var res = await fetch(url);
|
|
786
|
+
if (!res.ok) {
|
|
787
|
+
var errText = await res.text().catch(() => "");
|
|
788
|
+
setError(`后端服务未就绪 (${res.status}): ${errText || res.statusText}`);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
723
791
|
var json = await res.json();
|
|
724
792
|
if (json.ok && json.data) {
|
|
725
793
|
setData(json.data);
|
|
@@ -769,20 +837,43 @@ body[data-ds-dark-theme] .dsh-github-subheading {
|
|
|
769
837
|
var loadData = React.useCallback(async (forceRefresh = false) => {
|
|
770
838
|
setLoading(true);
|
|
771
839
|
setError(null);
|
|
840
|
+
// 全局驾驶舱要 spawn 十几次 gh,冷启动可能超过 10 秒。
|
|
841
|
+
// 不设显式超时的话浏览器会抛一个与真实原因无关的网络错误,
|
|
842
|
+
// 早先这里会把所有失败都归因成「后端服务未就绪或接口不存在」——归因错误比没有报错更糟。
|
|
843
|
+
var controller = new AbortController();
|
|
844
|
+
var timedOut = false;
|
|
845
|
+
var timer = setTimeout(function () { timedOut = true; controller.abort(); }, 30000);
|
|
772
846
|
try {
|
|
773
847
|
if (forceRefresh)
|
|
774
848
|
await fetch("/api/github/refresh", { method: "POST" }).catch(() => { });
|
|
775
|
-
var res = await fetch("/api/github/global-overview");
|
|
849
|
+
var res = await fetch("/api/github/global-overview", { signal: controller.signal });
|
|
850
|
+
if (!res.ok) {
|
|
851
|
+
var errText = await res.text().catch(() => "");
|
|
852
|
+
setError(`获取全局数据失败(HTTP ${res.status})${errText ? ": " + errText : ""}`);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
776
855
|
var json = await res.json();
|
|
777
|
-
if (json.ok && json.data)
|
|
856
|
+
if (json.ok && json.data) {
|
|
778
857
|
setData(json.data);
|
|
779
|
-
|
|
858
|
+
// 工作区矩阵单独失败时不能静默:否则界面显示「暂无工作区」,把故障伪装成空状态
|
|
859
|
+
if (json.data.workspaceError) {
|
|
860
|
+
setError("工作区列表读取失败:" + json.data.workspaceError);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
780
864
|
setError(json.error || "获取全局数据失败");
|
|
865
|
+
}
|
|
781
866
|
}
|
|
782
867
|
catch (err) {
|
|
783
|
-
|
|
868
|
+
if (timedOut || err.name === "AbortError") {
|
|
869
|
+
setError("获取超时(30 秒):本机 gh 命令响应过慢,通常是网络或工作区数量较多所致。可稍后重试,或减少同时打开的工作区。");
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
setError("无法连接后端服务:" + (err.message || err));
|
|
873
|
+
}
|
|
784
874
|
}
|
|
785
875
|
finally {
|
|
876
|
+
clearTimeout(timer);
|
|
786
877
|
setLoading(false);
|
|
787
878
|
}
|
|
788
879
|
}, []);
|
package/lib/commands/gh.js
CHANGED
|
@@ -7,7 +7,9 @@ export function registerGhCommand(commandsService, executor) {
|
|
|
7
7
|
async handler({ rawInput }) {
|
|
8
8
|
const trimmed = (rawInput || '').trim();
|
|
9
9
|
if (!trimmed || trimmed === 'status') {
|
|
10
|
-
|
|
10
|
+
// 人类显式诊断必须绕过缓存:用户敲 /gh status 往往正是为了确认
|
|
11
|
+
// 「我刚 gh auth login 到底成没成」,给他 30 秒前的缓存等于答非所问。
|
|
12
|
+
const auth = await executor.checkAuth(undefined, true);
|
|
11
13
|
const repo = await executor.getRepoMetadata();
|
|
12
14
|
let md = `### GitHub 集成状态\n\n`;
|
|
13
15
|
if (auth.loggedIn) {
|
package/lib/executor.d.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 按「字符数 + UTF-8 字节数」双口径截断输出。
|
|
4
|
+
* 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
|
|
5
|
+
* 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
|
|
6
|
+
*/
|
|
7
|
+
export declare function truncateOutput(input: string, maxChars: number, maxBytes: number): {
|
|
8
|
+
text: string;
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
};
|
|
2
11
|
export declare class GhExecutor {
|
|
3
12
|
ghPath: string;
|
|
4
13
|
defaultTimeoutMs: number;
|
|
5
14
|
maxOutputChars: number;
|
|
15
|
+
/** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
|
|
16
|
+
maxOutputBytes: number;
|
|
17
|
+
/** 认证状态缓存。`gh auth status` 实测 5.4 秒,而它位于全局驾驶舱关键路径的最前面 */
|
|
18
|
+
private authCache;
|
|
6
19
|
constructor(config?: PluginConfig);
|
|
7
20
|
updateConfig(config?: PluginConfig): void;
|
|
21
|
+
/**
|
|
22
|
+
* 智能解析最可靠的工作区物理路径:
|
|
23
|
+
* 1. 优先使用显式指定的有效 cwd(若包含 .git 目录)
|
|
24
|
+
* 2. 读取当前进程/会话的 DSH_SESSION_ID 并匹配 ~/.dsh/storages/workspace.json
|
|
25
|
+
* 3. 匹配 ~/.dsh/storages/workspace.json 中最近活跃的工作区
|
|
26
|
+
* 4. 回退到 process.cwd()
|
|
27
|
+
*/
|
|
28
|
+
resolveWorkspaceCwd(explicitCwd?: string): string;
|
|
8
29
|
/**
|
|
9
30
|
* 安全执行 gh 命令
|
|
10
31
|
* 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
|
|
@@ -12,8 +33,24 @@ export declare class GhExecutor {
|
|
|
12
33
|
run<T = unknown>(args: string[], options?: GhExecutionOptions): Promise<GhResult<T>>;
|
|
13
34
|
/**
|
|
14
35
|
* 检查宿主机当前 GitHub CLI 的认证状态与账号信息
|
|
36
|
+
*
|
|
37
|
+
* @param cwd 解析工作区用的路径
|
|
38
|
+
* @param bypassCache 跳过进程内缓存。`/gh status` 这类人类显式发起的诊断必须看到实时结果,
|
|
39
|
+
* 而 Web 面板每次挂载都会来问一次,重复执行实测要 5.4 秒,属于纯浪费。
|
|
40
|
+
*/
|
|
41
|
+
checkAuth(cwd?: string, bypassCache?: boolean): Promise<AuthStatus>;
|
|
42
|
+
/** 实际执行 `gh auth status` 并解析结果,不含缓存逻辑 */
|
|
43
|
+
private probeAuth;
|
|
44
|
+
/**
|
|
45
|
+
* 仅从本地 .git/config 解析仓库身份,绝不发起网络请求。
|
|
46
|
+
*
|
|
47
|
+
* 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
|
|
48
|
+
* `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
|
|
49
|
+
* 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
|
|
50
|
+
* 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
|
|
51
|
+
* 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
|
|
15
52
|
*/
|
|
16
|
-
|
|
53
|
+
getRepoIdentityFromLocal(cwd?: string): RepoMetadata | null;
|
|
17
54
|
/**
|
|
18
55
|
* 获取当前目录(工作区)关联的远程仓库元数据
|
|
19
56
|
* 采用 gh repo view + 本地 git remote 双保险机制,彻底杜绝已配置远程却误判未关联的问题
|
package/lib/executor.js
CHANGED
|
@@ -1,29 +1,151 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { findMostRecentWorkspacePath, findWorkspacePathBySession, hasGitDir } from './workspace.js';
|
|
3
6
|
const execFileAsync = promisify(execFile);
|
|
7
|
+
/** 认证状态缓存时长。用户手动 `gh auth login/logout` 后最多 30 秒内面板才会反映,属可接受代价 */
|
|
8
|
+
const AUTH_CACHE_TTL_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* 按码点安全截取字符串,等价于 String.prototype.slice 但对孤立的代理对(surrogate pair)做占位替换。
|
|
11
|
+
* 为什么必须这样截:本文件末尾会按 UTF-8 字节数截断,而 node:child_process 把 stdout 按字节缓冲区
|
|
12
|
+
* 切分后再 toString('utf8'),一个汉字被从中间切开时会变成 U+FFFD 替换字符。直接用 slice 会把半截
|
|
13
|
+
* 代理对留在结果里,导致下游 JSON.stringify 产出孤立转义序列、写入文件时编码报错。
|
|
14
|
+
*/
|
|
15
|
+
function safeSlice(input, start, end) {
|
|
16
|
+
const chars = Array.from(input);
|
|
17
|
+
const from = Math.max(0, start);
|
|
18
|
+
const to = end === undefined ? chars.length : Math.max(from, end);
|
|
19
|
+
return chars.slice(from, to).map((c) => (c.length === 1 && c.charCodeAt(0) >= 0xd800 && c.charCodeAt(0) <= 0xdfff ? '\ufffd' : c)).join('');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 以 UTF-8 字节数(而非字符串长度)为口径,从两端保留内容。
|
|
23
|
+
* 为什么需要:maxOutputChars 数的是字符,中文一个字占 3 字节。只按字符限长时,
|
|
24
|
+
* 中文环境下真实放行的字节量可达标称值的数倍,Token 预算保护会失真。
|
|
25
|
+
* 采用二分查找定位截断点,避免对超大输出逐字符累加造成 O(n) 反复编码。
|
|
26
|
+
*/
|
|
27
|
+
function cutByUtf8Bytes(input, headBytes, tailBytes) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const totalBytes = encoder.encode(input).length;
|
|
30
|
+
if (totalBytes <= headBytes + tailBytes) {
|
|
31
|
+
return { head: input, tail: '', keptHeadBytes: totalBytes, keptTailBytes: 0, truncated: false };
|
|
32
|
+
}
|
|
33
|
+
const chars = Array.from(input);
|
|
34
|
+
const atByte = (limit, fromEnd) => {
|
|
35
|
+
let lo = 0;
|
|
36
|
+
let hi = chars.length;
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
while (lo < hi) {
|
|
39
|
+
const mid = (lo + hi + 1) >> 1;
|
|
40
|
+
const slice = fromEnd ? chars.slice(chars.length - mid) : chars.slice(0, mid);
|
|
41
|
+
const size = encoder.encode(slice.join('')).length;
|
|
42
|
+
if (size <= limit) {
|
|
43
|
+
lo = mid;
|
|
44
|
+
bytes = size;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
hi = mid - 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { count: lo, bytes };
|
|
51
|
+
};
|
|
52
|
+
const headPart = atByte(headBytes, false);
|
|
53
|
+
const tailPart = atByte(tailBytes, true);
|
|
54
|
+
return {
|
|
55
|
+
head: safeSlice(input, 0, headPart.count),
|
|
56
|
+
tail: safeSlice(input, chars.length - tailPart.count),
|
|
57
|
+
keptHeadBytes: headPart.bytes,
|
|
58
|
+
keptTailBytes: tailPart.bytes,
|
|
59
|
+
truncated: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 按「字符数 + UTF-8 字节数」双口径截断输出。
|
|
64
|
+
* 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
|
|
65
|
+
* 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
|
|
66
|
+
*/
|
|
67
|
+
export function truncateOutput(input, maxChars, maxBytes) {
|
|
68
|
+
// 双重口径限长:字符数守住 Token 预算,字节数守住真实传输量。
|
|
69
|
+
// 中文场景下两者会分别触发,因此两条路径的提示文案必须区分,避免用户按错误的配置项去调参。
|
|
70
|
+
if (input.length > maxChars) {
|
|
71
|
+
// 保留前 1/3 字符和尾部 1/2 字符,保留开头上下文和错误尾部
|
|
72
|
+
const headLen = Math.floor(maxChars * 0.35);
|
|
73
|
+
const tailLen = Math.floor(maxChars * 0.55);
|
|
74
|
+
const head = safeSlice(input, 0, headLen);
|
|
75
|
+
const tail = safeSlice(input, input.length - tailLen);
|
|
76
|
+
return {
|
|
77
|
+
text: `${head}\n\n... [⚠️ 提示:内容过长已由 DSH GitHub 插件截断中间部分,当前截取前 ${Math.round(headLen / 1024)}KB 与后 ${Math.round(tailLen / 1024)}KB] ...\n\n${tail}`,
|
|
78
|
+
truncated: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const byteCut = cutByUtf8Bytes(input, Math.floor(maxBytes * 0.35), Math.floor(maxBytes * 0.55));
|
|
82
|
+
if (byteCut.truncated) {
|
|
83
|
+
// 这里必须用「实际保留的字节数」而非 maxChars 推导值:字节口径触发时 maxChars 往往远大于
|
|
84
|
+
// 字节上限,早先版本照抄字符分支的算法,会报出与实际严重不符的「已保留 100KB」(实际仅数百字节)。
|
|
85
|
+
return {
|
|
86
|
+
text: `${byteCut.head}\n\n... [⚠️ 提示:内容超出 UTF-8 字节上限 (${maxBytes} bytes) 已截断中间部分,实际保留前 ${byteCut.keptHeadBytes} 字节与后 ${byteCut.keptTailBytes} 字节;如需放宽请调大配置项 maxOutputBytes] ...\n\n${byteCut.tail}`,
|
|
87
|
+
truncated: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { text: input, truncated: false };
|
|
91
|
+
}
|
|
4
92
|
export class GhExecutor {
|
|
5
93
|
ghPath;
|
|
6
94
|
defaultTimeoutMs;
|
|
7
95
|
maxOutputChars;
|
|
96
|
+
/** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
|
|
97
|
+
maxOutputBytes;
|
|
98
|
+
/** 认证状态缓存。`gh auth status` 实测 5.4 秒,而它位于全局驾驶舱关键路径的最前面 */
|
|
99
|
+
authCache = null;
|
|
8
100
|
constructor(config = {}) {
|
|
9
101
|
this.ghPath = config.ghPath || 'gh';
|
|
10
102
|
this.defaultTimeoutMs = config.defaultTimeoutMs || 30_000;
|
|
11
103
|
this.maxOutputChars = config.maxOutputChars || 24_000;
|
|
104
|
+
this.maxOutputBytes = config.maxOutputBytes || this.maxOutputChars * 4;
|
|
12
105
|
}
|
|
13
106
|
updateConfig(config = {}) {
|
|
14
107
|
if (config.ghPath)
|
|
15
108
|
this.ghPath = config.ghPath;
|
|
16
109
|
if (config.defaultTimeoutMs)
|
|
17
110
|
this.defaultTimeoutMs = config.defaultTimeoutMs;
|
|
18
|
-
if (config.maxOutputChars)
|
|
111
|
+
if (config.maxOutputChars) {
|
|
19
112
|
this.maxOutputChars = config.maxOutputChars;
|
|
113
|
+
if (!config.maxOutputBytes)
|
|
114
|
+
this.maxOutputBytes = config.maxOutputChars * 4;
|
|
115
|
+
}
|
|
116
|
+
if (config.maxOutputBytes)
|
|
117
|
+
this.maxOutputBytes = config.maxOutputBytes;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 智能解析最可靠的工作区物理路径:
|
|
121
|
+
* 1. 优先使用显式指定的有效 cwd(若包含 .git 目录)
|
|
122
|
+
* 2. 读取当前进程/会话的 DSH_SESSION_ID 并匹配 ~/.dsh/storages/workspace.json
|
|
123
|
+
* 3. 匹配 ~/.dsh/storages/workspace.json 中最近活跃的工作区
|
|
124
|
+
* 4. 回退到 process.cwd()
|
|
125
|
+
*/
|
|
126
|
+
resolveWorkspaceCwd(explicitCwd) {
|
|
127
|
+
// 显式路径优先,但只认真正含 .git 的目录:调用方传进来的可能是会话根目录而非仓库目录,
|
|
128
|
+
// 此时继续往下走按会话/最近活跃反查,比盲信传入值更准。
|
|
129
|
+
if (explicitCwd && hasGitDir(explicitCwd)) {
|
|
130
|
+
return explicitCwd;
|
|
131
|
+
}
|
|
132
|
+
const sessionId = process.env.DSH_SESSION_ID;
|
|
133
|
+
if (sessionId) {
|
|
134
|
+
const bySession = findWorkspacePathBySession(sessionId);
|
|
135
|
+
if (bySession)
|
|
136
|
+
return bySession;
|
|
137
|
+
}
|
|
138
|
+
const recent = findMostRecentWorkspacePath();
|
|
139
|
+
if (recent)
|
|
140
|
+
return recent;
|
|
141
|
+
return explicitCwd || process.cwd();
|
|
20
142
|
}
|
|
21
143
|
/**
|
|
22
144
|
* 安全执行 gh 命令
|
|
23
145
|
* 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
|
|
24
146
|
*/
|
|
25
147
|
async run(args, options = {}) {
|
|
26
|
-
const cwd = options.cwd
|
|
148
|
+
const cwd = this.resolveWorkspaceCwd(options.cwd);
|
|
27
149
|
const timeout = options.timeoutMs || this.defaultTimeoutMs;
|
|
28
150
|
try {
|
|
29
151
|
const { stdout, stderr } = await execFileAsync(this.ghPath, args, {
|
|
@@ -41,16 +163,9 @@ export class GhExecutor {
|
|
|
41
163
|
if (!rawOutput && stderr) {
|
|
42
164
|
rawOutput = stderr.trim();
|
|
43
165
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const headLen = Math.floor(this.maxOutputChars * 0.35);
|
|
48
|
-
const tailLen = Math.floor(this.maxOutputChars * 0.55);
|
|
49
|
-
const head = rawOutput.slice(0, headLen);
|
|
50
|
-
const tail = rawOutput.slice(-tailLen);
|
|
51
|
-
rawOutput = `${head}\n\n... [⚠️ 提示:内容过长已由 DSH GitHub 插件截断中间部分,当前截取前 ${Math.round(headLen / 1024)}KB 与后 ${Math.round(tailLen / 1024)}KB] ...\n\n${tail}`;
|
|
52
|
-
truncated = true;
|
|
53
|
-
}
|
|
166
|
+
const outcome = truncateOutput(rawOutput, this.maxOutputChars, this.maxOutputBytes);
|
|
167
|
+
rawOutput = outcome.text;
|
|
168
|
+
const truncated = outcome.truncated;
|
|
54
169
|
// 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
|
|
55
170
|
if (!options.rawText && !truncated && (args.includes('--json') || args.includes('api'))) {
|
|
56
171
|
try {
|
|
@@ -89,8 +204,26 @@ export class GhExecutor {
|
|
|
89
204
|
}
|
|
90
205
|
/**
|
|
91
206
|
* 检查宿主机当前 GitHub CLI 的认证状态与账号信息
|
|
207
|
+
*
|
|
208
|
+
* @param cwd 解析工作区用的路径
|
|
209
|
+
* @param bypassCache 跳过进程内缓存。`/gh status` 这类人类显式发起的诊断必须看到实时结果,
|
|
210
|
+
* 而 Web 面板每次挂载都会来问一次,重复执行实测要 5.4 秒,属于纯浪费。
|
|
92
211
|
*/
|
|
93
|
-
async checkAuth(cwd) {
|
|
212
|
+
async checkAuth(cwd, bypassCache = false) {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
if (!bypassCache && this.authCache && now - this.authCache.at < AUTH_CACHE_TTL_MS) {
|
|
215
|
+
return this.authCache.value;
|
|
216
|
+
}
|
|
217
|
+
const value = await this.probeAuth(cwd);
|
|
218
|
+
// 只缓存确定的结果:出错时(未安装/未登录/网络异常)不缓存,
|
|
219
|
+
// 否则用户 `gh auth login` 之后还要再等满一个 TTL 才能看到面板恢复。
|
|
220
|
+
if (value.loggedIn) {
|
|
221
|
+
this.authCache = { at: now, value };
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
/** 实际执行 `gh auth status` 并解析结果,不含缓存逻辑 */
|
|
226
|
+
async probeAuth(cwd) {
|
|
94
227
|
const res = await this.run(['auth', 'status'], { cwd, timeoutMs: 10_000, rawText: true });
|
|
95
228
|
const raw = (res.rawOutput || '') + '\n' + (res.error || '');
|
|
96
229
|
const loggedIn = raw.includes('Logged in to') || raw.includes('✓');
|
|
@@ -139,12 +272,48 @@ export class GhExecutor {
|
|
|
139
272
|
raw,
|
|
140
273
|
};
|
|
141
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* 仅从本地 .git/config 解析仓库身份,绝不发起网络请求。
|
|
277
|
+
*
|
|
278
|
+
* 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
|
|
279
|
+
* `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
|
|
280
|
+
* 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
|
|
281
|
+
* 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
|
|
282
|
+
* 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
|
|
283
|
+
*/
|
|
284
|
+
getRepoIdentityFromLocal(cwd) {
|
|
285
|
+
const targetCwd = cwd || this.resolveWorkspaceCwd();
|
|
286
|
+
try {
|
|
287
|
+
const gitConfigPath = path.join(targetCwd, '.git', 'config');
|
|
288
|
+
if (!fs.existsSync(gitConfigPath))
|
|
289
|
+
return null;
|
|
290
|
+
const configText = fs.readFileSync(gitConfigPath, 'utf8');
|
|
291
|
+
const match = configText.match(/github\.com[:/]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\r|\n|$)/i);
|
|
292
|
+
if (!match)
|
|
293
|
+
return null;
|
|
294
|
+
const owner = match[1];
|
|
295
|
+
const repoName = match[2];
|
|
296
|
+
const nameWithOwner = `${owner}/${repoName}`;
|
|
297
|
+
return {
|
|
298
|
+
nameWithOwner,
|
|
299
|
+
name: repoName,
|
|
300
|
+
owner,
|
|
301
|
+
defaultBranch: 'main',
|
|
302
|
+
description: '',
|
|
303
|
+
isPrivate: false,
|
|
304
|
+
url: `https://github.com/${nameWithOwner}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
142
311
|
/**
|
|
143
312
|
* 获取当前目录(工作区)关联的远程仓库元数据
|
|
144
313
|
* 采用 gh repo view + 本地 git remote 双保险机制,彻底杜绝已配置远程却误判未关联的问题
|
|
145
314
|
*/
|
|
146
315
|
async getRepoMetadata(cwd) {
|
|
147
|
-
const targetCwd =
|
|
316
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
148
317
|
// 1. 优先尝试由 gh 直接根据当前上下文识别
|
|
149
318
|
const res = await this.run(['repo', 'view', '--json', 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url'], { cwd: targetCwd, timeoutMs: 10_000 });
|
|
150
319
|
if (res.ok && res.data && res.data.nameWithOwner) {
|
|
@@ -159,20 +328,17 @@ export class GhExecutor {
|
|
|
159
328
|
url: d.url || `https://github.com/${d.nameWithOwner}`,
|
|
160
329
|
};
|
|
161
330
|
}
|
|
162
|
-
// 2.
|
|
331
|
+
// 2. 原生文件解析:直接读取本地 .git/config 文件(0 子进程开销、免疫沙箱 EPERM)
|
|
163
332
|
try {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (remoteOut) {
|
|
169
|
-
// 匹配 github.com 格式:git@github.com:owner/repo.git 或 https://github.com/owner/repo.git
|
|
170
|
-
const match = remoteOut.match(/github\.com[:/]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\()/i);
|
|
333
|
+
const gitConfigPath = path.join(targetCwd, '.git', 'config');
|
|
334
|
+
if (fs.existsSync(gitConfigPath)) {
|
|
335
|
+
const configText = fs.readFileSync(gitConfigPath, 'utf8');
|
|
336
|
+
const match = configText.match(/github\.com[:/]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\r|\n|$)/i);
|
|
171
337
|
if (match) {
|
|
172
338
|
const owner = match[1];
|
|
173
339
|
const repoName = match[2];
|
|
174
340
|
const nameWithOwner = `${owner}/${repoName}`;
|
|
175
|
-
//
|
|
341
|
+
// 使用精确的 owner/repo 向 gh 获取元数据(显式传参,不依赖无上下文的探测)
|
|
176
342
|
const directRes = await this.run(['repo', 'view', nameWithOwner, '--json', 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url'], { cwd: targetCwd, timeoutMs: 10_000 });
|
|
177
343
|
if (directRes.ok && directRes.data && directRes.data.nameWithOwner) {
|
|
178
344
|
const d = directRes.data;
|
|
@@ -199,9 +365,7 @@ export class GhExecutor {
|
|
|
199
365
|
}
|
|
200
366
|
}
|
|
201
367
|
}
|
|
202
|
-
catch {
|
|
203
|
-
// 本地无 git 或无 remote 配置
|
|
204
|
-
}
|
|
368
|
+
catch { }
|
|
205
369
|
return null;
|
|
206
370
|
}
|
|
207
371
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.github` 服务的 TypeScript 声明合并。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个文件:README 教生态插件这样复用本插件的能力——
|
|
5
|
+
* export const inject = ['github']
|
|
6
|
+
* export function apply(ctx) { await ctx.github.checkAuth() }
|
|
7
|
+
* 但在此之前项目里没有任何 `declare module` 声明合并,消费方拿到的 `ctx.github`
|
|
8
|
+
* 类型是 unknown,照文档写会直接编译不过,也没有任何自动补全。
|
|
9
|
+
* 官方每个服务包(如 @deepseek-ai/dsh-workspace、dsh-commands)都做这件事,这里补齐。
|
|
10
|
+
*
|
|
11
|
+
* 消费方用法:import type {} from 'dsh-github-flow/host' —— 或直接 import 本插件导出的任一名,
|
|
12
|
+
* 只要类型被加载,`ctx.github` 就有完整类型。
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import type { GhExecutor } from './executor.js';
|
|
16
|
+
declare module '@deepseek-ai/cordis' {
|
|
17
|
+
interface Context {
|
|
18
|
+
/** 由 dsh-github-flow 提供的 GitHub CLI 执行引擎 */
|
|
19
|
+
github: GhExecutor;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 供消费方做类型引用的空导出。
|
|
24
|
+
* 目的是让 `import type {} from 'dsh-github-flow'` 能顺带加载上面的声明合并。
|
|
25
|
+
*/
|
|
26
|
+
export type { Context };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export declare function apply(ctx: any): void;
|
|
4
|
-
export * from './types.js';
|
|
1
|
+
export * from './main.js';
|
|
2
|
+
export { apply as default } from './main.js';
|
package/lib/index.js
CHANGED
|
@@ -1,30 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { createIssueTool } from './tools/issue.js';
|
|
4
|
-
import { createRunTool } from './tools/run.js';
|
|
5
|
-
import { createRepoTool } from './tools/repo.js';
|
|
6
|
-
import { createApiTool } from './tools/api.js';
|
|
7
|
-
import { registerGhCommand } from './commands/gh.js';
|
|
8
|
-
import { registerApiRoutes } from './api/routes.js';
|
|
9
|
-
export const name = 'dsh-github-flow';
|
|
10
|
-
export const inject = ['tools', 'commands', 'webServer', 'workspaceRegistry'];
|
|
11
|
-
export function apply(ctx) {
|
|
12
|
-
const executor = new GhExecutor();
|
|
13
|
-
// 1. 注册人类 Slash 命令 (/gh status, /gh help)
|
|
14
|
-
if (ctx.commands) {
|
|
15
|
-
registerGhCommand(ctx.commands, executor);
|
|
16
|
-
}
|
|
17
|
-
// 2. 注册 Agent 模型工具集(5 大核心领域工具)
|
|
18
|
-
if (ctx.tools) {
|
|
19
|
-
ctx.tools.register(createPrTool(executor));
|
|
20
|
-
ctx.tools.register(createIssueTool(executor));
|
|
21
|
-
ctx.tools.register(createRunTool(executor));
|
|
22
|
-
ctx.tools.register(createRepoTool(executor));
|
|
23
|
-
ctx.tools.register(createApiTool(executor));
|
|
24
|
-
}
|
|
25
|
-
// 3. 注册面向 Client 端的 WebServer HTTP 路由
|
|
26
|
-
if (ctx.webServer) {
|
|
27
|
-
registerApiRoutes(ctx.webServer, executor, ctx.workspaceRegistry);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export * from './types.js';
|
|
1
|
+
export * from './main.js';
|
|
2
|
+
export { apply as default } from './main.js';
|