iivo-sub 0.1.0

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 (3) hide show
  1. package/README.md +33 -0
  2. package/bin/iivo-sub.js +854 -0
  3. package/package.json +17 -0
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # iivo-sub
2
+
3
+ Interactive quick configuration tool for IIVO internal AI gateway clients.
4
+
5
+ ```bash
6
+ npx iivo-sub
7
+ ```
8
+
9
+ Local development:
10
+
11
+ ```bash
12
+ node cli/iivo-sub/bin/iivo-sub.js
13
+ ```
14
+
15
+ The CLI asks for:
16
+
17
+ - API Key
18
+ - API Host, defaulting to `https://sub.iivo.net`
19
+ - Model
20
+ - Quick configuration scenario: OpenClaw, Hermes, Codex, or Claude Code
21
+
22
+ In an interactive terminal, press `Esc` to return to the previous step.
23
+
24
+ The main menu includes:
25
+
26
+ - Quick configuration
27
+ - Backup configuration
28
+ - Restore backup
29
+ - Exit
30
+
31
+ Backup configuration lets you choose Codex, Claude Code, Hermes, OpenClaw, or all targets. You can name the backup yourself; the default name is `<date-time>_<target>`.
32
+
33
+ Existing config files are backed up under `~/.iivo-sub/backups/` before being replaced.
@@ -0,0 +1,854 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import readline from 'node:readline'
7
+
8
+ const VERSION = '0.1.0'
9
+ const APP_DIR = path.join(os.homedir(), '.iivo-sub')
10
+ const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
11
+ let scriptedInputIndex = 0
12
+ const DEFAULT_HOST = 'https://sub.iivo.net'
13
+ const HOST_OPTIONS = [
14
+ { label: 'sub.iivo.net', value: 'https://sub.iivo.net' },
15
+ { label: 'ai.iivo.net', value: 'https://ai.iivo.net' },
16
+ { label: '手动输入 / Custom', value: '__custom__' }
17
+ ]
18
+
19
+ const MODEL_OPTIONS = [
20
+ 'gpt-5.3-codex',
21
+ 'gpt-5.3-codex-spark',
22
+ 'gpt-5.2',
23
+ 'gpt-5.5',
24
+ 'claude-sonnet-4-6',
25
+ 'claude-opus-4-6',
26
+ 'gemini-2.5-pro',
27
+ 'gemini-2.5-flash',
28
+ '手动输入 / Custom'
29
+ ]
30
+
31
+ const SCENARIOS = [
32
+ { label: 'OpenClaw', value: 'openclaw' },
33
+ { label: 'Hermes', value: 'hermes' },
34
+ { label: 'Codex', value: 'codex' },
35
+ { label: 'Claude Code', value: 'claude-code' }
36
+ ]
37
+
38
+ const MAIN_MENU = [
39
+ { label: '快速配置', value: 'quick' },
40
+ { label: '备份配置', value: 'backup' },
41
+ { label: '恢复备份', value: 'restore' },
42
+ { label: '退出', value: 'exit' }
43
+ ]
44
+
45
+ const BACKUP_TARGETS = [
46
+ { label: 'Codex', value: 'codex' },
47
+ { label: 'Claude Code', value: 'claude-code' },
48
+ { label: 'Hermes', value: 'hermes' },
49
+ { label: 'OpenClaw', value: 'openclaw' },
50
+ { label: '全部备份', value: 'all' }
51
+ ]
52
+
53
+ const RESTORE_CONFIRM_OPTIONS = [
54
+ { label: '确认恢复', value: 'yes' },
55
+ { label: '取消', value: 'no' }
56
+ ]
57
+
58
+ const colors = {
59
+ reset: '\x1b[0m',
60
+ dim: '\x1b[2m',
61
+ cyan: '\x1b[36m',
62
+ green: '\x1b[32m',
63
+ yellow: '\x1b[33m',
64
+ red: '\x1b[31m',
65
+ magenta: '\x1b[35m',
66
+ bold: '\x1b[1m'
67
+ }
68
+
69
+ function c(color, text) {
70
+ return `${colors[color] || ''}${text}${colors.reset}`
71
+ }
72
+
73
+ function clear() {
74
+ if (process.stdout.isTTY) {
75
+ process.stdout.write('\x1b[2J\x1b[0f')
76
+ }
77
+ }
78
+
79
+ function banner() {
80
+ const lines = [
81
+ `IIVO SUB 助手 v${VERSION}`,
82
+ 'AI 基站快速配置工具'
83
+ ]
84
+ const width = 42
85
+ const border = '─'.repeat(width)
86
+ console.log(c('magenta', `┌${border}┐`))
87
+ for (const line of lines) {
88
+ const visual = stringWidth(line)
89
+ const left = Math.floor((width - visual) / 2)
90
+ const right = width - visual - left
91
+ console.log(c('magenta', '│') + ' '.repeat(left) + c('bold', line) + ' '.repeat(right) + c('magenta', '│'))
92
+ }
93
+ console.log(c('magenta', `└${border}┘`))
94
+ console.log()
95
+ console.log(`中转站地址: ${c('green', DEFAULT_HOST)}`)
96
+ console.log()
97
+ }
98
+
99
+ function stringWidth(value) {
100
+ let width = 0
101
+ for (const char of [...value]) {
102
+ width += /[\u4e00-\u9fff\uff00-\uffef]/.test(char) ? 2 : 1
103
+ }
104
+ return width
105
+ }
106
+
107
+ function question(prompt, { mask = false, defaultValue = '' } = {}) {
108
+ if (SCRIPTED_INPUT) {
109
+ const answer = SCRIPTED_INPUT[scriptedInputIndex++] ?? ''
110
+ const fullPrompt = defaultValue ? `${prompt} (${defaultValue}): ` : `${prompt}: `
111
+ process.stdout.write(fullPrompt)
112
+ process.stdout.write(mask ? '********\n' : `${answer}\n`)
113
+ return Promise.resolve(answer.trim() || defaultValue)
114
+ }
115
+
116
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
117
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
118
+ const fullPrompt = defaultValue ? `${prompt} ${c('dim', `(${defaultValue})`)}: ` : `${prompt}: `
119
+ return new Promise((resolve) => {
120
+ rl.question(fullPrompt, (answer) => {
121
+ rl.close()
122
+ resolve(answer.trim() || defaultValue)
123
+ })
124
+ })
125
+ }
126
+
127
+ return interactiveQuestion(prompt, { mask, defaultValue })
128
+ }
129
+
130
+ function interactiveQuestion(prompt, { mask = false, defaultValue = '' } = {}) {
131
+ return new Promise((resolve) => {
132
+ let buffer = ''
133
+ const promptText = defaultValue ? `${prompt} ${c('dim', `(${defaultValue})`)}: ` : `${prompt}: `
134
+ const wasRaw = process.stdin.isRaw
135
+
136
+ readline.emitKeypressEvents(process.stdin)
137
+ process.stdin.setRawMode(true)
138
+ process.stdout.write(promptText)
139
+
140
+ const cleanup = (value) => {
141
+ process.stdin.off('keypress', onKey)
142
+ process.stdin.setRawMode(wasRaw)
143
+ process.stdout.write('\n')
144
+ resolve(value)
145
+ }
146
+
147
+ const onKey = (str, key) => {
148
+ if (key?.ctrl && key.name === 'c') {
149
+ cleanup('__exit__')
150
+ return
151
+ }
152
+ if (key?.name === 'escape') {
153
+ cleanup(null)
154
+ return
155
+ }
156
+ if (key?.name === 'return') {
157
+ cleanup(buffer.trim() || defaultValue)
158
+ return
159
+ }
160
+ if (key?.name === 'backspace') {
161
+ if (buffer.length > 0) {
162
+ buffer = [...buffer].slice(0, -1).join('')
163
+ process.stdout.write('\b \b')
164
+ }
165
+ return
166
+ }
167
+ if (!str || key?.name === 'tab') return
168
+
169
+ buffer += str
170
+ process.stdout.write(mask ? '*'.repeat([...str].length) : str)
171
+ }
172
+
173
+ process.stdin.on('keypress', onKey)
174
+ })
175
+ }
176
+
177
+ async function select(title, options, { allowEsc = true } = {}) {
178
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
179
+ console.log(title)
180
+ options.forEach((item, index) => console.log(`${index + 1}. ${item.label ?? item}`))
181
+ const answer = await question('请输入序号')
182
+ const index = Number(answer) - 1
183
+ const item = options[index]
184
+ return item && Object.prototype.hasOwnProperty.call(item, 'value') ? item.value : (item ?? null)
185
+ }
186
+
187
+ let index = 0
188
+ readline.emitKeypressEvents(process.stdin)
189
+ const wasRaw = process.stdin.isRaw
190
+ process.stdin.setRawMode(true)
191
+
192
+ return new Promise((resolve) => {
193
+ const render = () => {
194
+ clear()
195
+ banner()
196
+ console.log(`${title} ${c('cyan', '[使用上下方向键移动,回车确认,Esc 返回上一层]')}`)
197
+ options.forEach((item, itemIndex) => {
198
+ const label = item.label ?? item
199
+ const prefix = itemIndex === index ? c('cyan', '>') : ' '
200
+ const text = itemIndex === index ? c('cyan', label) : label
201
+ console.log(`${prefix} ${text}`)
202
+ })
203
+ }
204
+
205
+ const cleanup = (value) => {
206
+ process.stdin.off('keypress', onKey)
207
+ process.stdin.setRawMode(wasRaw)
208
+ resolve(value)
209
+ }
210
+
211
+ const onKey = (_str, key) => {
212
+ if (key.name === 'up') {
213
+ index = (index - 1 + options.length) % options.length
214
+ render()
215
+ return
216
+ }
217
+ if (key.name === 'down') {
218
+ index = (index + 1) % options.length
219
+ render()
220
+ return
221
+ }
222
+ if (key.name === 'return') {
223
+ const item = options[index]
224
+ cleanup(item && Object.prototype.hasOwnProperty.call(item, 'value') ? item.value : item)
225
+ return
226
+ }
227
+ if (allowEsc && key.name === 'escape') {
228
+ cleanup(null)
229
+ return
230
+ }
231
+ if (key.ctrl && key.name === 'c') {
232
+ cleanup('__exit__')
233
+ }
234
+ }
235
+
236
+ render()
237
+ process.stdin.on('keypress', onKey)
238
+ })
239
+ }
240
+
241
+ function normalizeHost(input) {
242
+ const trimmed = String(input || '').trim()
243
+ if (!trimmed) return DEFAULT_HOST
244
+ const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
245
+ return withProtocol.replace(/\/+$/, '')
246
+ }
247
+
248
+ function ensureDir(dir) {
249
+ fs.mkdirSync(dir, { recursive: true })
250
+ }
251
+
252
+ function fileExists(filePath) {
253
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
254
+ }
255
+
256
+ function configTargets() {
257
+ return {
258
+ codex: [
259
+ path.join(os.homedir(), '.codex', 'config.toml'),
260
+ path.join(os.homedir(), '.codex', 'auth.json'),
261
+ path.join(APP_DIR, 'codex.env'),
262
+ path.join(APP_DIR, 'codex.ps1'),
263
+ path.join(APP_DIR, 'codex.cmd')
264
+ ],
265
+ 'claude-code': [
266
+ path.join(os.homedir(), '.claude', 'settings.json'),
267
+ detectShellProfile(),
268
+ path.join(APP_DIR, 'claude-code.env'),
269
+ path.join(APP_DIR, 'claude-code.ps1'),
270
+ path.join(APP_DIR, 'claude-code.cmd')
271
+ ],
272
+ hermes: [
273
+ path.join(os.homedir(), '.hermes', 'iivo-sub.json'),
274
+ path.join(os.homedir(), '.hermes', '.env'),
275
+ path.join(APP_DIR, 'hermes.env'),
276
+ path.join(APP_DIR, 'hermes.ps1'),
277
+ path.join(APP_DIR, 'hermes.cmd')
278
+ ],
279
+ openclaw: [
280
+ path.join(os.homedir(), '.openclaw', 'iivo-sub.json'),
281
+ path.join(os.homedir(), '.openclaw', 'iivo-sub.env'),
282
+ path.join(APP_DIR, 'openclaw.env'),
283
+ path.join(APP_DIR, 'openclaw.ps1'),
284
+ path.join(APP_DIR, 'openclaw.cmd')
285
+ ]
286
+ }
287
+ }
288
+
289
+ function filesForBackupTarget(target) {
290
+ const targets = configTargets()
291
+ if (target === 'all') {
292
+ return Object.values(targets).flat()
293
+ }
294
+ return targets[target] ?? []
295
+ }
296
+
297
+ function uniqueFiles(files) {
298
+ return [...new Set(files)]
299
+ }
300
+
301
+ function backupTargetLabel(value) {
302
+ return BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
303
+ }
304
+
305
+ function timestampForName() {
306
+ const now = new Date()
307
+ const pad = (value) => String(value).padStart(2, '0')
308
+ return [
309
+ now.getFullYear(),
310
+ pad(now.getMonth() + 1),
311
+ pad(now.getDate())
312
+ ].join('-') + '_' + [
313
+ pad(now.getHours()),
314
+ pad(now.getMinutes()),
315
+ pad(now.getSeconds())
316
+ ].join('-')
317
+ }
318
+
319
+ function safeBackupName(value) {
320
+ return String(value || '')
321
+ .trim()
322
+ .replace(/[\\/]/g, '-')
323
+ .replace(/[\x00-\x1f<>:"|?*]/g, '-')
324
+ .replace(/\s+/g, '_')
325
+ .replace(/^-+|-+$/g, '')
326
+ .slice(0, 120)
327
+ }
328
+
329
+ function toBackupFileName(filePath) {
330
+ return path.relative(os.homedir(), filePath).replaceAll(path.sep, '__')
331
+ || path.basename(filePath)
332
+ }
333
+
334
+ function copyConfigToBackup(filePath, backupRoot) {
335
+ const backupFile = toBackupFileName(filePath)
336
+ const backupPath = path.join(backupRoot, 'files', backupFile)
337
+ ensureDir(path.dirname(backupPath))
338
+ fs.copyFileSync(filePath, backupPath)
339
+ return {
340
+ source: filePath,
341
+ backup: path.relative(backupRoot, backupPath)
342
+ }
343
+ }
344
+
345
+ function loadBackupManifest(dir) {
346
+ const manifestPath = path.join(dir, 'manifest.json')
347
+ if (!fileExists(manifestPath)) return null
348
+ try {
349
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
350
+ } catch {
351
+ return null
352
+ }
353
+ }
354
+
355
+ function writeFileWithBackup(filePath, content, backupRoot) {
356
+ ensureDir(path.dirname(filePath))
357
+ if (fs.existsSync(filePath)) {
358
+ const relative = path.relative(os.homedir(), filePath).replaceAll(path.sep, '__')
359
+ const backupPath = path.join(backupRoot, relative || path.basename(filePath))
360
+ ensureDir(path.dirname(backupPath))
361
+ fs.copyFileSync(filePath, backupPath)
362
+ }
363
+ fs.writeFileSync(filePath, content, 'utf8')
364
+ }
365
+
366
+ function appendManagedBlock(filePath, block, marker, backupRoot) {
367
+ ensureDir(path.dirname(filePath))
368
+ let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
369
+ if (fs.existsSync(filePath)) {
370
+ const relative = path.relative(os.homedir(), filePath).replaceAll(path.sep, '__')
371
+ fs.copyFileSync(filePath, path.join(backupRoot, relative || path.basename(filePath)))
372
+ }
373
+
374
+ const start = `# >>> ${marker} >>>`
375
+ const end = `# <<< ${marker} <<<`
376
+ const managed = `${start}\n${block.trim()}\n${end}`
377
+ const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`)
378
+ content = pattern.test(content)
379
+ ? content.replace(pattern, managed)
380
+ : `${content.replace(/\s*$/, '')}\n\n${managed}\n`
381
+ fs.writeFileSync(filePath, content, 'utf8')
382
+ }
383
+
384
+ function escapeRegExp(value) {
385
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
386
+ }
387
+
388
+ function json(value) {
389
+ return `${JSON.stringify(value, null, 2)}\n`
390
+ }
391
+
392
+ function shellExports(config) {
393
+ return Object.entries(config)
394
+ .map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
395
+ .join('\n')
396
+ }
397
+
398
+ function powershellExports(config) {
399
+ return Object.entries(config)
400
+ .map(([key, value]) => `$env:${key}=${JSON.stringify(String(value))}`)
401
+ .join('\n')
402
+ }
403
+
404
+ function cmdExports(config) {
405
+ return Object.entries(config)
406
+ .map(([key, value]) => `set ${key}=${String(value)}`)
407
+ .join('\r\n')
408
+ }
409
+
410
+ function codexToml({ host, model }) {
411
+ return `model_provider = "OpenAI"
412
+ model = "${model}"
413
+ review_model = "${model}"
414
+ model_reasoning_effort = "xhigh"
415
+ disable_response_storage = true
416
+ network_access = "enabled"
417
+ windows_wsl_setup_acknowledged = true
418
+
419
+ [model_providers.OpenAI]
420
+ name = "OpenAI"
421
+ base_url = "${host}"
422
+ wire_api = "responses"
423
+ requires_openai_auth = true
424
+
425
+ [features]
426
+ goals = true
427
+ `
428
+ }
429
+
430
+ function claudeSettings({ host, apiKey }) {
431
+ return json({
432
+ env: {
433
+ ANTHROPIC_BASE_URL: host,
434
+ ANTHROPIC_AUTH_TOKEN: apiKey,
435
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
436
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
437
+ }
438
+ })
439
+ }
440
+
441
+ function commonEnv({ host, apiKey, model }) {
442
+ const baseRoot = host.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
443
+ return {
444
+ IIVO_SUB_API_HOST: baseRoot,
445
+ IIVO_SUB_MODEL: model,
446
+ OPENAI_BASE_URL: `${baseRoot}/v1`,
447
+ OPENAI_API_KEY: apiKey,
448
+ ANTHROPIC_BASE_URL: baseRoot,
449
+ ANTHROPIC_AUTH_TOKEN: apiKey,
450
+ GOOGLE_GEMINI_BASE_URL: `${baseRoot}/v1beta`,
451
+ GEMINI_API_KEY: apiKey,
452
+ GEMINI_MODEL: model
453
+ }
454
+ }
455
+
456
+ function createPlan({ scenario, host, apiKey, model }) {
457
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
458
+ const backupRoot = path.join(APP_DIR, 'backups', timestamp)
459
+ const files = []
460
+ const baseConfig = { scenario, host, model, configured_at: new Date().toISOString() }
461
+
462
+ files.push({
463
+ path: path.join(APP_DIR, 'config.json'),
464
+ content: json({ ...baseConfig, api_key_hint: maskKey(apiKey) }),
465
+ mode: 'write'
466
+ })
467
+
468
+ if (scenario === 'codex') {
469
+ files.push({
470
+ path: path.join(os.homedir(), '.codex', 'config.toml'),
471
+ content: codexToml({ host, model }),
472
+ mode: 'write'
473
+ })
474
+ files.push({
475
+ path: path.join(os.homedir(), '.codex', 'auth.json'),
476
+ content: json({ OPENAI_API_KEY: apiKey }),
477
+ mode: 'write'
478
+ })
479
+ } else if (scenario === 'claude-code') {
480
+ files.push({
481
+ path: path.join(os.homedir(), '.claude', 'settings.json'),
482
+ content: claudeSettings({ host, apiKey }),
483
+ mode: 'write'
484
+ })
485
+ const marker = 'iivo-sub claude-code'
486
+ files.push({
487
+ path: detectShellProfile(),
488
+ content: shellExports({
489
+ ANTHROPIC_BASE_URL: host,
490
+ ANTHROPIC_AUTH_TOKEN: apiKey,
491
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
492
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
493
+ }),
494
+ mode: 'managed',
495
+ marker
496
+ })
497
+ } else if (scenario === 'openclaw') {
498
+ const env = commonEnv({ host, apiKey, model })
499
+ files.push({
500
+ path: path.join(os.homedir(), '.openclaw', 'iivo-sub.json'),
501
+ content: json({ ...baseConfig, env }),
502
+ mode: 'write'
503
+ })
504
+ files.push({
505
+ path: path.join(os.homedir(), '.openclaw', 'iivo-sub.env'),
506
+ content: `${shellExports(env)}\n`,
507
+ mode: 'write'
508
+ })
509
+ } else if (scenario === 'hermes') {
510
+ const env = commonEnv({ host, apiKey, model })
511
+ files.push({
512
+ path: path.join(os.homedir(), '.hermes', 'iivo-sub.json'),
513
+ content: json({ ...baseConfig, env }),
514
+ mode: 'write'
515
+ })
516
+ files.push({
517
+ path: path.join(os.homedir(), '.hermes', '.env'),
518
+ content: `${Object.entries(env).map(([key, value]) => `${key}=${String(value)}`).join('\n')}\n`,
519
+ mode: 'write'
520
+ })
521
+ }
522
+
523
+ files.push({
524
+ path: path.join(APP_DIR, `${scenario}.env`),
525
+ content: `${shellExports(commonEnv({ host, apiKey, model }))}\n`,
526
+ mode: 'write'
527
+ })
528
+ files.push({
529
+ path: path.join(APP_DIR, `${scenario}.ps1`),
530
+ content: `${powershellExports(commonEnv({ host, apiKey, model }))}\n`,
531
+ mode: 'write'
532
+ })
533
+ files.push({
534
+ path: path.join(APP_DIR, `${scenario}.cmd`),
535
+ content: `${cmdExports(commonEnv({ host, apiKey, model }))}\r\n`,
536
+ mode: 'write'
537
+ })
538
+
539
+ return { backupRoot, files }
540
+ }
541
+
542
+ function detectShellProfile() {
543
+ const shell = process.env.SHELL || ''
544
+ if (shell.includes('zsh')) return path.join(os.homedir(), '.zshrc')
545
+ if (shell.includes('bash')) return path.join(os.homedir(), '.bashrc')
546
+ return path.join(os.homedir(), '.profile')
547
+ }
548
+
549
+ function maskKey(key) {
550
+ if (key.length <= 8) return '********'
551
+ return `${key.slice(0, 4)}...${key.slice(-4)}`
552
+ }
553
+
554
+ async function quickConfig() {
555
+ const state = {
556
+ apiKey: '',
557
+ host: DEFAULT_HOST,
558
+ model: 'gpt-5.3-codex',
559
+ scenario: ''
560
+ }
561
+
562
+ let step = 0
563
+ while (step < 4) {
564
+ if (step === 0) {
565
+ clear()
566
+ banner()
567
+ const apiKey = await question('请输入 API Key [Esc 返回主菜单]', { mask: true })
568
+ if (apiKey === '__exit__') return '__exit__'
569
+ if (apiKey === null) return
570
+ if (!apiKey) {
571
+ console.log(c('red', 'API Key 不能为空'))
572
+ await pause()
573
+ continue
574
+ }
575
+ state.apiKey = apiKey
576
+ step = 1
577
+ continue
578
+ }
579
+
580
+ if (step === 1) {
581
+ const hostChoice = await select('请选择 API Host:', HOST_OPTIONS)
582
+ if (hostChoice === '__exit__') return '__exit__'
583
+ if (hostChoice === null) {
584
+ step = 0
585
+ continue
586
+ }
587
+ if (hostChoice === '__custom__') {
588
+ clear()
589
+ banner()
590
+ const customHost = await question('请输入 API Host [Esc 返回上一步]', { defaultValue: state.host || DEFAULT_HOST })
591
+ if (customHost === '__exit__') return '__exit__'
592
+ if (customHost === null) continue
593
+ state.host = normalizeHost(customHost)
594
+ } else {
595
+ state.host = normalizeHost(hostChoice)
596
+ }
597
+ step = 2
598
+ continue
599
+ }
600
+
601
+ if (step === 2) {
602
+ const modelChoice = await select('请选择模型:', MODEL_OPTIONS)
603
+ if (modelChoice === '__exit__') return '__exit__'
604
+ if (modelChoice === null) {
605
+ step = 1
606
+ continue
607
+ }
608
+ if (modelChoice === '手动输入 / Custom') {
609
+ clear()
610
+ banner()
611
+ const customModel = await question('请输入模型名 [Esc 返回上一步]', { defaultValue: state.model || 'gpt-5.3-codex' })
612
+ if (customModel === '__exit__') return '__exit__'
613
+ if (customModel === null) continue
614
+ state.model = String(customModel).trim()
615
+ } else {
616
+ state.model = modelChoice
617
+ }
618
+ step = 3
619
+ continue
620
+ }
621
+
622
+ const scenario = await select('请选择要快速配置的场景:', SCENARIOS)
623
+ if (scenario === '__exit__') return '__exit__'
624
+ if (scenario === null) {
625
+ step = 2
626
+ continue
627
+ }
628
+ state.scenario = scenario
629
+ step = 4
630
+ }
631
+
632
+ const { apiKey, host, model, scenario } = state
633
+ const plan = createPlan({ scenario, host, apiKey, model })
634
+ ensureDir(plan.backupRoot)
635
+ for (const file of plan.files) {
636
+ if (file.mode === 'managed') {
637
+ appendManagedBlock(file.path, file.content, file.marker, plan.backupRoot)
638
+ } else {
639
+ writeFileWithBackup(file.path, file.content, plan.backupRoot)
640
+ }
641
+ }
642
+
643
+ clear()
644
+ banner()
645
+ console.log(c('green', '配置完成。'))
646
+ console.log()
647
+ console.log(`场景: ${scenarioLabel(scenario)}`)
648
+ console.log(`API Host: ${host}`)
649
+ console.log(`模型: ${model}`)
650
+ console.log(`API Key: ${maskKey(apiKey)}`)
651
+ console.log()
652
+ console.log(c('cyan', '写入文件:'))
653
+ for (const file of plan.files) {
654
+ console.log(`- ${file.path}`)
655
+ }
656
+ console.log()
657
+ console.log(c('yellow', `备份目录: ${plan.backupRoot}`))
658
+ console.log()
659
+ console.log('临时环境变量也已生成,可按需 source:')
660
+ console.log(c('cyan', `source ${path.join(APP_DIR, `${scenario}.env`)}`))
661
+ await pause()
662
+ }
663
+
664
+ function scenarioLabel(value) {
665
+ return SCENARIOS.find((item) => item.value === value)?.label ?? value
666
+ }
667
+
668
+ async function backupConfig() {
669
+ clear()
670
+ banner()
671
+ const target = await select('请选择备份目标:', BACKUP_TARGETS)
672
+ if (target === '__exit__') return '__exit__'
673
+ if (!target) return
674
+
675
+ const defaultName = `${timestampForName()}_${target}`
676
+ clear()
677
+ banner()
678
+ const rawName = await question('请输入备份名称 [Esc 返回上一步]', { defaultValue: defaultName })
679
+ if (rawName === '__exit__') return '__exit__'
680
+ if (rawName === null) return backupConfig()
681
+
682
+ const name = safeBackupName(rawName) || defaultName
683
+ const backupRoot = path.join(APP_DIR, 'backups', name)
684
+ if (fs.existsSync(backupRoot)) {
685
+ console.log(c('red', `备份名称已存在: ${name}`))
686
+ await pause()
687
+ return backupConfig()
688
+ }
689
+
690
+ const candidates = uniqueFiles(filesForBackupTarget(target))
691
+ const existing = candidates.filter(fileExists)
692
+ if (existing.length === 0) {
693
+ console.log(c('yellow', `没有找到可备份的 ${backupTargetLabel(target)} 配置。`))
694
+ await pause()
695
+ return
696
+ }
697
+
698
+ ensureDir(backupRoot)
699
+ const files = existing.map((filePath) => copyConfigToBackup(filePath, backupRoot))
700
+ const manifest = {
701
+ name,
702
+ target,
703
+ target_label: backupTargetLabel(target),
704
+ created_at: new Date().toISOString(),
705
+ files
706
+ }
707
+ fs.writeFileSync(path.join(backupRoot, 'manifest.json'), json(manifest), 'utf8')
708
+
709
+ clear()
710
+ banner()
711
+ console.log(c('green', '备份完成。'))
712
+ console.log()
713
+ console.log(`备份名称: ${name}`)
714
+ console.log(`备份目标: ${backupTargetLabel(target)}`)
715
+ console.log(`备份目录: ${backupRoot}`)
716
+ console.log()
717
+ console.log(c('cyan', '已备份文件:'))
718
+ for (const file of files) {
719
+ console.log(`- ${file.source}`)
720
+ }
721
+ await pause()
722
+ }
723
+
724
+ async function restoreBackup() {
725
+ clear()
726
+ banner()
727
+ const backupDir = path.join(APP_DIR, 'backups')
728
+ if (!fs.existsSync(backupDir)) {
729
+ console.log(c('yellow', '暂无备份。'))
730
+ return pause()
731
+ }
732
+
733
+ const entries = fs.readdirSync(backupDir, { withFileTypes: true })
734
+ .filter((entry) => entry.isDirectory())
735
+ .map((entry) => entry.name)
736
+ .sort()
737
+ .reverse()
738
+
739
+ if (entries.length === 0) {
740
+ console.log(c('yellow', '暂无备份。'))
741
+ return pause()
742
+ }
743
+
744
+ const selected = await select('请选择要查看的备份:', [
745
+ ...entries.map((name) => ({ label: name, value: name })),
746
+ { label: '返回', value: null }
747
+ ])
748
+ if (!selected) return
749
+
750
+ clear()
751
+ banner()
752
+ const selectedDir = path.join(backupDir, selected)
753
+ const manifest = loadBackupManifest(selectedDir)
754
+ if (!manifest) {
755
+ console.log(c('cyan', `备份目录: ${selectedDir}`))
756
+ console.log()
757
+ for (const file of fs.readdirSync(selectedDir)) {
758
+ console.log(`- ${path.join(selectedDir, file)}`)
759
+ }
760
+ console.log()
761
+ console.log('这是旧格式自动备份,仅支持查看文件。需要恢复时,请手动复制对应文件覆盖目标配置。')
762
+ await pause()
763
+ return
764
+ }
765
+
766
+ console.log(c('cyan', `备份目录: ${selectedDir}`))
767
+ console.log(`备份名称: ${manifest.name || selected}`)
768
+ console.log(`备份目标: ${manifest.target_label || manifest.target || '未知'}`)
769
+ console.log(`创建时间: ${manifest.created_at || '未知'}`)
770
+ console.log()
771
+ console.log(c('cyan', '将恢复以下文件:'))
772
+ for (const file of manifest.files || []) {
773
+ console.log(`- ${file.source}`)
774
+ }
775
+ console.log()
776
+
777
+ const confirm = await select('确认恢复这个备份吗?当前同名配置会先自动备份。', RESTORE_CONFIRM_OPTIONS)
778
+ if (confirm === '__exit__') return '__exit__'
779
+ if (confirm !== 'yes') return
780
+
781
+ const safetyBackup = path.join(APP_DIR, 'backups', `${timestampForName()}_pre-restore_${safeBackupName(manifest.name || selected)}`)
782
+ ensureDir(safetyBackup)
783
+ const restored = []
784
+ for (const file of manifest.files || []) {
785
+ const source = file.source
786
+ const backupPath = path.join(selectedDir, file.backup)
787
+ if (!source || !fileExists(backupPath)) continue
788
+ if (fileExists(source)) {
789
+ copyConfigToBackup(source, safetyBackup)
790
+ }
791
+ ensureDir(path.dirname(source))
792
+ fs.copyFileSync(backupPath, source)
793
+ restored.push(source)
794
+ }
795
+
796
+ clear()
797
+ banner()
798
+ console.log(c('green', '恢复完成。'))
799
+ console.log()
800
+ for (const file of restored) {
801
+ console.log(`- ${file}`)
802
+ }
803
+ console.log()
804
+ console.log(c('yellow', `恢复前安全备份目录: ${safetyBackup}`))
805
+ await pause()
806
+ }
807
+
808
+ async function pause() {
809
+ await question('按回车继续')
810
+ }
811
+
812
+ async function main() {
813
+ ensureDir(APP_DIR)
814
+ while (true) {
815
+ const action = await select('请选择操作:', MAIN_MENU, { allowEsc: false })
816
+ if (action === '__exit__' || action === 'exit') break
817
+ if (action === 'quick') {
818
+ const result = await quickConfig()
819
+ if (result === '__exit__') break
820
+ }
821
+ if (action === 'backup') {
822
+ const result = await backupConfig()
823
+ if (result === '__exit__') break
824
+ }
825
+ if (action === 'restore') {
826
+ const result = await restoreBackup()
827
+ if (result === '__exit__') break
828
+ }
829
+ }
830
+ clear()
831
+ console.log('已退出 iivo-sub。')
832
+ }
833
+
834
+ function releaseStdin() {
835
+ if (process.stdin.isTTY) {
836
+ try {
837
+ process.stdin.setRawMode(false)
838
+ } catch {
839
+ // stdin may already be detached in non-interactive shells.
840
+ }
841
+ }
842
+ process.stdin.removeAllListeners('keypress')
843
+ process.stdin.pause()
844
+ }
845
+
846
+ main()
847
+ .then(() => {
848
+ releaseStdin()
849
+ })
850
+ .catch((error) => {
851
+ releaseStdin()
852
+ console.error(c('red', error?.stack || String(error)))
853
+ process.exitCode = 1
854
+ })
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "iivo-sub",
3
+ "version": "0.1.0",
4
+ "description": "IIVO AI gateway quick configuration CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "iivo-sub": "bin/iivo-sub.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "license": "MIT"
17
+ }