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.
Files changed (112) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +210 -58
  4. package/src/cli/ai/context.js +58 -0
  5. package/src/cli/ai/context.test.js +117 -0
  6. package/src/cli/ai/extensions.js +131 -0
  7. package/src/cli/ai/extensions.test.js +158 -0
  8. package/src/cli/ai/mcp.js +498 -0
  9. package/src/cli/ai/mcp.test.js +275 -0
  10. package/src/cli/ai/runtime.test.js +32 -0
  11. package/src/cli/ai/skills.js +207 -0
  12. package/src/cli/ai/skills.test.js +126 -0
  13. package/src/cli/ai/termui.js +1 -0
  14. package/src/cli/ai/tools.js +94 -3
  15. package/src/cli/ai/tools.test.js +82 -0
  16. package/src/cli/ai/transport.js +18 -4
  17. package/src/cli/ai/turn.js +21 -5
  18. package/src/config.js +27 -3
  19. package/src/paths.js +11 -0
  20. package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
  21. package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
  22. package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
  23. package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
  24. package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
  25. package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
  26. package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
  27. package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
  28. package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
  30. package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
  31. package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
  32. package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
  33. package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
  34. package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
  35. package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
  36. package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
  37. package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
  38. package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
  39. package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
  41. package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
  44. package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
  45. package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
  46. package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
  47. package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
  48. package/src/ui/public/assets/index-CW3xs-af.css +1 -0
  49. package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
  50. package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
  51. package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
  52. package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
  53. package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
  54. package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
  55. package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
  56. package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
  57. package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
  58. package/src/ui/public/index.html +17 -17
  59. package/src/ui/server/index.js +4 -0
  60. package/src/ui/server/routes/config.js +16 -3
  61. package/src/ui/server/routes/exec.js +19 -11
  62. package/src/ui/server/routes/fileOpen.js +142 -2
  63. package/src/ui/server/routes/remoteRepos.js +604 -0
  64. package/src/ui/server/routes/remoteRepos.test.js +386 -0
  65. package/src/ui/server/routes/workbench/agentChat.js +86 -180
  66. package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
  67. package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
  68. package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
  69. package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
  70. package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
  71. package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
  72. package/src/ui/server/routes/workbench/jobStore.js +17 -3
  73. package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
  74. package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
  75. package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
  76. package/src/ui/server/routes/workbench/projectTool.js +198 -0
  77. package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
  78. package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
  79. package/src/ui/server/routes/workbench/taskRunner.js +269 -12
  80. package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
  81. package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
  82. package/src/utils/shellPath.js +253 -0
  83. package/src/utils/shellPath.test.js +221 -0
  84. package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
  85. package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
  86. package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
  87. package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
  88. package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
  89. package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
  90. package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
  91. package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
  92. package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
  93. package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
  94. package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
  95. package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
  96. package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
  97. package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
  98. package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
  99. package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
  100. package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
  101. package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
  102. package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
  103. package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
  104. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
  105. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
  106. package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
  107. package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
  108. package/src/ui/public/assets/index-cApp6w0b.css +0 -1
  109. package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
  110. package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
  111. package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
  112. package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
