zen-gitsync 2.17.8 → 2.17.10
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.md +6 -0
- package/package.json +2 -1
- package/src/cli/ai/agent.js +210 -58
- package/src/cli/ai/context.js +58 -0
- package/src/cli/ai/context.test.js +117 -0
- package/src/cli/ai/extensions.js +131 -0
- package/src/cli/ai/extensions.test.js +158 -0
- package/src/cli/ai/mcp.js +498 -0
- package/src/cli/ai/mcp.test.js +275 -0
- package/src/cli/ai/runtime.test.js +32 -0
- package/src/cli/ai/skills.js +207 -0
- package/src/cli/ai/skills.test.js +126 -0
- package/src/cli/ai/termui.js +1 -0
- package/src/cli/ai/tools.js +94 -3
- package/src/cli/ai/tools.test.js +82 -0
- package/src/cli/ai/transport.js +18 -4
- package/src/cli/ai/turn.js +21 -5
- package/src/config.js +27 -3
- package/src/paths.js +11 -0
- package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
- package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
- package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
- package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
- package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
- package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
- package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
- package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
- package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
- package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
- package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
- package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
- package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
- package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
- package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
- package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
- package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
- package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
- package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
- package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
- package/src/ui/public/assets/index-CW3xs-af.css +1 -0
- package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
- package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
- package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
- package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
- package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
- package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
- package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
- package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
- package/src/ui/public/index.html +17 -17
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/config.js +16 -3
- package/src/ui/server/routes/exec.js +19 -11
- package/src/ui/server/routes/fileOpen.js +142 -2
- package/src/ui/server/routes/remoteRepos.js +604 -0
- package/src/ui/server/routes/remoteRepos.test.js +386 -0
- package/src/ui/server/routes/workbench/agentChat.js +86 -180
- package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
- package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
- package/src/ui/server/routes/workbench/jobStore.js +17 -3
- package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
- package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
- package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
- package/src/ui/server/routes/workbench/projectTool.js +198 -0
- package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
- package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
- package/src/ui/server/routes/workbench/taskRunner.js +269 -12
- package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
- package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
- package/src/utils/shellPath.js +253 -0
- package/src/utils/shellPath.test.js +221 -0
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
- package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
- package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
- package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
- package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
- package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
- package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
- package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
- package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
- package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
- package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
- package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
- package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
- package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
- package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
- package/src/ui/public/assets/index-cApp6w0b.css +0 -1
- package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
- package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
- package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
- package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 远程托管平台仓库列表(GitHub / Gitee 两个 Tab 的数据源)。
|
|
16
|
+
//
|
|
17
|
+
// 为什么是"调 CLI"而不是"直连 API":
|
|
18
|
+
// 两个平台都有自己的官方 CLI,凭据由 CLI 自己保管(`gh auth login` /
|
|
19
|
+
// `gitee auth login`)。走 CLI 意味着 ZenGitSync 不需要知道、也不需要转发
|
|
20
|
+
// 任何 token —— 我们不碰用户的令牌,只读 CLI 的输出。代价是解析 CLI 的
|
|
21
|
+
// 文本/JSON 输出,下面每个解析点都注明了依据。
|
|
22
|
+
//
|
|
23
|
+
// 为什么两个平台的"是否已登录"判定方式不同(踩过的坑):
|
|
24
|
+
// · gh : `gh auth status` 未登录时**退出码 1**,看 exit code 即可。
|
|
25
|
+
// · gitee : `gitee auth status` 未登录时**退出码仍然是 0**(实测 v0.3.1),
|
|
26
|
+
// 只能解析 `--json` 输出的 status 字段。用退出码判会永远认为"已登录",
|
|
27
|
+
// 然后 repo list 报 "Not logged in" —— 所以 gitee 走 JSON 解析。
|
|
28
|
+
//
|
|
29
|
+
// 为什么安装完能立刻检测到(不必重启服务):
|
|
30
|
+
// 装包管理器改的是**注册表里的 PATH**(winget / MSI 写 Machine,npm -g 写 User),
|
|
31
|
+
// 而已经跑起来的 node 进程持有的还是老 PATH,`where.exe` 找不到新装的命令。
|
|
32
|
+
// 所以这里除了 where.exe,还会去读注册表 **Machine + User 两边**的 Path 自己拼
|
|
33
|
+
// 候选路径(读取实现统一在 utils/shellPath.js,和"给子进程补 PATH"共用一份,
|
|
34
|
+
// 免得两处口径分叉 —— 这里曾经只读 HKCU,漏掉 winget 写的 Machine 那半),
|
|
35
|
+
// 再兜几个装包管理器的默认落点。否则"一键安装 → 自动刷新列表"会在刷新时
|
|
36
|
+
// 永远停在"未安装"。
|
|
37
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
38
|
+
import fs from 'node:fs/promises';
|
|
39
|
+
import os from 'node:os';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
|
|
42
|
+
import { getToolInstallers, launchCommandInTerminal, parseVersionOutput, publicInstallerInfo } from './fileOpen.js';
|
|
43
|
+
import { readRegistryPathDirs } from '../../../utils/shellPath.js';
|
|
44
|
+
import logger from '../utils/logger.js';
|
|
45
|
+
|
|
46
|
+
/** 单条 CLI 命令的超时。gh repo list / gitee repo list 都要联网,
|
|
47
|
+
* 国内直连 GitHub 有时要十几秒;给足余量但不能无限等(前端在轮询)。 */
|
|
48
|
+
const CLI_TIMEOUT_MS = 25000;
|
|
49
|
+
|
|
50
|
+
/** gitee 的分页上限:`--limit` 单页最大 100,翻到第 3 页还没完就停,
|
|
51
|
+
* 避免仓库上千的用户把一次请求拖成十几秒。 */
|
|
52
|
+
const GITEE_MAX_PAGES = 3;
|
|
53
|
+
const GITEE_PAGE_SIZE = 100;
|
|
54
|
+
|
|
55
|
+
/** gh repo list 一次拉够。gh 自己会翻页。 */
|
|
56
|
+
const GH_LIMIT = 200;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* provider 白名单。前端只能传这个 key,命令与参数全部由服务端决定 ——
|
|
60
|
+
* 与 /api/install-tool 同一条原则:命令不进请求体。
|
|
61
|
+
*/
|
|
62
|
+
const PROVIDERS = Object.freeze({
|
|
63
|
+
github: {
|
|
64
|
+
label: 'GitHub',
|
|
65
|
+
cli: 'gh',
|
|
66
|
+
/** 安装白名单里的 key(见 fileOpen.js getToolInstallers)。
|
|
67
|
+
* 注意它和 provider 名不同:provider 叫 github,工具 id 叫 gh。 */
|
|
68
|
+
toolId: 'gh',
|
|
69
|
+
docsUrl: 'https://cli.github.com/',
|
|
70
|
+
loginCommand: 'gh auth login',
|
|
71
|
+
/** 装包管理器(winget / brew / 系统包管理器)可能在 PATH 之外落地的位置 */
|
|
72
|
+
windowsFallbacks: [
|
|
73
|
+
'C:\\Program Files\\GitHub CLI\\gh.exe',
|
|
74
|
+
'C:\\Program Files (x86)\\GitHub CLI\\gh.exe',
|
|
75
|
+
],
|
|
76
|
+
posixFallbacks: ['/usr/local/bin/gh', '/opt/homebrew/bin/gh', '/usr/bin/gh'],
|
|
77
|
+
},
|
|
78
|
+
gitee: {
|
|
79
|
+
label: 'Gitee',
|
|
80
|
+
cli: 'gitee',
|
|
81
|
+
toolId: 'gitee',
|
|
82
|
+
docsUrl: 'https://gitee.com/oschina/gitee-cli',
|
|
83
|
+
loginCommand: 'gitee auth login',
|
|
84
|
+
/** npm 全局安装 → 落点是 npm 的 global bin。Windows 上是 .cmd 垫片。 */
|
|
85
|
+
windowsFallbacks: [],
|
|
86
|
+
posixFallbacks: [],
|
|
87
|
+
npmShimNames: ['gitee.cmd', 'gitee'],
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Windows 上同一个命令可能同时存在多个"文件":
|
|
93
|
+
* gh.exe(winget/scoop 装的真身) / gh.cmd(npm 垫片) / gh(bash 脚本)
|
|
94
|
+
* npm 在全局 bin 里会同时生成 `gitee`(无扩展名,POSIX sh 脚本)和 `gitee.cmd`,
|
|
95
|
+
* 而 **where.exe 会把无扩展名的那个排在前面**。直接取第一条会在 Windows 上
|
|
96
|
+
* 拿到 bash 脚本,spawn 报 ENOENT,表现为"检测到已安装但版本号是空的"——
|
|
97
|
+
* 一个不会报错、只会静默降级的坑(实测踩到)。所以按扩展名给优先级,
|
|
98
|
+
* 并且最终还要"试跑一次"才算数(见 resolveCli)。
|
|
99
|
+
*/
|
|
100
|
+
const WIN_EXT_PRIORITY = ['.exe', '.cmd', '.bat'];
|
|
101
|
+
|
|
102
|
+
function winCandidateScore(filePath) {
|
|
103
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
104
|
+
const index = WIN_EXT_PRIORITY.indexOf(ext);
|
|
105
|
+
// 无扩展名(或其它扩展名)排到最后:它在 Windows 上不是可执行文件
|
|
106
|
+
return index >= 0 ? index : WIN_EXT_PRIORITY.length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 可执行文件定位 ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** npm 的全局 bin 目录。`npm prefix -g` 是权威来源,失败时退回 %APPDATA%\npm。 */
|
|
112
|
+
function npmGlobalBin() {
|
|
113
|
+
const candidates = [];
|
|
114
|
+
const prefix = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['prefix', '-g'], {
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
}).stdout?.trim();
|
|
118
|
+
if (prefix) candidates.push(prefix);
|
|
119
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
120
|
+
candidates.push(path.join(process.env.APPDATA, 'npm'));
|
|
121
|
+
}
|
|
122
|
+
return candidates;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function isFile(p) {
|
|
126
|
+
if (!p) return false;
|
|
127
|
+
try {
|
|
128
|
+
const stat = await fs.stat(p);
|
|
129
|
+
return stat.isFile();
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 列出某个 CLI 的**全部**候选可执行文件,已按"能跑起来的可能性"排序。
|
|
137
|
+
* 顺序:
|
|
138
|
+
* 1. where.exe / which 的全部结果(Windows 上按扩展名优先级重排)
|
|
139
|
+
* 2. 注册表用户级 Path 里逐目录拼文件名 —— 装完没重启服务时的关键一步
|
|
140
|
+
* 3. npm 全局 bin(仅声明了 npmShimNames 的 provider)
|
|
141
|
+
* 4. 平台默认落点(Program Files / homebrew / usr-local)
|
|
142
|
+
* 返回空数组表示"没找到"(调用方按"未安装"处理,不是错误)。
|
|
143
|
+
*/
|
|
144
|
+
export async function listCliExecutables(provider) {
|
|
145
|
+
const config = PROVIDERS[provider];
|
|
146
|
+
if (!config) return [];
|
|
147
|
+
const isWin = process.platform === 'win32';
|
|
148
|
+
const exeNames = isWin ? [`${config.cli}.exe`, `${config.cli}.cmd`, `${config.cli}.bat`, config.cli] : [config.cli];
|
|
149
|
+
|
|
150
|
+
const found = [];
|
|
151
|
+
const push = async (candidate) => {
|
|
152
|
+
if (!candidate || found.includes(candidate)) return;
|
|
153
|
+
if (await isFile(candidate)) found.push(candidate);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// 1. where.exe / which
|
|
157
|
+
const checker = isWin ? 'where.exe' : 'which';
|
|
158
|
+
const where = spawnSync(checker, [config.cli], { encoding: 'utf8', windowsHide: true });
|
|
159
|
+
if (where.status === 0 && where.stdout) {
|
|
160
|
+
const lines = where.stdout
|
|
161
|
+
.split(/\r?\n/)
|
|
162
|
+
.map((s) => s.trim())
|
|
163
|
+
.filter((l) => l && !/^[A-Z_]+=/i.test(l)); // 偶尔混入 "PATH=..." 这类行
|
|
164
|
+
// Windows 上必须重排:where.exe 会把无扩展名的 POSIX 脚本排在 .cmd 前面
|
|
165
|
+
if (isWin) lines.sort((a, b) => winCandidateScore(a) - winCandidateScore(b));
|
|
166
|
+
for (const line of lines) await push(line);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. 注册表 PATH 里的目录(Machine + User 两边都要)
|
|
170
|
+
// ⚠️ 只读 HKCU 是不够的(2026-09-22 修正):winget / MSI 默认写的是 **Machine**,
|
|
171
|
+
// 而 npm -g 写 User —— 漏掉 Machine 就会让"gh 装了却检测不到"。
|
|
172
|
+
// force: true 是刻意的:探测是"用户明确要看最新状态"的路径,宁可多花一次 reg.exe
|
|
173
|
+
// 也不要吃 15s 缓存 —— 否则"一键安装 → 立刻刷新"会看到装之前的结论。
|
|
174
|
+
for (const dir of await readRegistryPathDirs({ force: true })) {
|
|
175
|
+
for (const name of exeNames) await push(path.join(dir, name));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. npm 全局 bin(gitee 走 npm 装,垫片名由 provider 声明)
|
|
179
|
+
if (config.npmShimNames) {
|
|
180
|
+
for (const bin of npmGlobalBin()) {
|
|
181
|
+
for (const name of config.npmShimNames) await push(path.join(bin, name));
|
|
182
|
+
// POSIX 上 npm 的垫片在 prefix/bin 下
|
|
183
|
+
if (!isWin) await push(path.join(bin, 'bin', config.cli));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. 平台默认落点
|
|
188
|
+
const fallbacks = isWin
|
|
189
|
+
? config.windowsFallbacks
|
|
190
|
+
: [...config.posixFallbacks, path.join(os.homedir(), '.local', 'bin', config.cli)];
|
|
191
|
+
for (const candidate of fallbacks) await push(candidate);
|
|
192
|
+
|
|
193
|
+
return found;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 解析出真正可用的那一个 CLI。
|
|
198
|
+
*
|
|
199
|
+
* 光"文件存在"不够:候选里可能有 Windows 上根本执行不了的 POSIX 垫片。
|
|
200
|
+
* 所以逐个试跑 `--version`,第一个能跑通的才算数,顺手把版本号收下
|
|
201
|
+
* (避免为了拿版本再跑第二遍)。全都不行返回 null。
|
|
202
|
+
*/
|
|
203
|
+
export async function resolveCli(provider) {
|
|
204
|
+
for (const executable of await listCliExecutables(provider)) {
|
|
205
|
+
const probe = await runCli(executable, ['--version'], { timeoutMs: 15000 });
|
|
206
|
+
if (probe.code === 0) {
|
|
207
|
+
return {
|
|
208
|
+
executable,
|
|
209
|
+
version: parseVersionOutput(`${probe.stdout}\n${probe.stderr}`),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// ENOENT / EACCES = 这个候选不是可执行文件,换下一个;
|
|
213
|
+
// 其它失败(比如 CLI 自己报错)说明命令是跑得起来的,就用它
|
|
214
|
+
if (!/ENOENT|EACCES|EPERM/i.test(probe.stderr)) {
|
|
215
|
+
return { executable, version: parseVersionOutput(`${probe.stdout}\n${probe.stderr}`) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 便捷包装:只要第一个候选路径(测试与调试用)。生产路径请用 resolveCli。 */
|
|
222
|
+
export async function findCliExecutable(provider) {
|
|
223
|
+
const [first] = await listCliExecutables(provider);
|
|
224
|
+
return first || null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── CLI 调用 ────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 跑一条 CLI 命令并把 stdout 收全。
|
|
231
|
+
*
|
|
232
|
+
* 全程非交互:GH_PROMPT_DISABLED / GIT_TERMINAL_PROMPT=0 保证需要凭据时
|
|
233
|
+
* **快速失败而不是挂住**(挂住会连带前端轮询一起卡死,与 directoryFetch.js
|
|
234
|
+
* 里 fetch 的处理是同一个考虑)。
|
|
235
|
+
* NO_COLOR + GITEE_NO_UPDATE_NOTIFIER:输出要拿来解析,不能混进 ANSI 色码,
|
|
236
|
+
* 也不要让每次调用都去查一次更新。
|
|
237
|
+
*
|
|
238
|
+
* Windows 上 npm 装出来的是 .cmd 垫片,不能直接 spawn,必须过一层 shell
|
|
239
|
+
* (这里的 args 全部是服务端硬编码常量,不含任何用户输入)。
|
|
240
|
+
*/
|
|
241
|
+
export function runCli(executable, args, { timeoutMs = CLI_TIMEOUT_MS, env = {} } = {}) {
|
|
242
|
+
return new Promise((resolve) => {
|
|
243
|
+
const useShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable);
|
|
244
|
+
const child = spawn(executable, args, {
|
|
245
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
246
|
+
windowsHide: true,
|
|
247
|
+
shell: useShell,
|
|
248
|
+
env: {
|
|
249
|
+
...process.env,
|
|
250
|
+
NO_COLOR: '1',
|
|
251
|
+
GH_PROMPT_DISABLED: '1',
|
|
252
|
+
GH_NO_UPDATE_NOTIFIER: '1',
|
|
253
|
+
GITEE_NO_UPDATE_NOTIFIER: '1',
|
|
254
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
255
|
+
...env,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
let stdout = '';
|
|
260
|
+
let stderr = '';
|
|
261
|
+
let done = false;
|
|
262
|
+
const finish = (code, timedOut) => {
|
|
263
|
+
if (done) return;
|
|
264
|
+
done = true;
|
|
265
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
266
|
+
resolve({ code, stdout, stderr, timedOut: !!timedOut });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
270
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
271
|
+
child.on('error', (error) => {
|
|
272
|
+
stderr += `\n${error.message}`;
|
|
273
|
+
finish(-1, false);
|
|
274
|
+
});
|
|
275
|
+
child.on('exit', (code) => finish(code, false));
|
|
276
|
+
setTimeout(() => finish(-1, true), timeoutMs);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 从 CLI 输出里挑一句能给用户看的话(gitee 把错误打在 stdout,gh 打在 stderr) */
|
|
281
|
+
function firstMeaningfulLine(...texts) {
|
|
282
|
+
for (const text of texts) {
|
|
283
|
+
if (!text) continue;
|
|
284
|
+
for (const line of String(text).split(/\r?\n/).map((s) => s.trim())) {
|
|
285
|
+
if (line && !/^\s*$/.test(line)) return line;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return '';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 宽松 JSON 解析:容忍 CLI 在 JSON 前面打警告/更新提示行。
|
|
292
|
+
* 只定位第一个 `[` 或 `{` 再整体交给 JSON.parse —— 不做任何字符串清洗,
|
|
293
|
+
* 解析不出来就是 null,由调用方决定怎么降级。 */
|
|
294
|
+
export function parseJsonLoose(text) {
|
|
295
|
+
if (!text) return null;
|
|
296
|
+
const start = text.search(/[[{]/);
|
|
297
|
+
if (start < 0) return null;
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(text.slice(start));
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 把 CLI 输出解析成仓库数组。允许裸数组 / {repos} / {data} 三种外壳 ——
|
|
306
|
+
* gh 是裸数组,官方 gitee-cli 实测也是裸数组,多留两条分支是防它换壳。
|
|
307
|
+
*
|
|
308
|
+
* 注意:纯对象(如 `gitee auth status --json` 的 `{"host":…,"status":…}`)
|
|
309
|
+
* 在这里**返回 null**是正确的 —— 它不是仓库列表。要解析任意 JSON 请用
|
|
310
|
+
* parseJsonLoose,别拿这个函数去解 auth status(踩过:返回 null 导致
|
|
311
|
+
* 登录态解析静默退化成文本匹配,登录后拿不到用户名)。 */
|
|
312
|
+
export function parseRepoJson(text) {
|
|
313
|
+
const parsed = parseJsonLoose(text);
|
|
314
|
+
if (Array.isArray(parsed)) return parsed;
|
|
315
|
+
if (parsed && Array.isArray(parsed.repos)) return parsed.repos;
|
|
316
|
+
if (parsed && Array.isArray(parsed.data)) return parsed.data;
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 从 CLI 返回的 user 字段里取用户名。这个字段在不同实现里可能是字符串,
|
|
321
|
+
* 也可能是个对象(gitee 的 json tag 同时存在 user / login),两种都要认。 */
|
|
322
|
+
export function pickUserName(value) {
|
|
323
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
324
|
+
if (value && typeof value === 'object') {
|
|
325
|
+
for (const key of ['login', 'name', 'username', 'path']) {
|
|
326
|
+
if (typeof value[key] === 'string' && value[key].trim()) return value[key].trim();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── 各平台适配 ──────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 解释 `gh auth status` 的结果。
|
|
336
|
+
* gh 未登录时**退出码 1**,已登录时 0 —— 这里可以信退出码;
|
|
337
|
+
* 账号名从 "Logged in to github.com account <name> ..." 里抠。
|
|
338
|
+
*/
|
|
339
|
+
export function interpretGithubAuthStatus(exitCode, output) {
|
|
340
|
+
if (exitCode !== 0) return { authenticated: false, user: null };
|
|
341
|
+
const account = String(output || '').match(/account\s+([A-Za-z0-9-]+)/i);
|
|
342
|
+
return { authenticated: true, user: account ? account[1] : null };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 解释 `gitee auth status --json` 的结果。
|
|
347
|
+
*
|
|
348
|
+
* **不能看退出码**(实测 v0.3.1:未登录时退出码也是 0),只能解析 status 字段。
|
|
349
|
+
* status 只有 "logged in" / "not logged in" 两个取值:
|
|
350
|
+
* 先排除否定式、再要求确实出现过 logged in —— 这样将来多出别的状态词时,
|
|
351
|
+
* 宁可判成"未登录"(引导用户去登录)也不要谎报已登录(然后 repo list 报错)。
|
|
352
|
+
*
|
|
353
|
+
* 返回 null 表示 JSON 没解析出来,调用方应该退回文本判断。
|
|
354
|
+
*/
|
|
355
|
+
export function interpretGiteeAuthStatus(stdout) {
|
|
356
|
+
const parsed = parseJsonLoose(stdout);
|
|
357
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || typeof parsed.status !== 'string') {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
authenticated: !/not\s+logged\s+in/i.test(parsed.status) && /logged\s+in/i.test(parsed.status),
|
|
362
|
+
user: pickUserName(parsed.user) || pickUserName(parsed.login),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 检测 CLI 是否已登录 + 当前账号名。版本号由 resolveCli 试跑时带回,不重复跑。
|
|
368
|
+
*/
|
|
369
|
+
async function detectCli(provider, executable, version) {
|
|
370
|
+
if (provider === 'github') {
|
|
371
|
+
const authRes = await runCli(executable, ['auth', 'status'], { timeoutMs: 15000 });
|
|
372
|
+
const result = interpretGithubAuthStatus(authRes.code, `${authRes.stdout}\n${authRes.stderr}`);
|
|
373
|
+
return { installed: true, version, ...result };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const authRes = await runCli(executable, ['auth', 'status', '--json'], { timeoutMs: 15000 });
|
|
377
|
+
const fromJson = interpretGiteeAuthStatus(authRes.stdout);
|
|
378
|
+
if (fromJson) return { installed: true, version, ...fromJson };
|
|
379
|
+
|
|
380
|
+
// JSON 解析不出来(版本差异)时退回文本判断
|
|
381
|
+
const text = `${authRes.stdout}\n${authRes.stderr}`;
|
|
382
|
+
return {
|
|
383
|
+
installed: true,
|
|
384
|
+
version,
|
|
385
|
+
authenticated: !/not\s+logged\s+in/i.test(text) && /logged\s+in/i.test(text),
|
|
386
|
+
user: null,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** gh repo list 的 --json 字段 → 统一形状 */
|
|
391
|
+
export function normalizeGithubRepos(raw) {
|
|
392
|
+
return raw.map((r) => ({
|
|
393
|
+
name: r.name || '',
|
|
394
|
+
fullName: r.nameWithOwner || r.name || '',
|
|
395
|
+
description: r.description || '',
|
|
396
|
+
isPrivate: !!r.isPrivate,
|
|
397
|
+
isFork: !!r.isFork,
|
|
398
|
+
language: r.primaryLanguage?.name || null,
|
|
399
|
+
stars: typeof r.stargazerCount === 'number' ? r.stargazerCount : 0,
|
|
400
|
+
updatedAt: r.updatedAt || null,
|
|
401
|
+
url: r.url || '',
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** gitee repo list --json 的字段 → 统一形状 */
|
|
406
|
+
export function normalizeGiteeRepos(raw) {
|
|
407
|
+
return raw.map((r) => ({
|
|
408
|
+
name: r.name || r.path || '',
|
|
409
|
+
fullName: r.full_name || r.human_name || r.name || '',
|
|
410
|
+
description: r.description || '',
|
|
411
|
+
// gitee 同时给了 private 和 public 两个布尔,优先 private,缺失时用 public 反推
|
|
412
|
+
isPrivate: typeof r.private === 'boolean' ? r.private : r.public === false,
|
|
413
|
+
isFork: !!r.fork,
|
|
414
|
+
language: r.language || null,
|
|
415
|
+
stars: typeof r.stargazers_count === 'number' ? r.stargazers_count : 0,
|
|
416
|
+
updatedAt: r.updated_at || null,
|
|
417
|
+
url: r.html_url || (r.full_name ? `https://gitee.com/${r.full_name}` : ''),
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** 拉当前登录账号的仓库列表。失败时返回 { repos: [], error } 而不是抛 ——
|
|
422
|
+
* 前端要把这句话渲染在面板里,不是一个 500 弹窗。 */
|
|
423
|
+
async function listRepos(provider, executable) {
|
|
424
|
+
if (provider === 'github') {
|
|
425
|
+
const res = await runCli(executable, [
|
|
426
|
+
'repo', 'list',
|
|
427
|
+
'--limit', String(GH_LIMIT),
|
|
428
|
+
'--json', 'name,nameWithOwner,description,isPrivate,isFork,primaryLanguage,stargazerCount,updatedAt,url',
|
|
429
|
+
]);
|
|
430
|
+
if (res.timedOut) return { repos: [], error: 'gh repo list 超时(25 秒),请检查网络或代理设置' };
|
|
431
|
+
const parsed = parseRepoJson(res.stdout);
|
|
432
|
+
if (!parsed) {
|
|
433
|
+
return { repos: [], error: firstMeaningfulLine(res.stderr, res.stdout) || 'gh repo list 没有返回可解析的数据' };
|
|
434
|
+
}
|
|
435
|
+
return { repos: normalizeGithubRepos(parsed), error: null };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// gitee:官方 CLI 单页最多 100,手动翻页直到不足一页(上限 GITEE_MAX_PAGES)
|
|
439
|
+
const all = [];
|
|
440
|
+
let truncated = false;
|
|
441
|
+
for (let page = 1; page <= GITEE_MAX_PAGES; page += 1) {
|
|
442
|
+
const res = await runCli(executable, [
|
|
443
|
+
'repo', 'list',
|
|
444
|
+
'--limit', String(GITEE_PAGE_SIZE),
|
|
445
|
+
'--page', String(page),
|
|
446
|
+
'--json=full_name,human_name,name,path,description,private,public,fork,language,stargazers_count,updated_at,html_url',
|
|
447
|
+
'--no-tui',
|
|
448
|
+
]);
|
|
449
|
+
if (res.timedOut) {
|
|
450
|
+
return { repos: all, error: all.length ? null : 'gitee repo list 超时(25 秒),请检查网络设置' };
|
|
451
|
+
}
|
|
452
|
+
const parsed = parseRepoJson(res.stdout);
|
|
453
|
+
if (!parsed) {
|
|
454
|
+
const message = firstMeaningfulLine(res.stdout, res.stderr) || 'gitee repo list 没有返回可解析的数据';
|
|
455
|
+
return { repos: all, error: all.length ? null : message };
|
|
456
|
+
}
|
|
457
|
+
all.push(...normalizeGiteeRepos(parsed));
|
|
458
|
+
if (parsed.length < GITEE_PAGE_SIZE) break;
|
|
459
|
+
if (page === GITEE_MAX_PAGES) truncated = true;
|
|
460
|
+
}
|
|
461
|
+
return { repos: all, error: null, truncated };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── 路由 ────────────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 注册远程仓库相关路由:
|
|
468
|
+
* GET /api/remote-repos?provider=github|gitee 探测 + 拉列表
|
|
469
|
+
* POST /api/remote-repos/login 在新终端里跑登录命令
|
|
470
|
+
*
|
|
471
|
+
* *Impl 参数只为测试留的注入口(与 getToolInstallers 的 hasCommand、
|
|
472
|
+
* fetchLatestVersion 的 fetchImpl 同一套做法):生产调用不传,走真实实现;
|
|
473
|
+
* 单测传假实现就能把"未安装 / 未登录 / 已登录 / 拉取失败"四条分支全跑一遍,
|
|
474
|
+
* 不必真的装 gh、也不必联网。launchLoginImpl 尤其重要 —— 默认实现会**真的
|
|
475
|
+
* 开一个终端窗口**,单测里绝不能跑到它。
|
|
476
|
+
*/
|
|
477
|
+
export function registerRemoteReposRoutes({
|
|
478
|
+
app,
|
|
479
|
+
resolveCliImpl = resolveCli,
|
|
480
|
+
detectCliImpl = detectCli,
|
|
481
|
+
listReposImpl = listRepos,
|
|
482
|
+
launchLoginImpl = launchCommandInTerminal,
|
|
483
|
+
}) {
|
|
484
|
+
/**
|
|
485
|
+
* GET /api/remote-repos?provider=github|gitee
|
|
486
|
+
*
|
|
487
|
+
* 永远回 200(除非 provider 非法):"没装 CLI"和"装了没登录"都是**正常状态**,
|
|
488
|
+
* 前端据此渲染安装引导 / 登录引导,不是错误。只有真正的失败(CLI 报错、
|
|
489
|
+
* 超时、网络不通)才把原因放进 error 字段,由前端就地展示。
|
|
490
|
+
*/
|
|
491
|
+
app.get('/api/remote-repos', asyncRoute(async (req, res) => {
|
|
492
|
+
const provider = String(req.query.provider || '').toLowerCase();
|
|
493
|
+
if (!PROVIDERS[provider]) {
|
|
494
|
+
throw new HttpError(400, 'provider 必须是 github 或 gitee');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const config = PROVIDERS[provider];
|
|
498
|
+
const installer = getToolInstallers()[config.toolId];
|
|
499
|
+
const base = {
|
|
500
|
+
success: true,
|
|
501
|
+
provider,
|
|
502
|
+
cli: config.cli,
|
|
503
|
+
label: config.label,
|
|
504
|
+
platform: process.platform,
|
|
505
|
+
docsUrl: config.docsUrl,
|
|
506
|
+
loginCommand: config.loginCommand,
|
|
507
|
+
installer: installer ? publicInstallerInfo({ [config.toolId]: installer })[config.toolId] : null,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const resolved = await resolveCliImpl(provider);
|
|
511
|
+
if (!resolved) {
|
|
512
|
+
return res.json({
|
|
513
|
+
...base,
|
|
514
|
+
installed: false,
|
|
515
|
+
version: null,
|
|
516
|
+
authenticated: false,
|
|
517
|
+
user: null,
|
|
518
|
+
repos: [],
|
|
519
|
+
truncated: false,
|
|
520
|
+
error: null,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let detected;
|
|
525
|
+
try {
|
|
526
|
+
detected = await detectCliImpl(provider, resolved.executable, resolved.version);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
logger.info(`[remote-repos] ${provider} 检测失败: ${error.message}`);
|
|
529
|
+
return res.json({
|
|
530
|
+
...base,
|
|
531
|
+
installed: true,
|
|
532
|
+
version: resolved.version,
|
|
533
|
+
authenticated: false,
|
|
534
|
+
user: null,
|
|
535
|
+
repos: [],
|
|
536
|
+
truncated: false,
|
|
537
|
+
error: `检测 ${config.cli} 状态失败: ${error.message}`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!detected.authenticated) {
|
|
542
|
+
return res.json({
|
|
543
|
+
...base,
|
|
544
|
+
...detected,
|
|
545
|
+
repos: [],
|
|
546
|
+
truncated: false,
|
|
547
|
+
error: null,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const { repos, error, truncated } = await listReposImpl(provider, resolved.executable);
|
|
552
|
+
res.json({
|
|
553
|
+
...base,
|
|
554
|
+
...detected,
|
|
555
|
+
repos,
|
|
556
|
+
truncated: !!truncated,
|
|
557
|
+
error: error || null,
|
|
558
|
+
});
|
|
559
|
+
}));
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* POST /api/remote-repos/login body: { provider }
|
|
563
|
+
*
|
|
564
|
+
* 在新终端窗口里跑这个平台的登录命令。
|
|
565
|
+
*
|
|
566
|
+
* 为什么只能"开窗口"而不能真代劳:`gh auth login` / `gitee auth login` 都是
|
|
567
|
+
* **交互式**的(选平台、选协议、走浏览器还是粘贴 token),必须有 TTY ——
|
|
568
|
+
* 被 spawn 出去又没有终端,它直接失败,用户屏幕上什么也看不到。
|
|
569
|
+
* 所以这里只把窗口开起来,后面的问答交给 CLI 自己;这也正合"我们不碰 token"
|
|
570
|
+
* 那条原则:凭据由 CLI 自己收、自己存。
|
|
571
|
+
*
|
|
572
|
+
* 安全边界与 /api/install-tool 完全一致:请求体里只有一个 provider 名,
|
|
573
|
+
* 命令取自服务端 PROVIDERS 白名单常量。
|
|
574
|
+
*
|
|
575
|
+
* 为什么要先 resolveCli(早先省掉的这一步是错的,2026-09-22 实测踩到):
|
|
576
|
+
* 本进程的 `process.env.PATH` 是启动那一刻的快照。用户刚装好 gh 时,
|
|
577
|
+
* 安装器改的是注册表里的 PATH —— 探测能找到 gh(走注册表 + 默认落点),
|
|
578
|
+
* 界面显示"已安装",但子进程 cmd 按自己的旧 PATH 找,报
|
|
579
|
+
* `'gh' 不是内部或外部命令`;而用户自己新开一个 cmd 是好的。
|
|
580
|
+
* 所以这里必须解析出 **CLI 所在的目录**,前置进子进程 PATH 一起带下去。
|
|
581
|
+
* 顺带补上了"没装就别开窗口"的校验 —— 报错比空窗口好懂。
|
|
582
|
+
*/
|
|
583
|
+
app.post('/api/remote-repos/login', asyncRoute(async (req, res) => {
|
|
584
|
+
const provider = String(req.body?.provider || '').toLowerCase();
|
|
585
|
+
if (!PROVIDERS[provider]) {
|
|
586
|
+
throw new HttpError(400, 'provider 必须是 github 或 gitee');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const config = PROVIDERS[provider];
|
|
590
|
+
const resolved = await resolveCliImpl(provider);
|
|
591
|
+
if (!resolved) {
|
|
592
|
+
throw new HttpError(400, `未检测到 ${config.cli},请先完成安装`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 只有绝对路径才取目录:单测里的假实现可能只回一个裸命令名 'gh',
|
|
596
|
+
// 那种情况取出来是 '.',前置进 PATH 反而添乱。
|
|
597
|
+
const executableDir = resolved.executable && path.isAbsolute(resolved.executable)
|
|
598
|
+
? path.dirname(resolved.executable)
|
|
599
|
+
: null;
|
|
600
|
+
|
|
601
|
+
await launchLoginImpl(config.loginCommand, { pathDirs: [executableDir] });
|
|
602
|
+
res.json({ success: true });
|
|
603
|
+
}));
|
|
604
|
+
}
|