@blynkai/cli 0.1.1

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.
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { openSync } from 'node:fs'
5
+ import { mkdir } from 'node:fs/promises'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { ErrorCode } from '../core/errors.mjs'
9
+ import { logFile, pidFile, runtimeDir } from '../core/runtime-paths.mjs'
10
+ import { runBrowserLogin } from './auth-login.mjs'
11
+ import { printHelp } from './help.mjs'
12
+ import {
13
+ buildSearchBody,
14
+ getFlag,
15
+ normalizeSearchScene,
16
+ parseFlags,
17
+ parseSearchArgs,
18
+ } from './search-options.mjs'
19
+
20
+ const preferredBaseUrl = process.env.LINGRUI_AGENT_BASE_URL ?? 'http://127.0.0.1:38100'
21
+ const basePort = Number(new URL(preferredBaseUrl).port || 38100)
22
+ let resolvedBaseUrl = process.env.LINGRUI_AGENT_BASE_URL ?? null
23
+ const __dirname = dirname(fileURLToPath(import.meta.url))
24
+ const serverEntry = join(__dirname, '../server/index.mjs')
25
+ const requestTimeoutMs = Number(process.env.LINGRUI_AGENT_REQUEST_TIMEOUT_MS ?? 3000)
26
+
27
+ async function fetchJson(url, options = {}) {
28
+ const controller = new AbortController()
29
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? requestTimeoutMs)
30
+
31
+ try {
32
+ const response = await fetch(url, {
33
+ method: options.method ?? 'GET',
34
+ headers: {
35
+ 'content-type': 'application/json',
36
+ },
37
+ signal: controller.signal,
38
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
39
+ })
40
+
41
+ return response.json()
42
+ } finally {
43
+ clearTimeout(timeout)
44
+ }
45
+ }
46
+
47
+ async function rawRequest(path, options = {}) {
48
+ const baseUrl = await getBaseUrl(path)
49
+ return fetchJson(`${baseUrl}${path}`, options)
50
+ }
51
+
52
+ async function tryRequest(baseUrl, path, options = {}) {
53
+ return fetchJson(`${baseUrl}${path}`, {
54
+ ...options,
55
+ timeoutMs: options.timeoutMs ?? 800,
56
+ })
57
+ }
58
+
59
+ async function hasEndpoint(baseUrl, path) {
60
+ if (path !== '/api/search') return true
61
+
62
+ try {
63
+ const payload = await tryRequest(baseUrl, '/api/search', {
64
+ method: 'POST',
65
+ body: {
66
+ scene: 'data-center',
67
+ output: { dry_run: true },
68
+ },
69
+ })
70
+ return payload?.code !== 40400
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ async function getBaseUrl(path = '/health') {
77
+ if (resolvedBaseUrl && (await hasEndpoint(resolvedBaseUrl, path))) return resolvedBaseUrl
78
+
79
+ if (process.env.LINGRUI_AGENT_BASE_URL) {
80
+ resolvedBaseUrl = preferredBaseUrl
81
+ return resolvedBaseUrl
82
+ }
83
+
84
+ for (let port = basePort; port <= basePort + 20; port += 1) {
85
+ const candidate = `http://127.0.0.1:${port}`
86
+ try {
87
+ const payload = await tryRequest(candidate, '/health')
88
+ if (payload?.code === 0 && payload?.data?.service === 'lingrui-local-agent') {
89
+ if (await hasEndpoint(candidate, path)) {
90
+ resolvedBaseUrl = candidate
91
+ return resolvedBaseUrl
92
+ }
93
+ }
94
+ } catch {
95
+ // Try the next local port.
96
+ }
97
+ }
98
+
99
+ resolvedBaseUrl = preferredBaseUrl
100
+ return resolvedBaseUrl
101
+ }
102
+
103
+ function sleep(ms) {
104
+ return new Promise((resolve) => setTimeout(resolve, ms))
105
+ }
106
+
107
+ async function waitForBaseUrl(path = '/health') {
108
+ for (let i = 0; i < 20; i += 1) {
109
+ resolvedBaseUrl = null
110
+ const baseUrl = await getBaseUrl(path)
111
+ if (await hasEndpoint(baseUrl, path)) return baseUrl
112
+ await sleep(100)
113
+ }
114
+
115
+ return getBaseUrl(path)
116
+ }
117
+
118
+ function normalizeStatusPayload(payload) {
119
+ const data = payload?.data ?? {}
120
+ const authState = data.authState ?? 'missing'
121
+
122
+ return {
123
+ code: 0,
124
+ msg: 'ok',
125
+ data: {
126
+ service: data.service ?? 'lingrui-cli',
127
+ status: data.status ?? 'online',
128
+ loggedIn: authState === 'active',
129
+ authState,
130
+ user: data.user ?? null,
131
+ ...(data.port ? { port: data.port } : {}),
132
+ },
133
+ }
134
+ }
135
+
136
+ function getHint(msg) {
137
+ if (msg === 'auth_required') {
138
+ return '请运行 blynkai login,在浏览器中完成 CLI 授权。'
139
+ }
140
+
141
+ if (msg === 'session_revoked') {
142
+ return '当前本地会话已失效。请运行 blynkai login 重新授权。'
143
+ }
144
+
145
+ if (msg === 'local_agent_unavailable') {
146
+ return `无法连接到本地助手服务。请确认服务是否正常运行,或重试。`
147
+ }
148
+
149
+ return undefined
150
+ }
151
+
152
+ async function request(path, options = {}) {
153
+ try {
154
+ const payload = await rawRequest(path, options)
155
+ const msg = payload.msg ?? payload.message
156
+ if (payload.code !== 0) {
157
+ const hint = getHint(msg)
158
+ console.log(`\n❌ 请求失败 [错误代码: ${payload.code}]`)
159
+ if (hint) {
160
+ console.log(`💡 提示: ${hint}`)
161
+ } else {
162
+ console.log(`📄 原因: ${msg}`)
163
+ }
164
+ console.log()
165
+ process.exitCode = 1
166
+ return null
167
+ }
168
+
169
+ return payload
170
+ } catch (error) {
171
+ const msg = 'local_agent_unavailable'
172
+ const hint = getHint(msg)
173
+ console.log(`\n❌ 服务未响应 [错误代码: 50010]`)
174
+ console.log(`💡 提示: ${hint}\n`)
175
+ process.exitCode = 1
176
+ return null
177
+ }
178
+ }
179
+
180
+ async function requestStatus() {
181
+ try {
182
+ const payload = await rawRequest('/status')
183
+ if (payload.code === 0) return normalizeStatusPayload(payload)
184
+
185
+ if (payload.code === 40400 && payload.details?.path === '/status') {
186
+ const fallback = await rawRequest('/health')
187
+ if (fallback.code === 0) return normalizeStatusPayload(fallback)
188
+ return fallback
189
+ }
190
+
191
+ return payload
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ function printJson(value) {
198
+ console.log(JSON.stringify(value, null, 2))
199
+ }
200
+
201
+ function printError(code, msg, details = undefined) {
202
+ printJson({
203
+ code,
204
+ msg,
205
+ ...(details ? { details } : {}),
206
+ })
207
+ process.exitCode = 1
208
+ }
209
+
210
+ function toDisplayText(value, fallback = '-') {
211
+ if (value === undefined || value === null || value === '') return fallback
212
+ return String(value)
213
+ }
214
+
215
+ function getArticleSource(item) {
216
+ return (
217
+ item.website_name ||
218
+ item.website ||
219
+ item.media_channel ||
220
+ item.source ||
221
+ item.author ||
222
+ '-'
223
+ )
224
+ }
225
+
226
+ function formatSearchList(payload) {
227
+ const responseData = payload?.data?.result?.data
228
+ const items = Array.isArray(responseData?.items) ? responseData.items : []
229
+ const total = responseData?.total ?? items.length
230
+
231
+ if (items.length === 0) {
232
+ console.log('\n📭 未查询到结果\n')
233
+ return true
234
+ }
235
+
236
+ console.log(`\n🔎 查询结果:共 ${total} 条,当前 ${items.length} 条\n`)
237
+
238
+ for (let i = 0; i < items.length; i += 1) {
239
+ const item = items[i]
240
+ const analysis = item?.analysis || {}
241
+ const idx = i + 1
242
+ const title = toDisplayText(item?.title || item?.content, '(无标题)')
243
+ const publishTime = toDisplayText(item?.publish_time)
244
+ const pushTime = toDisplayText(item?.push_time)
245
+ const source = toDisplayText(getArticleSource(item))
246
+ const risk = toDisplayText(analysis?.risk_level || analysis?.risk_level_original)
247
+ const sentiment = toDisplayText(analysis?.sentiment || analysis?.sentiment_original)
248
+ const category = toDisplayText(analysis?.category || analysis?.category_original)
249
+ const summary = toDisplayText(analysis?.summary)
250
+ const valid = toDisplayText(analysis?.is_valid)
251
+
252
+ console.log(`${idx}. ${title}`)
253
+ console.log(` 来源: ${source} | 发布时间: ${publishTime} | 推送时间: ${pushTime}`)
254
+ console.log(` 风险: ${risk} | 情感: ${sentiment} | 分类: ${category} | 相关性: ${valid}`)
255
+ console.log(` 摘要: ${summary}`)
256
+ console.log()
257
+ }
258
+
259
+ return true
260
+ }
261
+
262
+ async function runDoctor() {
263
+ const baseUrl = await getBaseUrl('/api/search')
264
+ const report = {
265
+ code: 0,
266
+ msg: 'ok',
267
+ data: {
268
+ now: new Date().toISOString(),
269
+ baseUrl,
270
+ env: {
271
+ LINGRUI_AGENT_BASE_URL: process.env.LINGRUI_AGENT_BASE_URL ?? '',
272
+ LINGRUI_UPSTREAM_BASE_URL: process.env.LINGRUI_UPSTREAM_BASE_URL ?? '',
273
+ NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS ?? '',
274
+ NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED ?? '',
275
+ },
276
+ checks: {
277
+ health: null,
278
+ whoami: null,
279
+ upstreamReachability: null,
280
+ },
281
+ },
282
+ }
283
+
284
+ try {
285
+ report.data.checks.health = await tryRequest(baseUrl, '/health', { timeoutMs: 2000 })
286
+ } catch (error) {
287
+ report.data.checks.health = {
288
+ code: 50010,
289
+ msg: 'local_agent_unavailable',
290
+ error: error instanceof Error ? error.message : String(error),
291
+ }
292
+ }
293
+
294
+ try {
295
+ report.data.checks.whoami = await tryRequest(baseUrl, '/auth/whoami', { timeoutMs: 2000 })
296
+ } catch (error) {
297
+ report.data.checks.whoami = {
298
+ code: 50010,
299
+ msg: 'local_agent_unavailable',
300
+ error: error instanceof Error ? error.message : String(error),
301
+ }
302
+ }
303
+
304
+ const upstreamBaseUrl = report.data.checks.health?.data?.upstreamBaseUrl
305
+ if (upstreamBaseUrl) {
306
+ try {
307
+ const response = await fetch(`${upstreamBaseUrl}/v1/article/search`, {
308
+ method: 'POST',
309
+ headers: { 'content-type': 'application/json' },
310
+ body: '{}',
311
+ })
312
+ report.data.checks.upstreamReachability = {
313
+ ok: true,
314
+ status: response.status,
315
+ }
316
+ } catch (error) {
317
+ report.data.checks.upstreamReachability = {
318
+ ok: false,
319
+ message: error instanceof Error ? error.message : String(error),
320
+ cause:
321
+ error && typeof error === 'object' && 'cause' in error && error.cause instanceof Error
322
+ ? error.cause.message
323
+ : '',
324
+ }
325
+ }
326
+ } else {
327
+ report.data.checks.upstreamReachability = {
328
+ ok: false,
329
+ message: 'upstream_not_configured',
330
+ }
331
+ }
332
+
333
+ printJson(report)
334
+ }
335
+
336
+ async function main() {
337
+ const [command, subCommand, ...restArgs] = process.argv.slice(2)
338
+
339
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
340
+ printHelp()
341
+ return
342
+ }
343
+
344
+ if (command === 'agent' && subCommand === 'status') {
345
+ const payload = await request('/health')
346
+ if (payload) printJson(payload)
347
+ return
348
+ }
349
+
350
+ if (command === 'agent' && subCommand === 'start') {
351
+ await startAgent()
352
+ return
353
+ }
354
+
355
+ if (command === 'login' || (command === 'auth' && subCommand === 'login')) {
356
+ await runBrowserLogin()
357
+ return
358
+ }
359
+
360
+ if (command === 'status') {
361
+ const payload = await requestStatus()
362
+ if (!payload) {
363
+ console.log(`\n❌ 状态异常 [错误代码: 50010]`)
364
+ console.log(`💡 提示: ${getHint('local_agent_unavailable')}\n`)
365
+ process.exitCode = 1
366
+ return
367
+ }
368
+
369
+ if (payload.code !== 0) {
370
+ const hint = getHint(payload.msg ?? payload.message)
371
+ console.log(`\n❌ 状态异常 [错误代码: ${payload.code}]`)
372
+ if (hint) console.log(`💡 提示: ${hint}`)
373
+ console.log()
374
+ process.exitCode = 1
375
+ return
376
+ }
377
+
378
+ console.log(`\n🟢 本地助手运行状态:正常`)
379
+ console.log(`服务标识:${payload.data.service}`)
380
+ console.log(`登录状态:${payload.data.loggedIn ? '✅ 已登录' : '❌ 未登录'}`)
381
+ if (payload.data.loggedIn && payload.data.user) {
382
+ console.log(`当前账号:${payload.data.user.nickname || payload.data.user.username}`)
383
+ }
384
+ console.log()
385
+ return
386
+ }
387
+
388
+ if (command === 'health') {
389
+ const payload = await request('/health')
390
+ if (payload) printJson(payload)
391
+ return
392
+ }
393
+
394
+ if (command === 'doctor' || command === 'diag') {
395
+ await runDoctor()
396
+ return
397
+ }
398
+
399
+ if (command === 'whoami') {
400
+ const payload = await request('/auth/whoami')
401
+ if (payload && payload.data && payload.data.user) {
402
+ const user = payload.data.user
403
+ console.log(`\n👤 当前登录用户信息:`)
404
+ console.log(`-----------------------------------`)
405
+ console.log(`账号名:${user.username}`)
406
+ console.log(`昵 称:${user.nickname}`)
407
+ console.log(`机 构:${user.institution || '无'}`)
408
+ console.log(`角 色:${user.role === 'admin' ? '管理员' : '普通用户'}`)
409
+ if (payload.data.expiresAt) {
410
+ console.log(`过 期:${new Date(payload.data.expiresAt).toLocaleString('zh-CN')}`)
411
+ }
412
+ console.log(`-----------------------------------\n`)
413
+ }
414
+ return
415
+ }
416
+
417
+ if (command === 'list') {
418
+ if (!subCommand) {
419
+ const payload = await request('/commands/list')
420
+ if (payload) printJson(payload)
421
+ return
422
+ }
423
+
424
+ if (subCommand === 'datacenter' || subCommand === 'fengxian') {
425
+ const auth = await requestStatus()
426
+ if (!auth) return
427
+ if (!auth?.data?.loggedIn) {
428
+ printError(ErrorCode.AUTH_REQUIRED, 'auth_required', {
429
+ hint: 'Please sign in to Lingrui in your browser first.',
430
+ })
431
+ return
432
+ }
433
+
434
+ const flags = parseFlags(restArgs)
435
+ printError(ErrorCode.NOT_IMPLEMENTED, 'not_implemented', {
436
+ command: `list ${subCommand}`,
437
+ params: flags,
438
+ note: 'Business API is not connected yet.',
439
+ })
440
+ return
441
+ }
442
+
443
+ printError(ErrorCode.INVALID_PARAMS, 'invalid_params', {
444
+ command: 'list',
445
+ supported: ['datacenter', 'fengxian'],
446
+ })
447
+ return
448
+ }
449
+
450
+ if (command === 'auth' && subCommand === 'logout') {
451
+ const payload = await request('/auth/logout', { method: 'POST' })
452
+ if (payload) printJson(payload)
453
+ return
454
+ }
455
+
456
+ if (command === 'logout') {
457
+ const payload = await request('/auth/logout', { method: 'POST' })
458
+ if (payload) {
459
+ console.log(`\n👋 已成功退出登录!\n`)
460
+ }
461
+ return
462
+ }
463
+
464
+ if (command === 'search') {
465
+ const auth = await requestStatus()
466
+ if (!auth) return
467
+ if (!auth?.data?.loggedIn) {
468
+ printError(ErrorCode.AUTH_REQUIRED, 'auth_required', {
469
+ hint: 'Please sign in to Lingrui in your browser first.',
470
+ })
471
+ return
472
+ }
473
+
474
+ const legacyScene = normalizeSearchScene(subCommand)
475
+ const isLegacyScene = ['data-center', 'warning-center', 'website-stats'].includes(legacyScene)
476
+ const searchArgs = isLegacyScene ? restArgs : [subCommand, ...restArgs].filter(Boolean)
477
+ const { flags, terms } = parseSearchArgs(searchArgs)
478
+
479
+ if (isLegacyScene) {
480
+ if (legacyScene === 'warning-center') flags.center = flags.center ?? 'warning'
481
+ if (legacyScene === 'data-center') flags.center = flags.center ?? 'data'
482
+ if (legacyScene === 'website-stats') flags.stats = flags.stats ?? 'website'
483
+ }
484
+
485
+ if (!getFlag(flags, 'q', 'keywords', 'keyword', 'kw', 'k') && terms.length > 0) {
486
+ flags.q = terms.join(' ')
487
+ }
488
+ const body = buildSearchBody('data-center', flags)
489
+ const payload = await request('/api/search', {
490
+ method: 'POST',
491
+ body,
492
+ })
493
+ if (!payload) return
494
+
495
+ const wantsJson = ['1', 'true', 'yes'].includes(
496
+ String(process.env.LINGRUI_CLI_JSON || '').toLowerCase(),
497
+ )
498
+ if (!wantsJson && payload?.data?.result?.data?.items) {
499
+ formatSearchList(payload)
500
+ } else {
501
+ printJson(payload)
502
+ }
503
+ return
504
+ }
505
+
506
+ console.log(`\n❌ 未知命令: ${command}`)
507
+ console.log(`💡 输入 'blynkai help' 查看支持的命令列表。\n`)
508
+ process.exitCode = 1
509
+ }
510
+
511
+ async function startAgent() {
512
+ try {
513
+ const baseUrl = await getBaseUrl('/api/search')
514
+ if (await hasEndpoint(baseUrl, '/api/search')) {
515
+ const payload = await tryRequest(baseUrl, '/health')
516
+ printJson({
517
+ code: 0,
518
+ msg: 'ok',
519
+ data: {
520
+ status: 'already_running',
521
+ health: payload.data,
522
+ baseUrl,
523
+ },
524
+ })
525
+ return
526
+ }
527
+ } catch {
528
+ // Start a fresh local agent below.
529
+ }
530
+
531
+ await mkdir(runtimeDir, { recursive: true })
532
+ const logFd = openSync(logFile, 'a')
533
+ const child = spawn(process.execPath, [serverEntry], {
534
+ detached: true,
535
+ stdio: ['ignore', logFd, logFd],
536
+ env: process.env,
537
+ })
538
+ child.unref()
539
+
540
+ resolvedBaseUrl = null
541
+ printJson({
542
+ code: 0,
543
+ msg: 'ok',
544
+ data: {
545
+ status: 'started',
546
+ baseUrl: await waitForBaseUrl('/api/search'),
547
+ pid: child.pid,
548
+ pidFile,
549
+ logFile,
550
+ },
551
+ })
552
+ }
553
+
554
+ main()