@@ -0,0 +1,386 @@
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
+ // src/ui/server/routes/remoteRepos.js 单元测试。
16
+ //
17
+ // 重点覆盖两处**实测踩过的坑**,它们都不会报错、只会静默给出错误结果:
18
+ // 1. `gitee auth status` 未登录时退出码是 0 —— 用退出码判会永远认为已登录
19
+ // 2. `parseRepoJson` 对纯对象(如 auth status 的 JSON)返回 null ——
20
+ // 拿它去解登录态会让解析悄悄退化成文本匹配,登录后拿不到用户名
21
+ // 另外覆盖四条路由分支(未安装 / 未登录 / 已登录 / 拉取失败)与 provider 校验。
22
+ //
23
+ // 不真起 Express:沿用 branchStatus.test.js 的 mock app + mock req/res 做法。
24
+ // CLI 调用通过 registerRemoteReposRoutes 的 *Impl 注入口换成假实现,不联网、不装 gh。
25
+ import { test } from 'node:test'
26
+ import assert from 'node:assert/strict'
27
+ import {
28
+ interpretGiteeAuthStatus,
29
+ interpretGithubAuthStatus,
30
+ normalizeGiteeRepos,
31
+ normalizeGithubRepos,
32
+ parseJsonLoose,
33
+ parseRepoJson,
34
+ pickUserName,
35
+ registerRemoteReposRoutes,
36
+ } from './remoteRepos.js'
37
+
38
+ /** 最小 express app mock:只实现 get + post 与 handler 调用 */
39
+ function makeApp() {
40
+ const handlers = new Map()
41
+ return {
42
+ get(path, handler) { handlers.set(`GET ${path}`, handler) },
43
+ post(path, handler) { handlers.set(`POST ${path}`, handler) },
44
+ invoke(method, path, req = { query: {} }, res = makeRes()) {
45
+ const key = `${method} ${path}`
46
+ const handler = handlers.get(key)
47
+ assert.ok(handler, `no handler for ${key}`)
48
+ return handler(req, res, () => {})
49
+ },
50
+ }
51
+ }
52
+
53
+ function makeRes() {
54
+ return {
55
+ statusCode: 200,
56
+ body: undefined,
57
+ status(code) { this.statusCode = code; return this },
58
+ json(payload) { this.body = payload; return this },
59
+ }
60
+ }
61
+
62
+ /** 注册路由并把三个 CLI 依赖换成假实现 */
63
+ function setup({ resolved, detected, listed } = {}) {
64
+ const app = makeApp()
65
+ registerRemoteReposRoutes({
66
+ app,
67
+ resolveCliImpl: async () => (resolved === undefined ? { executable: 'gh', version: '2.101.0' } : resolved),
68
+ detectCliImpl: async (provider, executable, version) => (
69
+ detected === undefined ? { installed: true, version, authenticated: true, user: 'xz333221' } : detected
70
+ ),
71
+ listReposImpl: async () => (listed === undefined ? { repos: [], error: null } : listed),
72
+ // 安全网:登录接口的默认实现会真去开一个终端窗口。走 setup() 的用例不该碰它,
73
+ // 所以这里一律换成空实现 —— 万一哪天被调到,也只是静默通过,不会弹窗口。
74
+ launchLoginImpl: async () => {},
75
+ })
76
+ return app
77
+ }
78
+
79
+ const get = (query) => ({ query })
80
+
81
+ // ── 纯解析函数 ──────────────────────────────────────────────────────────────
82
+
83
+ test('parseJsonLoose: 容忍 JSON 前面的警告/更新提示行', () => {
84
+ assert.deepEqual(parseJsonLoose('[{"a":1}]'), [{ a: 1 }])
85
+ assert.deepEqual(parseJsonLoose('A new version is available\n[{"a":2}]'), [{ a: 2 }])
86
+ assert.deepEqual(parseJsonLoose('{"host":"gitee.com","status":"not logged in"}'), {
87
+ host: 'gitee.com',
88
+ status: 'not logged in',
89
+ })
90
+ })
91
+
92
+ test('parseJsonLoose: 没有 JSON / 坏 JSON 一律返回 null(由调用方降级)', () => {
93
+ assert.equal(parseJsonLoose('not logged in'), null)
94
+ assert.equal(parseJsonLoose(''), null)
95
+ assert.equal(parseJsonLoose(null), null)
96
+ assert.equal(parseJsonLoose('[{broken'), null)
97
+ })
98
+
99
+ test('parseRepoJson: 裸数组 / 对象壳都能解出仓库数组', () => {
100
+ assert.deepEqual(parseRepoJson('[{"name":"a"}]'), [{ name: 'a' }])
101
+ assert.deepEqual(parseRepoJson('{"repos":[{"name":"b"}]}'), [{ name: 'b' }])
102
+ assert.deepEqual(parseRepoJson('{"data":[{"name":"c"}]}'), [{ name: 'c' }])
103
+ })
104
+
105
+ test('parseRepoJson: 纯对象返回 null —— 别拿它解 auth status(否则登录态解析会静默退化)', () => {
106
+ assert.equal(parseRepoJson('{"host":"gitee.com","status":"logged in"}'), null)
107
+ })
108
+
109
+ test('pickUserName: 字符串与对象两种形态都要认', () => {
110
+ assert.equal(pickUserName('xz333221'), 'xz333221')
111
+ assert.equal(pickUserName({ login: 'xuze333221' }), 'xuze333221')
112
+ assert.equal(pickUserName({ name: '徐泽' }), '徐泽')
113
+ assert.equal(pickUserName({}), null)
114
+ assert.equal(pickUserName(''), null)
115
+ assert.equal(pickUserName(null), null)
116
+ })
117
+
118
+ test('interpretGithubAuthStatus: gh 未登录时退出码 1,已登录时能从输出里抠出账号名', () => {
119
+ assert.deepEqual(interpretGithubAuthStatus(1, 'You are not logged into any GitHub hosts.'), {
120
+ authenticated: false,
121
+ user: null,
122
+ })
123
+ assert.deepEqual(
124
+ interpretGithubAuthStatus(0, 'github.com\n ✓ Logged in to github.com account xz333221 (keyring)'),
125
+ { authenticated: true, user: 'xz333221' },
126
+ )
127
+ // 退出码 0 但输出里没有 account 行:仍然算已登录,只是没有用户名
128
+ assert.deepEqual(interpretGithubAuthStatus(0, 'logged in'), { authenticated: true, user: null })
129
+ })
130
+
131
+ test('interpretGiteeAuthStatus: 退出码不可信,只看 status 字段', () => {
132
+ // 实测 v0.3.1:`gitee auth status` 未登录时退出码也是 0,所以这里只有文本输入
133
+ assert.deepEqual(interpretGiteeAuthStatus('{"host":"gitee.com","status":"not logged in"}'), {
134
+ authenticated: false,
135
+ user: null,
136
+ })
137
+ assert.deepEqual(
138
+ interpretGiteeAuthStatus('{"host":"gitee.com","status":"logged in","user":"xuze333221"}'),
139
+ { authenticated: true, user: 'xuze333221' },
140
+ )
141
+ // user 是个对象时也要能取出名字
142
+ assert.deepEqual(
143
+ interpretGiteeAuthStatus('{"status":"logged in","user":{"login":"xuze333221"}}'),
144
+ { authenticated: true, user: 'xuze333221' },
145
+ )
146
+ })
147
+
148
+ test('interpretGiteeAuthStatus: 出现未知状态词时宁可判未登录,也不谎报已登录', () => {
149
+ assert.deepEqual(interpretGiteeAuthStatus('{"status":"token expired"}'), {
150
+ authenticated: false,
151
+ user: null,
152
+ })
153
+ })
154
+
155
+ test('interpretGiteeAuthStatus: JSON 解不出来返回 null,交给调用方走文本兜底', () => {
156
+ assert.equal(interpretGiteeAuthStatus('not logged in'), null)
157
+ assert.equal(interpretGiteeAuthStatus(''), null)
158
+ })
159
+
160
+ test('normalizeGithubRepos: primaryLanguage 是对象,取 .name;缺字段有默认值', () => {
161
+ const [repo] = normalizeGithubRepos([{
162
+ name: 'zen-gitsync',
163
+ nameWithOwner: 'xz333221/zen-gitsync',
164
+ description: 'Git GUI',
165
+ isPrivate: false,
166
+ isFork: false,
167
+ primaryLanguage: { name: 'JavaScript' },
168
+ stargazerCount: 1,
169
+ updatedAt: '2026-09-22T00:00:00Z',
170
+ url: 'https://github.com/xz333221/zen-gitsync',
171
+ }])
172
+ assert.equal(repo.language, 'JavaScript')
173
+ assert.equal(repo.fullName, 'xz333221/zen-gitsync')
174
+ assert.equal(repo.stars, 1)
175
+
176
+ const [bare] = normalizeGithubRepos([{}])
177
+ assert.equal(bare.language, null)
178
+ assert.equal(bare.stars, 0)
179
+ assert.equal(bare.isPrivate, false)
180
+ assert.equal(bare.url, '')
181
+ })
182
+
183
+ test('normalizeGiteeRepos: private 缺失时用 public 反推;url 缺失时用 full_name 拼', () => {
184
+ const [priv] = normalizeGiteeRepos([{ name: 'a', full_name: 'xz_web/a', public: false }])
185
+ assert.equal(priv.isPrivate, true)
186
+
187
+ const [pub] = normalizeGiteeRepos([{ name: 'b', full_name: 'xuze333221/b', public: true }])
188
+ assert.equal(pub.isPrivate, false)
189
+ assert.equal(pub.url, 'https://gitee.com/xuze333221/b')
190
+
191
+ // 显式 private 优先于 public
192
+ const [explicit] = normalizeGiteeRepos([{ name: 'c', private: true, public: true }])
193
+ assert.equal(explicit.isPrivate, true)
194
+ })
195
+
196
+ // ── 路由分支 ────────────────────────────────────────────────────────────────
197
+
198
+ test('GET /api/remote-repos: provider 非法 → 400', async () => {
199
+ const app = setup()
200
+ const res = makeRes()
201
+ await app.invoke('GET', '/api/remote-repos', get({ provider: 'gitlab' }), res)
202
+ assert.equal(res.statusCode, 400)
203
+ assert.equal(res.body.success, false)
204
+ assert.match(res.body.error, /github 或 gitee/)
205
+ })
206
+
207
+ test('GET /api/remote-repos: 未安装 CLI → installed:false,仍然带上安装命令', async () => {
208
+ const app = setup({ resolved: null })
209
+ const res = makeRes()
210
+ await app.invoke('GET', '/api/remote-repos', get({ provider: 'github' }), res)
211
+
212
+ assert.equal(res.statusCode, 200)
213
+ assert.equal(res.body.success, true)
214
+ assert.equal(res.body.installed, false)
215
+ assert.deepEqual(res.body.repos, [])
216
+ // 安装引导要能拿到命令,否则前端没法"提供一键安装的命令"
217
+ assert.ok(res.body.installer, '应该带上安装方式')
218
+ assert.ok(res.body.installer.command, '应该带上安装命令')
219
+ assert.equal(res.body.loginCommand, 'gh auth login')
220
+ })
221
+
222
+ test('GET /api/remote-repos: 已安装未登录 → authenticated:false 且不拉仓库', async () => {
223
+ let listCalled = false
224
+ const app = makeApp()
225
+ registerRemoteReposRoutes({
226
+ app,
227
+ resolveCliImpl: async () => ({ executable: 'gitee', version: '0.3.1' }),
228
+ detectCliImpl: async (provider, executable, version) => ({
229
+ installed: true, version, authenticated: false, user: null,
230
+ }),
231
+ listReposImpl: async () => {
232
+ listCalled = true
233
+ return { repos: [], error: null }
234
+ },
235
+ })
236
+ const res = makeRes()
237
+ await app.invoke('GET', '/api/remote-repos', get({ provider: 'gitee' }), res)
238
+
239
+ assert.equal(res.body.installed, true)
240
+ assert.equal(res.body.authenticated, false)
241
+ assert.equal(res.body.version, '0.3.1')
242
+ assert.deepEqual(res.body.repos, [])
243
+ assert.equal(listCalled, false, '没登录就不该去拉仓库列表')
244
+ assert.equal(res.body.loginCommand, 'gitee auth login')
245
+ })
246
+
247
+ test('GET /api/remote-repos: 已登录 → 返回仓库列表与账号名', async () => {
248
+ const app = setup({
249
+ detected: { installed: true, version: '2.101.0', authenticated: true, user: 'xz333221' },
250
+ listed: {
251
+ repos: [{
252
+ name: 'zen-gitsync',
253
+ fullName: 'xz333221/zen-gitsync',
254
+ description: '',
255
+ isPrivate: false,
256
+ isFork: false,
257
+ language: 'JavaScript',
258
+ stars: 1,
259
+ updatedAt: '2026-09-22T00:00:00Z',
260
+ url: 'https://github.com/xz333221/zen-gitsync',
261
+ }],
262
+ error: null,
263
+ },
264
+ })
265
+ const res = makeRes()
266
+ await app.invoke('GET', '/api/remote-repos', get({ provider: 'github' }), res)
267
+
268
+ assert.equal(res.body.authenticated, true)
269
+ assert.equal(res.body.user, 'xz333221')
270
+ assert.equal(res.body.repos.length, 1)
271
+ assert.equal(res.body.repos[0].fullName, 'xz333221/zen-gitsync')
272
+ assert.equal(res.body.error, null)
273
+ })
274
+
275
+ test('GET /api/remote-repos: 拉取失败 → 200 + error 说明(不是 500 弹窗,前端就地展示)', async () => {
276
+ const app = setup({
277
+ detected: { installed: true, version: '2.101.0', authenticated: true, user: 'xz333221' },
278
+ listed: { repos: [], error: 'gh repo list 超时(25 秒),请检查网络或代理设置' },
279
+ })
280
+ const res = makeRes()
281
+ await app.invoke('GET', '/api/remote-repos', get({ provider: 'github' }), res)
282
+
283
+ assert.equal(res.statusCode, 200)
284
+ assert.equal(res.body.success, true)
285
+ assert.match(res.body.error, /超时/)
286
+ assert.deepEqual(res.body.repos, [])
287
+ })
288
+
289
+ test('GET /api/remote-repos: provider 大小写不敏感', async () => {
290
+ const app = setup()
291
+ const res = makeRes()
292
+ await app.invoke('GET', '/api/remote-repos', get({ provider: 'GitHub' }), res)
293
+ assert.equal(res.statusCode, 200)
294
+ assert.equal(res.body.provider, 'github')
295
+ })
296
+
297
+ // ── POST /api/remote-repos/login(一键登录:只负责开终端窗口)──────────────────
298
+
299
+ /** 平台无关的"绝对路径"假数据:测试要能跨平台跑,不能写死 Windows 路径 */
300
+ const EXE_DIR = process.platform === 'win32' ? 'C:\\Program Files\\GitHub CLI' : '/usr/local/bin'
301
+ const EXE_PATH = process.platform === 'win32' ? `${EXE_DIR}\\gh.exe` : `${EXE_DIR}/gh`
302
+
303
+ /**
304
+ * 登录接口的测试架子。
305
+ * resolveCli 也要注入:路由会真的去解析 CLI(为了拿它所在目录),
306
+ * 不注入的话用例就依赖"本机装没装 gh"了,换台机器就红。
307
+ * launchLoginImpl 的默认实现会**真开一个终端窗口**,必须换成假的。
308
+ */
309
+ function setupLogin({ resolved } = {}) {
310
+ const launched = []
311
+ const app = makeApp()
312
+ registerRemoteReposRoutes({
313
+ app,
314
+ resolveCliImpl: async () => (
315
+ resolved === undefined ? { executable: EXE_PATH, version: '2.101.0' } : resolved
316
+ ),
317
+ launchLoginImpl: async (command, options) => { launched.push({ command, options }) },
318
+ })
319
+ return { app, launched }
320
+ }
321
+
322
+ test('POST /api/remote-repos/login: 在新终端里跑该平台的登录命令', async () => {
323
+ const { app, launched } = setupLogin()
324
+ const res = makeRes()
325
+ await app.invoke('POST', '/api/remote-repos/login', { body: { provider: 'github' } }, res)
326
+
327
+ assert.equal(res.statusCode, 200)
328
+ assert.equal(res.body.success, true)
329
+ assert.equal(launched.length, 1)
330
+ // gh auth login 是交互式的,服务端只把窗口开起来,后面的问答交给 CLI 自己
331
+ assert.equal(launched[0].command, 'gh auth login')
332
+ })
333
+
334
+ test('POST /api/remote-repos/login: 把 CLI 所在目录前置给子进程(否则刚装好的 CLI 会"不是内部或外部命令")', async () => {
335
+ // 回归 2026-09-22:服务端进程的 PATH 是启动那一刻的快照,用户刚装好的 CLI
336
+ // 不在里面。探测能找到它(走注册表),子进程 cmd 按自己的 PATH 却找不到 ——
337
+ // 所以必须把 CLI 目录一起带下去。这条断言就是这次修复的锚点。
338
+ const { app, launched } = setupLogin()
339
+ await app.invoke('POST', '/api/remote-repos/login', { body: { provider: 'github' } }, makeRes())
340
+
341
+ assert.deepEqual(launched[0].options.pathDirs, [EXE_DIR])
342
+ })
343
+
344
+ test('POST /api/remote-repos/login: 没检测到 CLI → 400 且不开窗口(报错比一个空窗口好懂)', async () => {
345
+ const { app, launched } = setupLogin({ resolved: null })
346
+ const res = makeRes()
347
+ await app.invoke('POST', '/api/remote-repos/login', { body: { provider: 'github' } }, res)
348
+
349
+ assert.equal(res.statusCode, 400)
350
+ assert.match(res.body.error, /未检测到 gh/)
351
+ assert.deepEqual(launched, [])
352
+ })
353
+
354
+ test('POST /api/remote-repos/login: gitee 用自己的登录命令(provider 大小写不敏感)', async () => {
355
+ const { app, launched } = setupLogin()
356
+ await app.invoke('POST', '/api/remote-repos/login', { body: { provider: 'Gitee' } }, makeRes())
357
+ assert.deepEqual(launched.map((l) => l.command), ['gitee auth login'])
358
+ })
359
+
360
+ test('POST /api/remote-repos/login: provider 非法或缺失 → 400 且绝不启动命令', async () => {
361
+ const { app, launched } = setupLogin()
362
+
363
+ const res = makeRes()
364
+ await app.invoke('POST', '/api/remote-repos/login', { body: { provider: 'gitlab' } }, res)
365
+ assert.equal(res.statusCode, 400)
366
+ assert.match(res.body.error, /github 或 gitee/)
367
+
368
+ // 整个 body 缺失(前端忘传 / 直接 POST 空请求)也要挡住
369
+ const res2 = makeRes()
370
+ await app.invoke('POST', '/api/remote-repos/login', {}, res2)
371
+ assert.equal(res2.statusCode, 400)
372
+
373
+ assert.deepEqual(launched, [], '参数不合法时绝不能开窗口')
374
+ })
375
+
376
+ test('POST /api/remote-repos/login: 命令只来自服务端白名单,请求体里塞的 command 一律忽略', async () => {
377
+ // 这条断言就是接口的安全边界本身:命令若能随请求体进来,
378
+ // 等于给这个接口开了一个任意命令执行的口子。
379
+ const { app, launched } = setupLogin()
380
+
381
+ await app.invoke('POST', '/api/remote-repos/login', {
382
+ body: { provider: 'github', command: 'whoami', loginCommand: 'whoami' },
383
+ }, makeRes())
384
+
385
+ assert.deepEqual(launched.map((l) => l.command), ['gh auth login'])
386
+ })