dsh-workbuddy2api-hub 0.0.0-stage → 1.0.7
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/CHANGELOG.md +128 -0
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/client/client.js +1354 -0
- package/cordis.patch.yml +10 -0
- package/docs/MAINTAINER.md +116 -0
- package/lib/hub.js +827 -0
- package/lib/index.js +1671 -0
- package/package.json +93 -4
- package/scripts/check-secrets.mjs +86 -0
- package/scripts/install-local.mjs +182 -0
- package/scripts/mock-hub.mjs +493 -0
- package/scripts/release-notes.mjs +53 -0
- package/scripts/release.mjs +107 -0
- package/scripts/smoke-live.mjs +280 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* workbuddy2api-hub 的本地替身,只实现本插件用到的那几个接口。
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有个 mock:真实 hub 是个 Python 服务(`wb_proxy.py`,`Server: wb-proxy/x.y.z`),
|
|
6
|
+
* 测试里既不能假设它开着,也不能让它 5 次/60 秒的登录限流把跑测试的人锁掉。这里按
|
|
7
|
+
* 实测到的响应形状伪造一份,于是「地址错了 / 密码错了 / 被限流 / 没 API Key / 没有模型」
|
|
8
|
+
* 这些分支都能在 CI 里稳定复现。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* node scripts/mock-hub.mjs --port 8799
|
|
12
|
+
* node scripts/mock-hub.mjs --fail-auth # 密码永远不对(401)
|
|
13
|
+
* node scripts/mock-hub.mjs --no-api-key # 面板里没有可用 API Key
|
|
14
|
+
* node scripts/mock-hub.mjs --empty # 模型列表为空
|
|
15
|
+
* node scripts/mock-hub.mjs --slow 3000 # 每个请求延迟 3 秒(测超时)
|
|
16
|
+
* node scripts/mock-hub.mjs --no-panel-auth # 面板不要求密码
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createServer } from 'node:http'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 解析命令行参数。
|
|
23
|
+
* @param argv - `process.argv.slice(2)`。
|
|
24
|
+
* @returns 选项对象。
|
|
25
|
+
*/
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const options = {
|
|
28
|
+
port: 8799,
|
|
29
|
+
password: 'admin',
|
|
30
|
+
failAuth: false,
|
|
31
|
+
noApiKey: false,
|
|
32
|
+
empty: false,
|
|
33
|
+
noPanelAuth: false,
|
|
34
|
+
slowMs: 0,
|
|
35
|
+
quiet: false,
|
|
36
|
+
apiKey: 'mock-api-key-0001',
|
|
37
|
+
}
|
|
38
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
39
|
+
const arg = argv[index]
|
|
40
|
+
if (arg === '--port') options.port = Number(argv[(index += 1)]) || options.port
|
|
41
|
+
else if (arg === '--password') options.password = argv[(index += 1)] ?? options.password
|
|
42
|
+
else if (arg === '--fail-auth') options.failAuth = true
|
|
43
|
+
else if (arg === '--no-api-key') options.noApiKey = true
|
|
44
|
+
else if (arg === '--empty') options.empty = true
|
|
45
|
+
else if (arg === '--no-panel-auth') options.noPanelAuth = true
|
|
46
|
+
else if (arg === '--slow') options.slowMs = Number(argv[(index += 1)]) || 0
|
|
47
|
+
else if (arg === '--quiet') options.quiet = true
|
|
48
|
+
}
|
|
49
|
+
return options
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 与真实 hub 一致的模型条目形状(`/v1/models` 的 data 元素)。 */
|
|
53
|
+
function modelList() {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
id: 'hy4-preview',
|
|
57
|
+
object: 'model',
|
|
58
|
+
owned_by: 'workbuddy',
|
|
59
|
+
name: 'Hy4 preview',
|
|
60
|
+
description: 'Mock flagship model',
|
|
61
|
+
capabilities: { vision: true, tool_calls: true, reasoning: true },
|
|
62
|
+
supports_vision: true,
|
|
63
|
+
supports_images: true,
|
|
64
|
+
supports_tool_calls: true,
|
|
65
|
+
supports_reasoning: true,
|
|
66
|
+
input_modalities: ['text', 'image'],
|
|
67
|
+
context_length: 960000,
|
|
68
|
+
max_input_tokens: 960000,
|
|
69
|
+
max_output_tokens: 64000,
|
|
70
|
+
max_completion_tokens: 64000,
|
|
71
|
+
context_windows: [300000, 600000, 960000],
|
|
72
|
+
reasoning_efforts: ['high'],
|
|
73
|
+
reasoning_default_effort: 'high',
|
|
74
|
+
reasoning_can_disable: false,
|
|
75
|
+
always_reasoning: true,
|
|
76
|
+
credits: 'x0.29',
|
|
77
|
+
vendor: 'j',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'hy3',
|
|
81
|
+
object: 'model',
|
|
82
|
+
name: 'Hy3',
|
|
83
|
+
capabilities: { vision: true, tool_calls: true, reasoning: true },
|
|
84
|
+
supports_images: true,
|
|
85
|
+
supports_tool_calls: true,
|
|
86
|
+
supports_reasoning: true,
|
|
87
|
+
context_length: 192000,
|
|
88
|
+
max_output_tokens: 64000,
|
|
89
|
+
reasoning_efforts: ['low', 'high'],
|
|
90
|
+
reasoning_default_effort: 'high',
|
|
91
|
+
reasoning_can_disable: true,
|
|
92
|
+
credits: 'x0.15',
|
|
93
|
+
vendor: 'j',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'deepseek-v4.1-flash',
|
|
97
|
+
object: 'model',
|
|
98
|
+
name: 'DeepSeek V4.1 Flash',
|
|
99
|
+
capabilities: { vision: false, tool_calls: true, reasoning: false },
|
|
100
|
+
supports_images: false,
|
|
101
|
+
supports_tool_calls: true,
|
|
102
|
+
supports_reasoning: false,
|
|
103
|
+
input_modalities: ['text'],
|
|
104
|
+
context_length: 1000000,
|
|
105
|
+
max_output_tokens: 32000,
|
|
106
|
+
credits: 'x0.08',
|
|
107
|
+
vendor: 'deepseek',
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 本周一 00:00 的本地时间戳串(与真实 hub 的 `range=week` 口径一致:
|
|
114
|
+
* `_local_midnight(days_back=tm_wday)`,即日历周、周一起算,不是「近 7 天」)。
|
|
115
|
+
*/
|
|
116
|
+
function weekStartStamp() {
|
|
117
|
+
const now = new Date()
|
|
118
|
+
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - ((now.getDay() + 6) % 7))
|
|
119
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
120
|
+
return `${monday.getFullYear()}-${pad(monday.getMonth() + 1)}-${pad(monday.getDate())} 00:00:00`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 起一个 HTTP 服务。
|
|
125
|
+
* @param options - {@link parseArgs} 的结果。
|
|
126
|
+
* @returns node http server。
|
|
127
|
+
*/
|
|
128
|
+
export function createMockHub(options = {}) {
|
|
129
|
+
const config = { port: 0, password: 'admin', apiKey: 'mock-api-key-0001', ...options }
|
|
130
|
+
const state = {
|
|
131
|
+
models: config.empty === true ? [] : modelList(),
|
|
132
|
+
apiKeyPresent: config.noApiKey !== true,
|
|
133
|
+
balance: 3712.57,
|
|
134
|
+
/** 面板 Token → 签发时间。 */
|
|
135
|
+
tokens: new Map(),
|
|
136
|
+
/** 失败登录时间戳,用来模拟 5 次/60 秒的限流。 */
|
|
137
|
+
failures: [],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const server = createServer((request, response) => {
|
|
141
|
+
const delay = Number(config.slowMs) || 0
|
|
142
|
+
const run = () => handle(request, response, config, state)
|
|
143
|
+
if (delay > 0) setTimeout(run, delay)
|
|
144
|
+
else run()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
server.on('clientError', (_error, socket) => {
|
|
148
|
+
// 客户端提前断开(比如插件超时 abort)不该把 mock 弄挂。
|
|
149
|
+
try {
|
|
150
|
+
socket.destroy()
|
|
151
|
+
} catch {
|
|
152
|
+
// 已经关了。
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return server
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 写 JSON 响应。 */
|
|
160
|
+
function send(response, status, payload, headers = {}) {
|
|
161
|
+
const text = JSON.stringify(payload)
|
|
162
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers })
|
|
163
|
+
response.end(text)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 读请求体。 */
|
|
167
|
+
async function readBody(request) {
|
|
168
|
+
const chunks = []
|
|
169
|
+
for await (const chunk of request) chunks.push(chunk)
|
|
170
|
+
if (chunks.length === 0) return {}
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
173
|
+
} catch {
|
|
174
|
+
return {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 一个请求的完整处理。
|
|
180
|
+
* @param request - node http 请求。
|
|
181
|
+
* @param response - node http 响应。
|
|
182
|
+
* @param config - 配置。
|
|
183
|
+
* @param state - 可变状态(测试可以改它来模拟 hub 侧变化)。
|
|
184
|
+
*/
|
|
185
|
+
async function handle(request, response, config, state) {
|
|
186
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
187
|
+
const path = url.pathname
|
|
188
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
189
|
+
|
|
190
|
+
/* -------------------------- mock 控制接口 -------------------------- */
|
|
191
|
+
|
|
192
|
+
if (path === '/mock/state') {
|
|
193
|
+
send(response, 200, {
|
|
194
|
+
models: state.models.map((model) => model.id),
|
|
195
|
+
apiKeyPresent: state.apiKeyPresent,
|
|
196
|
+
balance: state.balance,
|
|
197
|
+
tokens: state.tokens.size,
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (path === '/mock/reset') {
|
|
202
|
+
state.models = config.empty === true ? [] : modelList()
|
|
203
|
+
state.apiKeyPresent = config.noApiKey !== true
|
|
204
|
+
state.balance = 3712.57
|
|
205
|
+
state.tokens.clear()
|
|
206
|
+
state.failures.length = 0
|
|
207
|
+
send(response, 200, { ok: true })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (path === '/mock/models/remove') {
|
|
211
|
+
const id = url.searchParams.get('id') ?? ''
|
|
212
|
+
state.models = state.models.filter((model) => model.id !== id)
|
|
213
|
+
send(response, 200, { ok: true, models: state.models.map((model) => model.id) })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (path === '/mock/models/add') {
|
|
217
|
+
const id = url.searchParams.get('id') ?? 'mock-new-model'
|
|
218
|
+
state.models.push({
|
|
219
|
+
id,
|
|
220
|
+
object: 'model',
|
|
221
|
+
name: id,
|
|
222
|
+
supports_images: false,
|
|
223
|
+
supports_tool_calls: true,
|
|
224
|
+
supports_reasoning: false,
|
|
225
|
+
input_modalities: ['text'],
|
|
226
|
+
context_length: 128000,
|
|
227
|
+
max_output_tokens: 16000,
|
|
228
|
+
credits: 'x0.5',
|
|
229
|
+
})
|
|
230
|
+
send(response, 200, { ok: true, models: state.models.map((model) => model.id) })
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (path === '/mock/credits') {
|
|
234
|
+
state.balance = Number(url.searchParams.get('balance') ?? '12.5')
|
|
235
|
+
send(response, 200, { ok: true, balance: state.balance })
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (path === '/mock/keys') {
|
|
239
|
+
state.apiKeyPresent = url.searchParams.get('present') !== '0'
|
|
240
|
+
send(response, 200, { ok: true, present: state.apiKeyPresent })
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ----------------------------- 公开接口 ---------------------------- */
|
|
245
|
+
|
|
246
|
+
if (path === '/health') {
|
|
247
|
+
send(response, 200, {
|
|
248
|
+
ok: true,
|
|
249
|
+
realm: 'cn',
|
|
250
|
+
accounts: 2,
|
|
251
|
+
accounts_ready: 2,
|
|
252
|
+
// 真实 hub 的 /health 里这个字段恒为 true:它表达的是「/v1 需要 API Key」,
|
|
253
|
+
// 与「现在配了几个 Key」无关(实测 api_key_set:false 时它依然是 true)。
|
|
254
|
+
api_key_required: true,
|
|
255
|
+
})
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (path === '/panel/status') {
|
|
260
|
+
const presented = request.headers['x-panel-token']
|
|
261
|
+
send(response, 200, {
|
|
262
|
+
panel_password_required: config.noPanelAuth !== true,
|
|
263
|
+
panel_password_is_default: config.password === 'admin',
|
|
264
|
+
authenticated: typeof presented === 'string' && state.tokens.has(presented),
|
|
265
|
+
api_key_set: state.apiKeyPresent,
|
|
266
|
+
})
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (path === '/panel/login' && method === 'POST') {
|
|
271
|
+
if (config.failAuth === true) {
|
|
272
|
+
state.failures.push(Date.now())
|
|
273
|
+
send(response, 401, { ok: false, error: 'invalid panel password' })
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const body = await readBody(request)
|
|
277
|
+
const now = Date.now()
|
|
278
|
+
// 真实 hub 的限流:60 秒内 5 次失败 → 429。先判限流再判密码,与实测一致。
|
|
279
|
+
state.failures = state.failures.filter((at) => now - at < 60000)
|
|
280
|
+
if (state.failures.length >= 5) {
|
|
281
|
+
const wait = Math.max(1, Math.ceil((60000 - (now - state.failures[0])) / 1000))
|
|
282
|
+
send(response, 429, { ok: false, error: `too many login attempts, please wait ${wait}s` })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
if (typeof body.password !== 'string' || body.password !== config.password) {
|
|
286
|
+
state.failures.push(now)
|
|
287
|
+
send(response, 401, { ok: false, error: 'invalid panel password' })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
const token = `mock-token-${state.tokens.size + 1}-${Math.random().toString(36).slice(2, 10)}`
|
|
291
|
+
state.tokens.set(token, now)
|
|
292
|
+
send(response, 200, { ok: true, token, using_default_password: config.password === 'admin' })
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (path === '/panel/logout' && method === 'POST') {
|
|
297
|
+
const token = String(request.headers['x-panel-token'] ?? '')
|
|
298
|
+
state.tokens.delete(token)
|
|
299
|
+
send(response, 200, { ok: true })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ------------------------------ 面板接口 --------------------------- */
|
|
304
|
+
|
|
305
|
+
const token = String(request.headers['x-panel-token'] ?? '')
|
|
306
|
+
const authenticated = config.noPanelAuth === true || state.tokens.has(token)
|
|
307
|
+
const bearer = String(request.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 需要鉴权的接口。真实 hub 的 `_key_ok()` 第一行是 `_panel_ok()`,
|
|
311
|
+
* 所以面板 Token 能过;`Authorization: Bearer` 只认 API Key。
|
|
312
|
+
*/
|
|
313
|
+
const requireAccess = () => {
|
|
314
|
+
if (authenticated) return true
|
|
315
|
+
if (bearer !== '' && bearer === config.apiKey) return true
|
|
316
|
+
send(response, 401, { ok: false, error: 'panel password required' })
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (path === '/v1/models') {
|
|
321
|
+
if (!requireAccess()) return
|
|
322
|
+
send(response, 200, { object: 'list', data: state.models })
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (path === '/v1/balance-summary') {
|
|
327
|
+
if (!requireAccess()) return
|
|
328
|
+
send(response, 200, {
|
|
329
|
+
balance: state.balance,
|
|
330
|
+
unit: 'credit',
|
|
331
|
+
realm: 'cn',
|
|
332
|
+
accounts_enabled: 2,
|
|
333
|
+
accounts_with_credit_data: 2,
|
|
334
|
+
updated_at: Date.now() / 1000,
|
|
335
|
+
})
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (path === '/v1/chat/completions') {
|
|
340
|
+
if (!requireAccess()) return
|
|
341
|
+
send(response, 200, {
|
|
342
|
+
id: 'mock-completion',
|
|
343
|
+
object: 'chat.completion',
|
|
344
|
+
choices: [{ index: 0, message: { role: 'assistant', content: 'pong' }, finish_reason: 'stop' }],
|
|
345
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
346
|
+
})
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (path === '/usage') {
|
|
351
|
+
if (!requireAccess()) return
|
|
352
|
+
// 真实 hub 只认 `range`(today/week/month,未知值=不过滤),`hours` 被忽略。
|
|
353
|
+
// mock 照着做:today 返今日子集、week 返本周子集、其余返全量,方便测试区分。
|
|
354
|
+
const range = String(url.searchParams.get('range') ?? '').trim().toLowerCase()
|
|
355
|
+
const today = range === 'today' || range === 'day' || range === '1d'
|
|
356
|
+
const week = range === 'week' || range === 'w'
|
|
357
|
+
const totals = today
|
|
358
|
+
? { requests: 441, errors: 9, prompt_tokens: 42100000, completion_tokens: 380000,
|
|
359
|
+
reasoning_tokens: 210000, cached_tokens: 39000000, total_tokens: 42765524,
|
|
360
|
+
credit: 40.92, cost_cny: 6.2451 }
|
|
361
|
+
: week
|
|
362
|
+
? { requests: 1207, errors: 21, prompt_tokens: 118300000, completion_tokens: 960000,
|
|
363
|
+
reasoning_tokens: 480000, cached_tokens: 112000000, total_tokens: 119260000,
|
|
364
|
+
credit: 118.44, cost_cny: 17.8802 }
|
|
365
|
+
: { requests: 1813, errors: 38, prompt_tokens: 187393318, completion_tokens: 1493328,
|
|
366
|
+
reasoning_tokens: 753366, cached_tokens: 179497887, total_tokens: 188886646,
|
|
367
|
+
credit: 405.25, cost_cny: 20.968 }
|
|
368
|
+
const byModel = today
|
|
369
|
+
? {
|
|
370
|
+
'hy4-preview': { requests: 300, credit: 32.4, cost_cny: 4.6, total_tokens: 30000000 },
|
|
371
|
+
hy3: { requests: 141, credit: 8.52, cost_cny: 1.6451, total_tokens: 12765524 },
|
|
372
|
+
}
|
|
373
|
+
: week
|
|
374
|
+
? {
|
|
375
|
+
'hy4-preview': { requests: 820, credit: 92.1, cost_cny: 13.9, total_tokens: 90000000 },
|
|
376
|
+
hy3: { requests: 387, credit: 26.34, cost_cny: 3.9802, total_tokens: 29260000 },
|
|
377
|
+
}
|
|
378
|
+
: {
|
|
379
|
+
'hy4-preview': { requests: 1200, credit: 300.5, cost_cny: 15.2, total_tokens: 150000000 },
|
|
380
|
+
hy3: { requests: 613, credit: 104.75, cost_cny: 5.768, total_tokens: 38886646 },
|
|
381
|
+
}
|
|
382
|
+
send(response, 200, {
|
|
383
|
+
...totals,
|
|
384
|
+
started: Date.now() / 1000 - 3600,
|
|
385
|
+
since: today
|
|
386
|
+
? new Date().toISOString().slice(0, 10) + ' 00:00:00'
|
|
387
|
+
: week
|
|
388
|
+
? weekStartStamp()
|
|
389
|
+
: '2026-10-23 14:56:58',
|
|
390
|
+
by_model: byModel,
|
|
391
|
+
})
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (path === '/accounts') {
|
|
396
|
+
if (!requireAccess()) return
|
|
397
|
+
send(response, 200, {
|
|
398
|
+
accounts: [
|
|
399
|
+
{
|
|
400
|
+
uid: 'mock-uid-1',
|
|
401
|
+
nickname: '🐾',
|
|
402
|
+
domain: 'www.codebuddy.cn',
|
|
403
|
+
realm: 'cn',
|
|
404
|
+
platform: 'CLI',
|
|
405
|
+
product: 'workbuddy',
|
|
406
|
+
enabled: true,
|
|
407
|
+
tasksEnabled: true,
|
|
408
|
+
weight: 1,
|
|
409
|
+
disabledModels: [],
|
|
410
|
+
source: 'oauth',
|
|
411
|
+
expiresAt: Math.floor(Date.now() / 1000) + 27 * 86400,
|
|
412
|
+
expiresIn: '27 days',
|
|
413
|
+
tokenExpired: false,
|
|
414
|
+
needRelogin: false,
|
|
415
|
+
inCooldown: false,
|
|
416
|
+
credits: { remain: 1347.33, used: 665.67, size: 2013 },
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
uid: 'mock-uid-2',
|
|
420
|
+
nickname: 'second',
|
|
421
|
+
realm: 'cn',
|
|
422
|
+
platform: 'CLI',
|
|
423
|
+
enabled: true,
|
|
424
|
+
expiresAt: Math.floor(Date.now() / 1000) + 2 * 86400,
|
|
425
|
+
expiresIn: '2 days',
|
|
426
|
+
tokenExpired: false,
|
|
427
|
+
needRelogin: false,
|
|
428
|
+
inCooldown: false,
|
|
429
|
+
credits: { remain: 88.5 },
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
})
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (path === '/settings') {
|
|
437
|
+
if (!requireAccess()) return
|
|
438
|
+
send(response, 200, {
|
|
439
|
+
panel_password_is_default: config.password === 'admin',
|
|
440
|
+
api_key_set: state.apiKeyPresent,
|
|
441
|
+
api_keys: state.apiKeyPresent
|
|
442
|
+
? [
|
|
443
|
+
{
|
|
444
|
+
id: 'mockkey01',
|
|
445
|
+
name: 'local',
|
|
446
|
+
masked: 'mock******0001',
|
|
447
|
+
enabled: true,
|
|
448
|
+
source: 'panel',
|
|
449
|
+
created_at: '2026/10/19 11:49',
|
|
450
|
+
},
|
|
451
|
+
]
|
|
452
|
+
: [],
|
|
453
|
+
limits: {},
|
|
454
|
+
})
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (path === '/settings/reveal') {
|
|
459
|
+
if (!requireAccess()) return
|
|
460
|
+
const id = url.searchParams.get('id') ?? ''
|
|
461
|
+
if (id === '' || state.apiKeyPresent !== true) {
|
|
462
|
+
send(response, 404, { error: 'no such key' })
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
send(response, 200, { id, key: config.apiKey })
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (path === '/' || path === '/dashboard.html') {
|
|
470
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
471
|
+
response.end('<!doctype html><title>WorkBuddy 用量看板</title><p>mock dashboard</p>')
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
send(response, 404, { error: `no such endpoint: ${path}` })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* -------------------------- 命令行入口 -------------------------- */
|
|
479
|
+
|
|
480
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}` || process.argv[1]?.endsWith('mock-hub.mjs')) {
|
|
481
|
+
const options = parseArgs(process.argv.slice(2))
|
|
482
|
+
const server = createMockHub(options)
|
|
483
|
+
server.listen(options.port, '127.0.0.1', () => {
|
|
484
|
+
if (options.quiet !== true) {
|
|
485
|
+
console.log(`mock-hub listening on http://127.0.0.1:${options.port} (pwd=${options.password})`)
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
const stop = () => server.close(() => process.exit(0))
|
|
489
|
+
process.on('SIGINT', stop)
|
|
490
|
+
process.on('SIGTERM', stop)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export { parseArgs, modelList }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 从 CHANGELOG.md 抽出某个版本的正文,作为 GitHub Release 的说明。
|
|
4
|
+
*
|
|
5
|
+
* node scripts/release-notes.mjs v1.0.6 > notes.md
|
|
6
|
+
*
|
|
7
|
+
* 找不到对应小节时不报错、也不编内容:打印一句「见 CHANGELOG.md」交差,
|
|
8
|
+
* 免得一个漏写的标题把整个发布流程卡死。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
const tag = process.argv[2] ?? ''
|
|
13
|
+
const version = tag.replace(/^v/, '')
|
|
14
|
+
|
|
15
|
+
if (version === '') {
|
|
16
|
+
console.error('用法:node scripts/release-notes.mjs <tag>')
|
|
17
|
+
process.exit(2)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8')
|
|
21
|
+
const lines = changelog.split(/\r?\n/)
|
|
22
|
+
const start = lines.findIndex((line) => line.startsWith(`## [${version}]`))
|
|
23
|
+
|
|
24
|
+
if (start === -1) {
|
|
25
|
+
console.error(`CHANGELOG.md 里没有 ${version} 的小节,Release 说明将只有安装提示。`)
|
|
26
|
+
console.log(`详见 [CHANGELOG.md](https://github.com/HimA6652/dsh-workbuddy2api-hub/blob/main/CHANGELOG.md)`)
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let end = lines.findIndex((line, index) => index > start && line.startsWith('## '))
|
|
31
|
+
if (end === -1) end = lines.length
|
|
32
|
+
|
|
33
|
+
const body = lines.slice(start + 1, end).join('\n').trim()
|
|
34
|
+
|
|
35
|
+
console.log(
|
|
36
|
+
[
|
|
37
|
+
body,
|
|
38
|
+
'',
|
|
39
|
+
'---',
|
|
40
|
+
'',
|
|
41
|
+
'**安装 / 升级**',
|
|
42
|
+
'',
|
|
43
|
+
'```bash',
|
|
44
|
+
'git clone https://github.com/HimA6652/dsh-workbuddy2api-hub.git',
|
|
45
|
+
'cd dsh-workbuddy2api-hub',
|
|
46
|
+
'node scripts/install-local.mjs # 已装过就再跑一次,覆盖旧链接',
|
|
47
|
+
'```',
|
|
48
|
+
'',
|
|
49
|
+
'然后重启 DSH 桌面端。不需要 `npm install`,本包没有运行时依赖。',
|
|
50
|
+
'',
|
|
51
|
+
'升级前建议先 `npm run check`(确认仓库里没有混进凭据或本机路径)。',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 一条命令发版:改版本号 → 自检 → 测试 → 提交 → 打标签 → 推送。
|
|
4
|
+
* 推送标签后,.github/workflows/release.yml 会自动建 GitHub Release。
|
|
5
|
+
*
|
|
6
|
+
* node scripts/release.mjs 1.0.7
|
|
7
|
+
* node scripts/release.mjs 1.0.7 --dry-run # 只跑到测试为止,不提交
|
|
8
|
+
* node scripts/release.mjs 1.0.7 --no-push # 提交和打标签,但不推
|
|
9
|
+
*
|
|
10
|
+
* 前提:CHANGELOG.md 里已经写好 `## [1.0.7]` 那一节——正文就是 Release 说明,
|
|
11
|
+
* 这段必须人写(脚本不会替你编)。
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process'
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { dirname, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
19
|
+
const args = process.argv.slice(2)
|
|
20
|
+
const version = (args[0] ?? '').replace(/^v/, '')
|
|
21
|
+
const dryRun = args.includes('--dry-run')
|
|
22
|
+
const noPush = args.includes('--no-push')
|
|
23
|
+
|
|
24
|
+
function fail(message) {
|
|
25
|
+
console.error(`✖ ${message}`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function run(command, commandArgs) {
|
|
30
|
+
console.log(`$ ${command} ${commandArgs.join(' ')}`)
|
|
31
|
+
execFileSync(command, commandArgs, { cwd: ROOT, stdio: 'inherit' })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function output(command, commandArgs) {
|
|
35
|
+
return execFileSync(command, commandArgs, { cwd: ROOT, encoding: 'utf8' }).trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Windows 上 npm 是 .cmd 批处理,Node 20+ 不允许直接 execFile 它(会 ENOENT/EINVAL),
|
|
39
|
+
// 得让 shell 去解析。只有 npm 走这条路,git / node 仍走 execFile。
|
|
40
|
+
function runShell(command) {
|
|
41
|
+
console.log(`$ ${command}`)
|
|
42
|
+
execFileSync(command, { cwd: ROOT, stdio: 'inherit', shell: true })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) fail('用法:node scripts/release.mjs <major.minor.patch> [--dry-run] [--no-push]')
|
|
46
|
+
|
|
47
|
+
// 1. 版本说明必须先存在,否则发出去的 Release 是空的
|
|
48
|
+
const changelog = readFileSync(resolve(ROOT, 'CHANGELOG.md'), 'utf8')
|
|
49
|
+
if (!changelog.includes(`## [${version}]`)) {
|
|
50
|
+
fail(`CHANGELOG.md 里没有 "## [${version}]" 这一节,先把这版改了什么写进去`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. 工作区要干净,免得分不清哪些改动进了这一版
|
|
54
|
+
const dirty = output('git', ['status', '--porcelain'])
|
|
55
|
+
if (dirty !== '') fail(`工作区还有未提交的改动:\n${dirty}\n先提交或 stash,再发版`)
|
|
56
|
+
|
|
57
|
+
const branch = output('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
58
|
+
if (branch !== 'main') fail(`当前分支是 ${branch},发版要在 main 上`)
|
|
59
|
+
|
|
60
|
+
// 3. 标签不能重名
|
|
61
|
+
const tags = output('git', ['tag', '--list', `v${version}`])
|
|
62
|
+
if (tags !== '') fail(`标签 v${version} 已经存在——改完 CHANGELOG 用新版本号,或者先 git tag -d v${version}`)
|
|
63
|
+
|
|
64
|
+
// 4. 改版本号:package.json 与测试里的断言各一处
|
|
65
|
+
const pkgPath = resolve(ROOT, 'package.json')
|
|
66
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
67
|
+
const previous = pkg.version
|
|
68
|
+
if (previous !== version) {
|
|
69
|
+
writeFileSync(pkgPath, `${JSON.stringify({ ...pkg, version }, null, 2)}\n`)
|
|
70
|
+
|
|
71
|
+
const hostTestPath = resolve(ROOT, 'tests/host.test.mjs')
|
|
72
|
+
const hostTest = readFileSync(hostTestPath, 'utf8')
|
|
73
|
+
const patched = hostTest.replace(/PLUGIN_VERSION, '\d+\.\d+\.\d+'/, `PLUGIN_VERSION, '${version}'`)
|
|
74
|
+
if (patched === hostTest) fail('tests/host.test.mjs 里没找到 PLUGIN_VERSION 断言,手动改一下再跑')
|
|
75
|
+
writeFileSync(hostTestPath, patched)
|
|
76
|
+
console.log(`✔ 版本 ${previous} → ${version}`)
|
|
77
|
+
} else {
|
|
78
|
+
console.log(`✔ package.json 已经是 ${version},只补标签`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 5. 自检 + 测试:坏版本不放行
|
|
82
|
+
run('node', ['scripts/check-secrets.mjs'])
|
|
83
|
+
run('node', ['scripts/release-notes.mjs', `v${version}`])
|
|
84
|
+
runShell('npm test')
|
|
85
|
+
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
console.log('\n--dry-run:测试通过,但没提交、没打标签。工作区里的版本号改动自己决定留还是撤。')
|
|
88
|
+
process.exit(0)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 6. 提交 + 打标签 + 推送
|
|
92
|
+
run('git', ['add', '-A'])
|
|
93
|
+
run('git', ['commit', '-m', `chore(release): v${version}`])
|
|
94
|
+
run('git', ['tag', '-a', `v${version}`, '-m', `v${version}`])
|
|
95
|
+
|
|
96
|
+
if (noPush) {
|
|
97
|
+
console.log(`\n--no-push:提交与标签 v${version} 已建好,推送请手动执行`)
|
|
98
|
+
console.log(` git push && git push origin v${version}`)
|
|
99
|
+
process.exit(0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
run('git', ['push'])
|
|
103
|
+
run('git', ['push', 'origin', `v${version}`])
|
|
104
|
+
|
|
105
|
+
const repo = (pkg.repository?.url ?? 'https://github.com/HimA6652/dsh-workbuddy2api-hub.git').replace(/^git\+/, '').replace(/\.git$/, '')
|
|
106
|
+
console.log(`\n✔ 已推送 v${version}。Release 由 Actions 自动创建,稍等半分钟:`)
|
|
107
|
+
console.log(` ${repo}/releases`)
|