zen-gitsync 2.16.1 → 2.16.2
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 +28 -3
- package/package.json +1 -1
- package/src/cli/ai/agent.js +173 -15
- package/src/cli/ai/agent.test.js +77 -1
- package/src/cli/ai/modelSetup.js +594 -0
- package/src/cli/ai/modelSetup.test.js +496 -0
- package/src/cli/ai/platformGuard.js +200 -0
- package/src/cli/ai/platformGuard.test.js +253 -0
- package/src/cli/ai/termui.js +81 -6
- package/src/cli/ai/termui.test.js +38 -0
- package/src/cli/ai/tools.js +8 -0
- package/src/gitCommit.js +484 -484
- package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -0
- package/src/ui/public/assets/BranchSelector-BISqgXoZ.js +1 -0
- package/src/ui/public/assets/CommitForm-C7FmI0Sl.js +6 -0
- package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
- package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-CPblwVA1.js → FlowExecutionViewer-CZdF1f1e.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
- package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
- package/src/ui/public/assets/{MindmapView-C_9P5ijq.js → MindmapView-CU3tXgbR.js} +1 -1
- package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
- package/src/ui/public/assets/{RemoteRepoCard-C_cBtHcN.js → RemoteRepoCard-DHsJwTEX.js} +2 -2
- package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
- package/src/ui/public/assets/{SvgIcon-C9YQSvGU.js → SvgIcon-BqWp2WS8.js} +1 -1
- package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
- package/src/ui/public/assets/WorkbenchView-1q0xaqyV.js +28 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-CHQqCylN.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
- package/src/ui/public/assets/configStore-BkxDLXGa.js +1 -0
- package/src/ui/public/assets/{css.worker-CvXBzhp8.js → css.worker-BKx7BC4h.js} +7 -7
- package/src/ui/public/assets/dagre-CTEVx1fc.js +1 -0
- package/src/ui/public/assets/editor.worker-DQJwLooG.js +26 -0
- package/src/ui/public/assets/element-plus-CQrjg9us.css +1 -0
- package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
- package/src/ui/public/assets/{flow-mindmap-CUvJaYn2.js → flow-mindmap-DRmzYE4F.js} +6 -6
- package/src/ui/public/assets/{html.worker-BO6WuOEO.js → html.worker-ChRLLxCa.js} +4 -4
- package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
- package/src/ui/public/assets/{json.worker-BkJRGcCJ.js → json.worker-DR52qCex.js} +5 -5
- package/src/ui/public/assets/{monaco-BFLoT2VD.js → monaco-BvgtFLbw.js} +30 -30
- package/src/ui/public/assets/rolldown-runtime-DAXXjFlN.js +1 -0
- package/src/ui/public/assets/{socket-io-BSgn8Jx2.js → socket-io-pmu5tRPo.js} +1 -1
- package/src/ui/public/assets/{ts.worker-B0J26iPs.js → ts.worker-Cqvg7802.js} +18 -18
- package/src/ui/public/assets/{vendor-CU5NYUKJ.js → vendor-CcK8n8T4.js} +10 -10
- package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
- package/src/ui/public/favicon.svg +75 -75
- package/src/ui/public/index.html +30 -30
- package/src/ui/public/logo.svg +74 -74
- package/src/ui/server/.claude/codediff.txt +8 -0
- package/src/ui/server/middleware/requestLogger.js +53 -53
- package/src/ui/server/routes/branchStatus.js +105 -105
- package/src/ui/server/routes/fs.js +791 -791
- package/src/ui/server/routes/git/diff.js +426 -426
- package/src/ui/server/routes/git/diffUtils.js +149 -149
- package/src/ui/server/routes/git/stash.js +550 -550
- package/src/ui/server/routes/git/tags.js +176 -176
- package/src/ui/server/routes/git.js +181 -181
- package/src/ui/server/routes/gitOps.js +1255 -1255
- package/src/ui/server/routes/process.js +86 -86
- package/src/ui/server/routes/status.js +67 -67
- package/src/ui/server/routes/workbench/pdfText.js +145 -145
- package/src/ui/server/utils/createSavePortToFile.js +48 -48
- package/src/ui/server/utils/instanceRegistry.js +282 -282
- package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
- package/src/utils/index.js +1173 -1173
- package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +0 -2
- package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +0 -1
- package/src/ui/public/assets/CommitForm-BihM26_4.js +0 -6
- package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +0 -1
- package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +0 -21
- package/src/ui/public/assets/LogList-va6ANtbL.js +0 -15
- package/src/ui/public/assets/MonitorView-DRPECOVW.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +0 -1
- package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +0 -3
- package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +0 -28
- package/src/ui/public/assets/configStore-Bw9U4AHx.js +0 -1
- package/src/ui/public/assets/dagre-CWIGcX9d.js +0 -1
- package/src/ui/public/assets/editor.worker-Cn2oRESe.js +0 -26
- package/src/ui/public/assets/element-plus-CyGhXd7X.js +0 -25
- package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
- package/src/ui/public/assets/index-BzNpkvm5.js +0 -68
- package/src/ui/public/assets/rolldown-runtime-CMxvf4Kt.js +0 -1
- package/src/ui/public/assets/vue-flow-CWmfpfPN.js +0 -39
|
@@ -0,0 +1,496 @@
|
|
|
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/cli/ai/modelSetup.js 的单元测试。
|
|
16
|
+
// 覆盖纯函数:getBuiltinModels / findProviderByUrl / validateEndpoint /
|
|
17
|
+
// buildModelConfig / testModelConnection(注入 mock fetch)。
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import {
|
|
21
|
+
getBuiltinModels,
|
|
22
|
+
findProviderByUrl,
|
|
23
|
+
validateEndpoint,
|
|
24
|
+
buildModelConfig,
|
|
25
|
+
testModelConnection,
|
|
26
|
+
collectModelInput,
|
|
27
|
+
PROVIDERS,
|
|
28
|
+
BUILTIN_MODELS,
|
|
29
|
+
} from './modelSetup.js'
|
|
30
|
+
|
|
31
|
+
// ── getBuiltinModels ──────────────────────────
|
|
32
|
+
|
|
33
|
+
test('getBuiltinModels: 已知 baseURL 返回模型列表', () => {
|
|
34
|
+
const models = getBuiltinModels('https://api.deepseek.com/v1')
|
|
35
|
+
assert.ok(Array.isArray(models))
|
|
36
|
+
assert.ok(models.length > 0)
|
|
37
|
+
assert.ok(models.includes('deepseek-chat'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('getBuiltinModels: 尾斜杠的 baseURL 也能匹配', () => {
|
|
41
|
+
const a = getBuiltinModels('https://api.deepseek.com/v1')
|
|
42
|
+
const b = getBuiltinModels('https://api.deepseek.com/v1/')
|
|
43
|
+
assert.deepEqual(a, b)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('getBuiltinModels: 未知 baseURL 返回空数组', () => {
|
|
47
|
+
assert.deepEqual(getBuiltinModels('https://example.com/v1'), [])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('getBuiltinModels: null / undefined / 空字符串 返回空数组', () => {
|
|
51
|
+
assert.deepEqual(getBuiltinModels(null), [])
|
|
52
|
+
assert.deepEqual(getBuiltinModels(undefined), [])
|
|
53
|
+
assert.deepEqual(getBuiltinModels(''), [])
|
|
54
|
+
assert.deepEqual(getBuiltinModels(123), [])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ── findProviderByUrl ─────────────────────────
|
|
58
|
+
|
|
59
|
+
test('findProviderByUrl: 已知 URL 返回 provider 对象', () => {
|
|
60
|
+
const p = findProviderByUrl('https://api.openai.com/v1')
|
|
61
|
+
assert.ok(p)
|
|
62
|
+
assert.equal(p.id, 'openai')
|
|
63
|
+
assert.equal(p.label, 'OpenAI')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('findProviderByUrl: 尾斜杠的 URL 也能匹配', () => {
|
|
67
|
+
const p = findProviderByUrl('https://api.openai.com/v1/')
|
|
68
|
+
assert.ok(p)
|
|
69
|
+
assert.equal(p.id, 'openai')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('findProviderByUrl: 未知 URL 返回 null', () => {
|
|
73
|
+
assert.equal(findProviderByUrl('https://example.com/v1'), null)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('findProviderByUrl: null / undefined 返回 null', () => {
|
|
77
|
+
assert.equal(findProviderByUrl(null), null)
|
|
78
|
+
assert.equal(findProviderByUrl(undefined), null)
|
|
79
|
+
assert.equal(findProviderByUrl(''), null)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('findProviderByUrl: 每个预设都能被找到', () => {
|
|
83
|
+
for (const p of PROVIDERS) {
|
|
84
|
+
const found = findProviderByUrl(p.url)
|
|
85
|
+
assert.ok(found, `应能找到 provider: ${p.id}`)
|
|
86
|
+
assert.equal(found.id, p.id)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ── validateEndpoint ──────────────────────────
|
|
91
|
+
|
|
92
|
+
test('validateEndpoint: 合法 https URL', () => {
|
|
93
|
+
assert.equal(validateEndpoint('https://api.openai.com/v1'), true)
|
|
94
|
+
assert.equal(validateEndpoint('https://example.com'), true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('validateEndpoint: 合法 http URL', () => {
|
|
98
|
+
assert.equal(validateEndpoint('http://localhost:11434/v1'), true)
|
|
99
|
+
assert.equal(validateEndpoint('http://192.168.1.1:8080'), true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('validateEndpoint: 非法协议(ftp)返回 false', () => {
|
|
103
|
+
assert.equal(validateEndpoint('ftp://example.com'), false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('validateEndpoint: 无协议返回 false', () => {
|
|
107
|
+
assert.equal(validateEndpoint('example.com'), false)
|
|
108
|
+
assert.equal(validateEndpoint('api.openai.com/v1'), false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('validateEndpoint: null / undefined / 空字符串返回 false', () => {
|
|
112
|
+
assert.equal(validateEndpoint(null), false)
|
|
113
|
+
assert.equal(validateEndpoint(undefined), false)
|
|
114
|
+
assert.equal(validateEndpoint(''), false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ── buildModelConfig ──────────────────────────
|
|
118
|
+
|
|
119
|
+
test('buildModelConfig: 基本构建', () => {
|
|
120
|
+
const m = buildModelConfig({
|
|
121
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
122
|
+
model: 'deepseek-chat',
|
|
123
|
+
apiKey: 'sk-test',
|
|
124
|
+
name: 'My DeepSeek',
|
|
125
|
+
isDefault: true,
|
|
126
|
+
})
|
|
127
|
+
assert.equal(m.model, 'deepseek-chat')
|
|
128
|
+
assert.equal(m.name, 'My DeepSeek')
|
|
129
|
+
assert.equal(m.baseURL, 'https://api.deepseek.com/v1')
|
|
130
|
+
assert.equal(m.apiKey, 'sk-test')
|
|
131
|
+
assert.equal(m.isDefault, true)
|
|
132
|
+
assert.ok(m.id, 'id 不应为空')
|
|
133
|
+
assert.match(m.id, /^model-/, 'id 应以 model- 开头')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('buildModelConfig: 不传 name 时默认用 model 名', () => {
|
|
137
|
+
const m = buildModelConfig({
|
|
138
|
+
baseURL: 'https://api.openai.com/v1',
|
|
139
|
+
model: 'gpt-4o',
|
|
140
|
+
})
|
|
141
|
+
assert.equal(m.name, 'gpt-4o')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('buildModelConfig: name 为空白时默认用 model 名', () => {
|
|
145
|
+
const m = buildModelConfig({
|
|
146
|
+
baseURL: 'https://api.openai.com/v1',
|
|
147
|
+
model: 'gpt-4o',
|
|
148
|
+
name: ' ',
|
|
149
|
+
})
|
|
150
|
+
assert.equal(m.name, 'gpt-4o')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('buildModelConfig: baseURL 去尾斜杠', () => {
|
|
154
|
+
const m = buildModelConfig({
|
|
155
|
+
baseURL: 'https://api.openai.com/v1/',
|
|
156
|
+
model: 'gpt-4o',
|
|
157
|
+
})
|
|
158
|
+
assert.equal(m.baseURL, 'https://api.openai.com/v1')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('buildModelConfig: isDefault 默认 false', () => {
|
|
162
|
+
const m = buildModelConfig({
|
|
163
|
+
baseURL: 'https://api.openai.com/v1',
|
|
164
|
+
model: 'gpt-4o',
|
|
165
|
+
})
|
|
166
|
+
assert.equal(m.isDefault, false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('buildModelConfig: apiKey 为空时存空字符串', () => {
|
|
170
|
+
const m = buildModelConfig({
|
|
171
|
+
baseURL: 'http://localhost:11434/v1',
|
|
172
|
+
model: 'llama3',
|
|
173
|
+
})
|
|
174
|
+
assert.equal(m.apiKey, '')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('buildModelConfig: id 包含 name 的 slug 化形式', () => {
|
|
178
|
+
const m = buildModelConfig({
|
|
179
|
+
baseURL: 'https://api.openai.com/v1',
|
|
180
|
+
model: 'gpt-4o',
|
|
181
|
+
name: 'GPT 4o Mini!',
|
|
182
|
+
})
|
|
183
|
+
// name 中的非 a-z0-9 字符被替换为 -
|
|
184
|
+
assert.match(m.id, /^model-gpt-4o-mini-/)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ── testModelConnection ───────────────────────
|
|
188
|
+
// 注入 mock fetch 避免真实网络请求
|
|
189
|
+
|
|
190
|
+
function mockFetch({ status = 200, ok = true, body = {} } = {}) {
|
|
191
|
+
return async (url, opts) => ({
|
|
192
|
+
ok,
|
|
193
|
+
status,
|
|
194
|
+
json: async () => body,
|
|
195
|
+
text: async () => JSON.stringify(body),
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
test('testModelConnection: 200 成功', async () => {
|
|
200
|
+
const result = await testModelConnection({
|
|
201
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
202
|
+
model: 'deepseek-chat',
|
|
203
|
+
apiKey: 'sk-test',
|
|
204
|
+
fetchFn: mockFetch({ status: 200, ok: true }),
|
|
205
|
+
})
|
|
206
|
+
assert.equal(result.ok, true)
|
|
207
|
+
assert.equal(result.status, 200)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('testModelConnection: 400 也视为成功(连接通的)', async () => {
|
|
211
|
+
const result = await testModelConnection({
|
|
212
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
213
|
+
model: 'deepseek-chat',
|
|
214
|
+
fetchFn: mockFetch({ status: 400, ok: false }),
|
|
215
|
+
})
|
|
216
|
+
assert.equal(result.ok, true)
|
|
217
|
+
assert.equal(result.status, 400)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('testModelConnection: 401 API Key 无效', async () => {
|
|
221
|
+
const result = await testModelConnection({
|
|
222
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
223
|
+
model: 'deepseek-chat',
|
|
224
|
+
apiKey: 'sk-bad',
|
|
225
|
+
fetchFn: mockFetch({ status: 401, ok: false }),
|
|
226
|
+
})
|
|
227
|
+
assert.equal(result.ok, false)
|
|
228
|
+
assert.equal(result.status, 401)
|
|
229
|
+
assert.match(result.message, /401/)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('testModelConnection: 404 模型不存在', async () => {
|
|
233
|
+
const result = await testModelConnection({
|
|
234
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
235
|
+
model: 'nonexistent',
|
|
236
|
+
fetchFn: mockFetch({ status: 404, ok: false }),
|
|
237
|
+
})
|
|
238
|
+
assert.equal(result.ok, false)
|
|
239
|
+
assert.equal(result.status, 404)
|
|
240
|
+
assert.match(result.message, /404/)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('testModelConnection: 500 服务器错误', async () => {
|
|
244
|
+
const result = await testModelConnection({
|
|
245
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
246
|
+
model: 'deepseek-chat',
|
|
247
|
+
fetchFn: mockFetch({ status: 500, ok: false }),
|
|
248
|
+
})
|
|
249
|
+
assert.equal(result.ok, false)
|
|
250
|
+
assert.equal(result.status, 500)
|
|
251
|
+
assert.match(result.message, /500/)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('testModelConnection: AbortError 返回超时消息', async () => {
|
|
255
|
+
const abortFetch = async () => {
|
|
256
|
+
const err = new Error('aborted')
|
|
257
|
+
err.name = 'AbortError'
|
|
258
|
+
throw err
|
|
259
|
+
}
|
|
260
|
+
const result = await testModelConnection({
|
|
261
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
262
|
+
model: 'deepseek-chat',
|
|
263
|
+
fetchFn: abortFetch,
|
|
264
|
+
timeoutMs: 100,
|
|
265
|
+
})
|
|
266
|
+
assert.equal(result.ok, false)
|
|
267
|
+
assert.match(result.message, /超时|timeout/i)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('testModelConnection: 网络错误返回失败消息', async () => {
|
|
271
|
+
const failFetch = async () => {
|
|
272
|
+
throw new Error('ECONNREFUSED')
|
|
273
|
+
}
|
|
274
|
+
const result = await testModelConnection({
|
|
275
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
276
|
+
model: 'deepseek-chat',
|
|
277
|
+
fetchFn: failFetch,
|
|
278
|
+
})
|
|
279
|
+
assert.equal(result.ok, false)
|
|
280
|
+
assert.match(result.message, /ECONNREFUSED|连接失败/)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('testModelConnection: baseURL 尾斜杠被正确处理', async () => {
|
|
284
|
+
let calledUrl = ''
|
|
285
|
+
const spyFetch = async (url) => {
|
|
286
|
+
calledUrl = url
|
|
287
|
+
return { ok: true, status: 200, json: async () => ({}) }
|
|
288
|
+
}
|
|
289
|
+
await testModelConnection({
|
|
290
|
+
baseURL: 'https://api.deepseek.com/v1/',
|
|
291
|
+
model: 'deepseek-chat',
|
|
292
|
+
fetchFn: spyFetch,
|
|
293
|
+
})
|
|
294
|
+
// 尾斜杠被去掉,不产生双斜杠
|
|
295
|
+
assert.equal(calledUrl, 'https://api.deepseek.com/v1/chat/completions')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('testModelConnection: 无 apiKey 时不发 Authorization 头', async () => {
|
|
299
|
+
let calledHeaders = {}
|
|
300
|
+
const spyFetch = async (url, opts) => {
|
|
301
|
+
calledHeaders = opts.headers
|
|
302
|
+
return { ok: true, status: 200, json: async () => ({}) }
|
|
303
|
+
}
|
|
304
|
+
await testModelConnection({
|
|
305
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
306
|
+
model: 'deepseek-chat',
|
|
307
|
+
apiKey: '',
|
|
308
|
+
fetchFn: spyFetch,
|
|
309
|
+
})
|
|
310
|
+
assert.ok(!calledHeaders['Authorization'], '无 apiKey 时不应有 Authorization 头')
|
|
311
|
+
assert.equal(calledHeaders['Content-Type'], 'application/json')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('testModelConnection: 有 apiKey 时发 Bearer token', async () => {
|
|
315
|
+
let calledHeaders = {}
|
|
316
|
+
const spyFetch = async (url, opts) => {
|
|
317
|
+
calledHeaders = opts.headers
|
|
318
|
+
return { ok: true, status: 200, json: async () => ({}) }
|
|
319
|
+
}
|
|
320
|
+
await testModelConnection({
|
|
321
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
322
|
+
model: 'deepseek-chat',
|
|
323
|
+
apiKey: 'sk-abc123',
|
|
324
|
+
fetchFn: spyFetch,
|
|
325
|
+
})
|
|
326
|
+
assert.equal(calledHeaders['Authorization'], 'Bearer sk-abc123')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// ── 数据完整性 ────────────────────────────────
|
|
330
|
+
|
|
331
|
+
test('PROVIDERS: 至少有 16 个预设', () => {
|
|
332
|
+
assert.ok(PROVIDERS.length >= 16)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('PROVIDERS: 每个预设有 id / label / url', () => {
|
|
336
|
+
for (const p of PROVIDERS) {
|
|
337
|
+
assert.ok(p.id, `provider 缺少 id: ${JSON.stringify(p)}`)
|
|
338
|
+
assert.ok(p.label, `provider 缺少 label: ${JSON.stringify(p)}`)
|
|
339
|
+
assert.ok(p.url, `provider 缺少 url: ${JSON.stringify(p)}`)
|
|
340
|
+
assert.ok(validateEndpoint(p.url), `provider url 不合法: ${p.url}`)
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('BUILTIN_MODELS: 每个 baseURL 都对应一个非空数组', () => {
|
|
345
|
+
for (const [url, models] of Object.entries(BUILTIN_MODELS)) {
|
|
346
|
+
assert.ok(Array.isArray(models), `BUILTIN_MODELS[${url}] 应为数组`)
|
|
347
|
+
assert.ok(models.length > 0, `BUILTIN_MODELS[${url}] 不应为空`)
|
|
348
|
+
for (const m of models) {
|
|
349
|
+
assert.equal(typeof m, 'string', `模型名应为字符串: ${m}`)
|
|
350
|
+
assert.ok(m.length > 0, `模型名不应为空`)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('BUILTIN_MODELS: 每个 key 都能在 PROVIDERS 中找到', () => {
|
|
356
|
+
const providerUrls = new Set(PROVIDERS.map(p => p.url))
|
|
357
|
+
for (const url of Object.keys(BUILTIN_MODELS)) {
|
|
358
|
+
assert.ok(providerUrls.has(url), `BUILTIN_MODELS 的 key "${url}" 不在 PROVIDERS 中`)
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
// ── collectModelInput:交互式收集(注入 mock rl + mock fetch) ──
|
|
363
|
+
//
|
|
364
|
+
// collectModelInput 是 runModelSetup 和 agent.js /addmodel 共用的核心交互逻辑。
|
|
365
|
+
// 通过 mock readline 逐条返回预设答案,mock fetch 控制 /chat/completions 测试结果,
|
|
366
|
+
// 覆盖:预设服务商、自定义服务商、测试成功/失败/取消 四条主路径。
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 创建 mock readline,按队列依次返回预设答案给 rl.question()。
|
|
370
|
+
* @param {string[]} answers - 预设答案队列(每次 question 消费一条)
|
|
371
|
+
* @param {object} [opts]
|
|
372
|
+
* @param {number} [opts.closeOnQuestion=-1] - 在第 N 次 question 时触发 close 事件(模拟取消),-1=不触发
|
|
373
|
+
*/
|
|
374
|
+
function createMockRl(answers, { closeOnQuestion = -1 } = {}) {
|
|
375
|
+
const queue = [...answers]
|
|
376
|
+
const closeHandlers = []
|
|
377
|
+
let qNum = 0
|
|
378
|
+
return {
|
|
379
|
+
question(_prompt, cb) {
|
|
380
|
+
qNum++
|
|
381
|
+
if (qNum === closeOnQuestion) {
|
|
382
|
+
// 先让 once('close') 注册,再在 nextTick 触发(模拟用户 Ctrl+D)
|
|
383
|
+
process.nextTick(() => { for (const h of closeHandlers) h() })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const answer = queue.shift() ?? ''
|
|
387
|
+
process.nextTick(() => cb(answer))
|
|
388
|
+
},
|
|
389
|
+
once(event, cb) {
|
|
390
|
+
if (event === 'close') closeHandlers.push(cb)
|
|
391
|
+
},
|
|
392
|
+
close() {},
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 静默 console.log/error/warn,避免交互式测试刷屏;返回 restore 函数 */
|
|
397
|
+
function silenceConsole() {
|
|
398
|
+
const orig = { log: console.log, error: console.error, warn: console.warn }
|
|
399
|
+
console.log = () => {}
|
|
400
|
+
console.error = () => {}
|
|
401
|
+
console.warn = () => {}
|
|
402
|
+
return () => {
|
|
403
|
+
console.log = orig.log
|
|
404
|
+
console.error = orig.error
|
|
405
|
+
console.warn = orig.warn
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
|
|
410
|
+
const restore = silenceConsole()
|
|
411
|
+
try {
|
|
412
|
+
// 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) → apiKey → displayName(空=默认)
|
|
413
|
+
const rl = createMockRl(['1', '', '1', 'sk-test', ''])
|
|
414
|
+
const result = await collectModelInput({
|
|
415
|
+
locale: 'zh-CN',
|
|
416
|
+
rl,
|
|
417
|
+
fetchFn: mockFetch({ status: 200, ok: true }),
|
|
418
|
+
})
|
|
419
|
+
assert.ok(result, '应返回收集结果')
|
|
420
|
+
assert.equal(result.baseURL, 'https://api.openai.com/v1')
|
|
421
|
+
assert.equal(result.model, 'gpt-5.5')
|
|
422
|
+
assert.equal(result.apiKey, 'sk-test')
|
|
423
|
+
assert.equal(result.displayName, 'gpt-5.5')
|
|
424
|
+
} finally {
|
|
425
|
+
restore()
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回收集的字段', async () => {
|
|
430
|
+
const restore = silenceConsole()
|
|
431
|
+
try {
|
|
432
|
+
// 选自定义(0) → 输入 baseURL → 确认(空=默认) → 手动输入模型名 → apiKey(空) → displayName(空=默认)
|
|
433
|
+
const rl = createMockRl(['0', 'https://my.custom.api/v1', '', 'my-model', '', ''])
|
|
434
|
+
const result = await collectModelInput({
|
|
435
|
+
locale: 'zh-CN',
|
|
436
|
+
rl,
|
|
437
|
+
fetchFn: mockFetch({ status: 200, ok: true }),
|
|
438
|
+
})
|
|
439
|
+
assert.ok(result)
|
|
440
|
+
assert.equal(result.baseURL, 'https://my.custom.api/v1')
|
|
441
|
+
assert.equal(result.model, 'my-model')
|
|
442
|
+
assert.equal(result.apiKey, '')
|
|
443
|
+
assert.equal(result.displayName, 'my-model')
|
|
444
|
+
} finally {
|
|
445
|
+
restore()
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null', async () => {
|
|
450
|
+
const restore = silenceConsole()
|
|
451
|
+
try {
|
|
452
|
+
// 选 DeepSeek(3) → 确认 → 选第一个模型(1) → apiKey → displayName → 测试401失败 → 不保存(n)
|
|
453
|
+
const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'n'])
|
|
454
|
+
const result = await collectModelInput({
|
|
455
|
+
locale: 'zh-CN',
|
|
456
|
+
rl,
|
|
457
|
+
fetchFn: mockFetch({ status: 401, ok: false }),
|
|
458
|
+
})
|
|
459
|
+
assert.equal(result, null)
|
|
460
|
+
} finally {
|
|
461
|
+
restore()
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('collectModelInput: 测试失败 + 用户选择继续 → 返回收集的字段', async () => {
|
|
466
|
+
const restore = silenceConsole()
|
|
467
|
+
try {
|
|
468
|
+
const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'y'])
|
|
469
|
+
const result = await collectModelInput({
|
|
470
|
+
locale: 'zh-CN',
|
|
471
|
+
rl,
|
|
472
|
+
fetchFn: mockFetch({ status: 500, ok: false }),
|
|
473
|
+
})
|
|
474
|
+
assert.ok(result)
|
|
475
|
+
assert.equal(result.model, 'deepseek-v4-pro')
|
|
476
|
+
} finally {
|
|
477
|
+
restore()
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () => {
|
|
482
|
+
const restore = silenceConsole()
|
|
483
|
+
try {
|
|
484
|
+
// 在第 1 次 question(选服务商)时触发 close,模拟用户取消
|
|
485
|
+
const rl = createMockRl([], { closeOnQuestion: 1 })
|
|
486
|
+
const result = await collectModelInput({
|
|
487
|
+
locale: 'zh-CN',
|
|
488
|
+
rl,
|
|
489
|
+
fetchFn: mockFetch({ status: 200, ok: true }),
|
|
490
|
+
cancelMessage: '自定义取消文案',
|
|
491
|
+
})
|
|
492
|
+
assert.equal(result, null)
|
|
493
|
+
} finally {
|
|
494
|
+
restore()
|
|
495
|
+
}
|
|
496
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
// g ai 智能体的平台兼容性守卫 — 拦截"在当前 shell 里注定失败"的命令。
|
|
16
|
+
//
|
|
17
|
+
// 设计背景:
|
|
18
|
+
// Windows cmd.exe 没有 tail/head/grep/ls/sed/cat 等 Unix 命令,模型(尤其
|
|
19
|
+
// 训练数据偏 Unix 的模型)经常写出 `npm test | tail -60` 这类管道,在
|
|
20
|
+
// Windows 上直接报"不是内部或外部命令"。虽然 system prompt 已经提醒过,
|
|
21
|
+
// 但模型仍会忽略 —— 本模块在运行时做最后一道防线。
|
|
22
|
+
//
|
|
23
|
+
// 与 safety.js 的区别:
|
|
24
|
+
// - safety.js 拦截"会把系统搞崩"的命令(硬红线,所有平台)
|
|
25
|
+
// - 本模块拦截"在当前平台注定失败"的命令(软红线,仅检测 + 给替代方案)
|
|
26
|
+
// - 两者互补:safety.js 保命,platformGuard 省时间
|
|
27
|
+
//
|
|
28
|
+
// 避免误杀策略:
|
|
29
|
+
// 不靠命令名硬判,而是先 `where <cmd>` 检查命令是否真的存在于 PATH 中
|
|
30
|
+
// (很多 Windows 开发者装了 Git Bash,tail/grep 等确实可用)。只有确认
|
|
31
|
+
// 不存在时才拦截。检查结果按命令名缓存,同一会话内不重复查。
|
|
32
|
+
|
|
33
|
+
import { exec } from 'node:child_process'
|
|
34
|
+
|
|
35
|
+
// ──────────────────────────────────────────────
|
|
36
|
+
// Unix-only 命令清单 + 跨平台替代方案
|
|
37
|
+
// ──────────────────────────────────────────────
|
|
38
|
+
// key = 命令名(小写),value = 拦截时回喂给模型的替代方案说明。
|
|
39
|
+
// 只收录"Windows cmd.exe 几乎肯定没有"的命令;像 sort/find 这类 Windows
|
|
40
|
+
// 自带的(虽然行为不同)不收录,避免误判。
|
|
41
|
+
export const UNIX_ONLY_COMMANDS = {
|
|
42
|
+
tail: 'Windows cmd 没有 tail。查看输出末尾:用 PowerShell "命令 | Select-Object -Last 60";查看文件末尾用 read_file 工具(传 offset 靠后)',
|
|
43
|
+
head: 'Windows cmd 没有 head。查看输出开头:直接运行命令即可;查看文件开头用 read_file 工具(offset=1)',
|
|
44
|
+
cat: 'Windows cmd 没有 cat。查看文件用 read_file 工具,或 cmd 的 type 命令',
|
|
45
|
+
grep: 'Windows cmd 没有 grep。搜内容用 search_text 工具,或 cmd 的 findstr 命令',
|
|
46
|
+
ls: 'Windows cmd 没有 ls。列目录用 list_files 工具,或 cmd 的 dir 命令',
|
|
47
|
+
sed: 'Windows cmd 没有 sed。用 node -e "..." 或 PowerShell 做文本替换',
|
|
48
|
+
awk: 'Windows cmd 没有 awk。用 node -e "..." 或 PowerShell 处理文本',
|
|
49
|
+
wc: 'Windows cmd 没有 wc。用 node -e "..." 或 PowerShell 统计行数/字数',
|
|
50
|
+
cut: 'Windows cmd 没有 cut。用 node -e "..." 或 PowerShell 截取字段',
|
|
51
|
+
uniq: 'Windows cmd 没有 uniq。用 node -e "..." 或 PowerShell 去重',
|
|
52
|
+
xargs: 'Windows cmd 没有 xargs。用 PowerShell ForEach-Object,或改写命令逻辑',
|
|
53
|
+
which: 'Windows cmd 没有 which。用 cmd 的 where 命令查找可执行文件',
|
|
54
|
+
touch: 'Windows cmd 没有 touch。用 node -e "require(\'fs\').writeFileSync(\'file\',\'\')" 或 PowerShell "New-Item -Type File"',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ──────────────────────────────────────────────
|
|
58
|
+
// 纯函数:命令段解析(便于单测)
|
|
59
|
+
// ──────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 将命令串按管道/链式操作符切分为段。
|
|
63
|
+
* 与 safety.js 的 splitCommandSegments 逻辑一致,但独立维护(两个模块各有侧重)。
|
|
64
|
+
* @param {string} cmd
|
|
65
|
+
* @returns {string[]} 去空白后的命令段
|
|
66
|
+
*/
|
|
67
|
+
export function splitCommandSegments(cmd) {
|
|
68
|
+
return String(cmd).split(/&&|\|\||[;|]/).map(s => s.trim()).filter(Boolean)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 从一个命令段中提取命令名(第一个 token,去掉 sudo/doas 前缀)。
|
|
73
|
+
* @param {string} seg
|
|
74
|
+
* @returns {string} 小写命令名,可能为空
|
|
75
|
+
*/
|
|
76
|
+
export function extractCommandName(seg) {
|
|
77
|
+
const m = String(seg).match(/^(?:sudo|doas)\s+(\S+)|^(\S+)/)
|
|
78
|
+
const name = m?.[1] || m?.[2] || ''
|
|
79
|
+
return name.toLowerCase()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 检测命令串中出现的 Unix-only 命令(纯函数,不查 PATH)。
|
|
84
|
+
*
|
|
85
|
+
* 遍历所有段(管道/链式),返回检测到的 Unix-only 命令名列表。
|
|
86
|
+
* 调用方(如 guardCommand)再决定是否需要查 PATH 确认。
|
|
87
|
+
*
|
|
88
|
+
* @param {string} command - 完整命令行
|
|
89
|
+
* @returns {string[]} 检测到的 Unix-only 命令名(去重,保持出现顺序)
|
|
90
|
+
*/
|
|
91
|
+
export function detectUnixOnlyCommands(command) {
|
|
92
|
+
if (typeof command !== 'string' || !command.trim()) return []
|
|
93
|
+
const found = []
|
|
94
|
+
const seen = new Set()
|
|
95
|
+
for (const seg of splitCommandSegments(command)) {
|
|
96
|
+
const name = extractCommandName(seg)
|
|
97
|
+
// 去掉可能的路径前缀(如 /usr/bin/tail → tail)
|
|
98
|
+
const base = name.split(/[/\\]/).pop()
|
|
99
|
+
if (UNIX_ONLY_COMMANDS[base] && !seen.has(base)) {
|
|
100
|
+
seen.add(base)
|
|
101
|
+
found.push(base)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return found
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ──────────────────────────────────────────────
|
|
108
|
+
// 命令可用性检查(带缓存,仅 Windows 用)
|
|
109
|
+
// ──────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
// 缓存:命令名 → true(可用)/ false(不可用)。同一进程内 PATH 不变,缓存安全。
|
|
112
|
+
const _availabilityCache = new Map()
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 清空可用性缓存(测试用)。
|
|
116
|
+
*/
|
|
117
|
+
export function _clearAvailabilityCache() {
|
|
118
|
+
_availabilityCache.clear()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 在 Windows 上用 `where <cmd>` 检查命令是否存在于 PATH 中。
|
|
123
|
+
*
|
|
124
|
+
* @param {string} cmdName - 命令名(如 "tail")
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {(cmd: string, opts: object, cb: (err, stdout) => void) => void} [opts.execFn]
|
|
127
|
+
* 可注入的 exec 函数(测试用),默认用 child_process.exec
|
|
128
|
+
* @returns {Promise<boolean>} true = 命令可用;false = 不存在
|
|
129
|
+
*/
|
|
130
|
+
export async function checkCommandAvailability(cmdName, { execFn } = {}) {
|
|
131
|
+
if (_availabilityCache.has(cmdName)) return _availabilityCache.get(cmdName)
|
|
132
|
+
const _exec = execFn || exec
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
_exec(`where ${cmdName}`, { windowsHide: true }, (err) => {
|
|
135
|
+
const available = !err
|
|
136
|
+
_availabilityCache.set(cmdName, available)
|
|
137
|
+
resolve(available)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ──────────────────────────────────────────────
|
|
143
|
+
// 主接口:平台守卫
|
|
144
|
+
// ──────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 检查一条命令在当前平台是否会因命令不存在而注定失败。
|
|
148
|
+
*
|
|
149
|
+
* 仅在 Windows 上生效:检测命令中的 Unix-only 命令,逐个用 `where` 确认
|
|
150
|
+
* 是否可用;不可用的直接拦截并给出跨平台替代方案。
|
|
151
|
+
*
|
|
152
|
+
* 非 Windows 平台直接放行(Unix 命令天然可用)。
|
|
153
|
+
*
|
|
154
|
+
* @param {string} command - 待检查的完整命令行
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {string} [opts.platform=process.platform] - 平台标识(测试用)
|
|
157
|
+
* @param {(cmd: string, opts: object, cb: (err, stdout) => void) => void} [opts.execFn]
|
|
158
|
+
* 可注入的 exec 函数(测试用)
|
|
159
|
+
* @returns {Promise<{ blocked: boolean, reason: string|null }>}
|
|
160
|
+
* blocked=true 时 reason 给出中文原因 + 替代方案(会回喂给模型)
|
|
161
|
+
*/
|
|
162
|
+
export async function guardCommand(command, { platform = process.platform, execFn } = {}) {
|
|
163
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
164
|
+
return { blocked: false, reason: null }
|
|
165
|
+
}
|
|
166
|
+
// 非 Windows 平台:Unix 命令天然可用,不需要守卫
|
|
167
|
+
if (platform !== 'win32') {
|
|
168
|
+
return { blocked: false, reason: null }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const detected = detectUnixOnlyCommands(command)
|
|
172
|
+
if (detected.length === 0) {
|
|
173
|
+
return { blocked: false, reason: null }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 逐个检查检测到的 Unix-only 命令是否真的不存在于 PATH
|
|
177
|
+
const missing = []
|
|
178
|
+
for (const cmdName of detected) {
|
|
179
|
+
const available = await checkCommandAvailability(cmdName, { execFn })
|
|
180
|
+
if (!available) missing.push(cmdName)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (missing.length === 0) {
|
|
184
|
+
return { blocked: false, reason: null }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 构建拦截原因:列出每个缺失命令的替代方案
|
|
188
|
+
const hints = missing.map(name => ` • ${name}: ${UNIX_ONLY_COMMANDS[name]}`)
|
|
189
|
+
const reason = [
|
|
190
|
+
`当前 Windows 环境(cmd.exe)中不存在以下 Unix 命令,执行会失败:`,
|
|
191
|
+
...hints,
|
|
192
|
+
'',
|
|
193
|
+
`请改用上述跨平台方案或内置工具,然后重试。`,
|
|
194
|
+
`原始命令: ${command}`,
|
|
195
|
+
].join('\n')
|
|
196
|
+
|
|
197
|
+
return { blocked: true, reason }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default { guardCommand, detectUnixOnlyCommands, checkCommandAvailability, UNIX_ONLY_COMMANDS }
|