9router-manager 0.0.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.
package/src/cli.js ADDED
@@ -0,0 +1,799 @@
1
+ // src/cli.js
2
+ // 9Router Manager CLI
3
+ //
4
+ // CLI wiring (commander v12):
5
+ // - 7 commands: scan, list, results, test, setup, version, help
6
+ // - results has aliases: check, status
7
+ // - list accepts --details to print models in each combo
8
+ // - each command delegates to the appropriate module
9
+ // - no-args → interactive menu (banner + numbered/letter choices)
10
+ // - each command returns an exit code (0 success, 1 error)
11
+ import { Command } from 'commander';
12
+ import { spawn } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { loadEnv, getUserConfigPath, _generateVerifyToken } from './env.js';
17
+ import * as combo from './combo.js';
18
+ import {
19
+ login,
20
+ getModels,
21
+ testModelConcurrentWithBar,
22
+ testSingleModel,
23
+ writeAudit,
24
+ buildAuditData,
25
+ setVerifyContext,
26
+ clearVerifyContext,
27
+ setResolvedPassword,
28
+ } from './scan.js';
29
+ import { main as resultsMain } from './results.js';
30
+ import { getProviderForModel } from './metadata.js';
31
+ import { resolvePassword, passwordNotSetMessage } from './password.js';
32
+
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = path.dirname(__filename);
35
+ const PKG_PATH = path.join(__dirname, '..', 'package.json');
36
+ const PROG_NAME = '9router-manager';
37
+
38
+ function readPackageVersion() {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8')).version;
41
+ } catch {
42
+ return 'unknown';
43
+ }
44
+ }
45
+
46
+ const VERSION = readPackageVersion();
47
+
48
+ // ============================================================
49
+ // UI HELPERS
50
+ // ============================================================
51
+ function banner(version = VERSION) {
52
+ return [
53
+ '',
54
+ '╔═══════════════════════════════════════════════════╗',
55
+ `║ 9Router Manager v${version} ║`,
56
+ '╠═══════════════════════════════════════════════════╣',
57
+ '║ AI Gateway Local - Model Scanner ║',
58
+ '╚═══════════════════════════════════════════════════╝',
59
+ '',
60
+ ].join('\n');
61
+ }
62
+
63
+ function formatDuration(s) {
64
+ if (s < 60) return `${s.toFixed(2)}s`;
65
+ const m = Math.floor(s / 60);
66
+ const sec = (s % 60).toFixed(0);
67
+ return `${m}m ${sec}s`;
68
+ }
69
+
70
+ function formatTimestamp(d) {
71
+ const pad = (n) => String(n).padStart(2, '0');
72
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
73
+ }
74
+
75
+ // ============================================================
76
+ // COMMAND IMPLEMENTATIONS
77
+ // ============================================================
78
+ export async function runScan(opts = {}) {
79
+ const envPath = loadEnv();
80
+ if (!envPath) {
81
+ console.error('ERROR: .env not found. Copy .env.template to .env first.');
82
+ return 1;
83
+ }
84
+ // Resolve password from CLI flag → env var → TTY prompt (see src/password.js).
85
+ const password = await resolvePassword({ cliPassword: opts.password });
86
+ if (!password) {
87
+ console.error(passwordNotSetMessage());
88
+ return 1;
89
+ }
90
+ setResolvedPassword(password);
91
+ const baseUrl = process.env.NINEROUTER_BASE_URL || 'http://localhost:20128';
92
+ const sleep = parseFloat(process.env.NINEROUTER_SLEEP || '0.05');
93
+ const maxRetries = parseInt(process.env.NINEROUTER_RETRY_429_MAX || '2', 10);
94
+ const maxWorkers = parseInt(process.env.NINEROUTER_MAX_WORKERS || '1', 10);
95
+
96
+ const startedAt = new Date();
97
+ console.error(`[scan] starting at ${formatTimestamp(startedAt)}`);
98
+
99
+ // Generate unique verification token up front. The model must echo this token
100
+ // verbatim in its response — guarantees a real round-trip (anti-caching).
101
+ // The full prompt is recorded in the audit alongside the token (sanitized on write).
102
+ const verifyToken = _generateVerifyToken(8);
103
+ const verifyPrompt = `Reply with this exact token and nothing else: ${verifyToken}`;
104
+ setVerifyContext(verifyToken, verifyPrompt);
105
+ console.error(`[scan] verification token: ${verifyToken}`);
106
+
107
+ try {
108
+ // 1) Login
109
+ try {
110
+ await login({ baseUrl, password });
111
+ console.error('[scan] logged in');
112
+ } catch (e) {
113
+ console.error(`ERROR: login failed: ${e.message}`);
114
+ return 1;
115
+ }
116
+
117
+ // 2) Get models
118
+ let models;
119
+ try {
120
+ models = await getModels({ baseUrl });
121
+ console.error(`[scan] discovered ${models.length} models`);
122
+ } catch (e) {
123
+ console.error(`ERROR: getModels failed: ${e.message}`);
124
+ return 1;
125
+ }
126
+
127
+ // 3) Filter to active providers only
128
+ let activeProviders;
129
+ try {
130
+ activeProviders = await combo.getAvailableProviders();
131
+ } catch (e) {
132
+ console.error(`WARN: getAvailableProviders failed (${e.message}); proceeding with all models`);
133
+ activeProviders = new Set();
134
+ }
135
+ const candidates = models.filter((m) => {
136
+ const p = getProviderForModel(m);
137
+ return !p || activeProviders.size === 0 || activeProviders.has(p);
138
+ });
139
+ const skipped = models.length - candidates.length;
140
+ console.error(`[scan] ${candidates.length} candidates (${skipped} skipped, inactive provider)`);
141
+
142
+ // 4) Concurrent test
143
+ const results = await testModelConcurrentWithBar(candidates, {
144
+ baseUrl,
145
+ maxRetries,
146
+ sleep,
147
+ maxWorkers,
148
+ });
149
+ const okCount = results.filter((r) => r.ok).length;
150
+ const failedCount = results.length - okCount;
151
+ console.error(`[scan] ${okCount} ok, ${failedCount} failed`);
152
+
153
+ // 5) Detect target combo
154
+ let targetCombo;
155
+ try {
156
+ targetCombo = await combo.detectTargetCombo(models);
157
+ console.error(`[scan] target combo: ${targetCombo}`);
158
+ } catch (e) {
159
+ console.error(`ERROR: detect target combo: ${e.message}`);
160
+ return 1;
161
+ }
162
+
163
+ // 6) Update combo
164
+ let oldModels = [];
165
+ let newModels = [];
166
+ try {
167
+ const out = await combo.updateCombo(results, targetCombo);
168
+ oldModels = out.oldModels;
169
+ newModels = out.newModels;
170
+ const addedCount = newModels.filter((m) => !oldModels.includes(m)).length;
171
+ const removedCount = oldModels.filter((m) => !newModels.includes(m)).length;
172
+ console.error(`[scan] combo updated: +${addedCount} added, -${removedCount} removed`);
173
+ } catch (e) {
174
+ console.error(`ERROR: updateCombo failed: ${e.message}`);
175
+ return 1;
176
+ }
177
+
178
+ // 7) Audit
179
+ const finishedAt = new Date();
180
+ const durationSeconds = (finishedAt - startedAt) / 1000;
181
+ const auditData = buildAuditData({
182
+ startedAt: formatTimestamp(startedAt),
183
+ finishedAt: formatTimestamp(finishedAt),
184
+ durationSeconds,
185
+ verifyToken,
186
+ verifyPrompt,
187
+ models,
188
+ candidates,
189
+ skipped,
190
+ okCount,
191
+ failedCount,
192
+ oldModels,
193
+ newModels,
194
+ results,
195
+ });
196
+
197
+ // Sanitize verifyToken before writing to audit JSON
198
+ const sanitizedAuditData = {
199
+ ...auditData,
200
+ verify_token: '***REDACTED***',
201
+ verify_prompt: auditData.verify_prompt.replace(verifyToken, '***REDACTED***')
202
+ };
203
+
204
+ try {
205
+ await writeAudit(sanitizedAuditData);
206
+ } catch (e) {
207
+ console.error(`WARN: writeAudit failed: ${e.message}`);
208
+ }
209
+
210
+ console.error(`[scan] done in ${formatDuration(durationSeconds)}`);
211
+ return 0;
212
+ } finally {
213
+ clearVerifyContext();
214
+ }
215
+ }
216
+
217
+ export async function listCombo() {
218
+ loadEnv();
219
+ try {
220
+ const names = await combo.getComboNames();
221
+ if (names.length === 0) {
222
+ console.log('(no combos in DB)');
223
+ return 0;
224
+ }
225
+ console.log('Combos in database:');
226
+ for (const n of names) console.log(` - ${n}`);
227
+ return 0;
228
+ } catch (e) {
229
+ console.error(`ERROR: ${e.message}`);
230
+ return 1;
231
+ }
232
+ }
233
+
234
+ export async function listComboDetails() {
235
+ loadEnv();
236
+ try {
237
+ const combos = await combo.getComboDetails();
238
+ if (combos.length === 0) {
239
+ console.log('(no combos in DB)');
240
+ return 0;
241
+ }
242
+ console.log('Combos in database:');
243
+ for (const c of combos) {
244
+ console.log(` - ${c.name} (${c.models.length} models, updated: ${c.updatedAt || '-'})`);
245
+ if (c.models.length === 0) {
246
+ console.log(' (no models)');
247
+ } else {
248
+ for (const m of c.models) console.log(` - ${m}`);
249
+ }
250
+ }
251
+ return 0;
252
+ } catch (e) {
253
+ console.error(`ERROR: ${e.message}`);
254
+ return 1;
255
+ }
256
+ }
257
+
258
+ export async function runTestInteractive() {
259
+ const readline = await import('node:readline/promises');
260
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
261
+ try {
262
+ const password = await resolvePassword({});
263
+ if (!password) {
264
+ console.error(passwordNotSetMessage());
265
+ return 1;
266
+ }
267
+ setResolvedPassword(password);
268
+
269
+ const baseUrl = process.env.NINEROUTER_BASE_URL || 'http://localhost:20128';
270
+ try {
271
+ await login({ baseUrl, password });
272
+ } catch (e) {
273
+ console.error(`ERROR: login failed: ${e.message}`);
274
+ return 1;
275
+ }
276
+
277
+ console.log('\n Fetching available models...');
278
+ let models = [];
279
+ try {
280
+ models = await getModels();
281
+ } catch (e) {
282
+ console.error(`\n ERROR: failed to fetch models: ${e.message}`);
283
+ return 1;
284
+ }
285
+
286
+ if (models.length === 0) {
287
+ console.log('\n No models found.');
288
+ return 1;
289
+ }
290
+
291
+ // Paginate so a 100+ model list doesn't blow past a single screen.
292
+ const PAGE_SIZE = 20;
293
+ let page = 0;
294
+ const totalPages = Math.ceil(models.length / PAGE_SIZE);
295
+ while (true) {
296
+ const start = page * PAGE_SIZE;
297
+ const end = Math.min(start + PAGE_SIZE, models.length);
298
+ console.log(`\n Available models (${models.length} total) — page ${page + 1}/${totalPages}:`);
299
+ for (let i = start; i < end; i++) {
300
+ console.log(` [${i + 1}] ${models[i]}`);
301
+ }
302
+ // Navigation options are clearly separated from the model list.
303
+ const navOptions = ['[b] Back'];
304
+ if (totalPages > 1) {
305
+ navOptions.push(page > 0 ? '[p] Previous page' : null);
306
+ navOptions.push(page < totalPages - 1 ? '[n] Next page' : null);
307
+ }
308
+ const navLine = navOptions.filter(Boolean).join(' ');
309
+ console.log(`\n ${navLine}`);
310
+
311
+ const promptLabel = totalPages > 1 ? 'nomor/p/n/b' : 'nomor/b';
312
+ const ans = (await rl.question(`\n Pilih model (${promptLabel}): `)).trim().toLowerCase();
313
+ if (ans === 'b' || ans === 'back') {
314
+ return 0;
315
+ }
316
+ if (ans === 'n' || ans === 'next') {
317
+ if (page < totalPages - 1) {
318
+ page++;
319
+ continue;
320
+ }
321
+ console.log(' Sudah di halaman terakhir.');
322
+ continue;
323
+ }
324
+ if (ans === 'p' || ans === 'prev' || ans === 'previous') {
325
+ if (page > 0) {
326
+ page--;
327
+ continue;
328
+ }
329
+ console.log(' Sudah di halaman pertama.');
330
+ continue;
331
+ }
332
+ const n = parseInt(ans, 10);
333
+ if (!Number.isFinite(n) || n < 1 || n > models.length) {
334
+ console.log(' Pilihan tidak valid.');
335
+ continue;
336
+ }
337
+ const modelId = models[n - 1];
338
+ console.log(`\n Testing model: ${modelId}`);
339
+ const r = await testSingleModel(modelId, { baseUrl, maxRetries: 0 });
340
+ console.log('\n ' + JSON.stringify(r, null, 2).split('\n').join('\n '));
341
+ return r.ok ? 0 : 1;
342
+ }
343
+ } finally {
344
+ rl.close();
345
+ }
346
+ }
347
+
348
+ export async function runTest(modelId, opts = {}) {
349
+ const envPath = loadEnv();
350
+ if (!envPath) {
351
+ console.error('ERROR: .env not found.');
352
+ return 1;
353
+ }
354
+ if (!modelId) {
355
+ console.error('ERROR: model id required. Usage: 9router-manager test <model>');
356
+ return 1;
357
+ }
358
+ // Resolve password from CLI flag → env var → TTY prompt (see src/password.js).
359
+ const password = await resolvePassword({ cliPassword: opts.password });
360
+ if (!password) {
361
+ console.error(passwordNotSetMessage());
362
+ return 1;
363
+ }
364
+ setResolvedPassword(password);
365
+ const baseUrl = process.env.NINEROUTER_BASE_URL || 'http://localhost:20128';
366
+ try {
367
+ await login({ baseUrl, password });
368
+ } catch (e) {
369
+ console.error(`ERROR: login failed: ${e.message}`);
370
+ return 1;
371
+ }
372
+ const r = await testSingleModel(modelId, { baseUrl, maxRetries: 0 });
373
+ console.log(JSON.stringify(r, null, 2));
374
+ return r.ok ? 0 : 1;
375
+ }
376
+
377
+ export async function runSetup({ yes = false } = {}) {
378
+ // Print the platform-appropriate scheduler installer.
379
+ // - Default: ask for confirmation, then spawn the script (stdio inherited)
380
+ // so the user sees the same prompts they would see running it directly.
381
+ // - With { yes: true }: skip the prompt (for non-interactive invocations).
382
+ // - Falls back to "print and exit" if stdin is not a TTY and --yes not set.
383
+ const system = process.platform; // 'win32' | 'darwin' | 'linux' | ...
384
+ const lines = [
385
+ '9Router Manager — scheduler setup',
386
+ '==================================',
387
+ '',
388
+ 'This will install a daily scan on your system\'s task scheduler.',
389
+ 'A setup script must be run separately to make the actual changes.',
390
+ '',
391
+ ];
392
+
393
+ let scriptRel, scriptCmd, scriptArgs;
394
+ switch (system) {
395
+ case 'win32':
396
+ scriptRel = 'bat\\setup-scheduler.bat';
397
+ scriptCmd = 'cmd';
398
+ scriptArgs = ['/c', scriptRel];
399
+ break;
400
+ case 'darwin':
401
+ scriptRel = 'sh/setup-scheduler-macos.sh';
402
+ scriptCmd = 'bash';
403
+ scriptArgs = [scriptRel];
404
+ break;
405
+ case 'linux':
406
+ default:
407
+ scriptRel = 'sh/setup-scheduler.sh';
408
+ scriptCmd = 'bash';
409
+ scriptArgs = [scriptRel];
410
+ break;
411
+ }
412
+ // Project root = src/cli.js → ../ (sh/ and bat/ live here).
413
+ const projectRoot = path.resolve(__dirname, '..');
414
+ const scriptAbs = path.join(projectRoot, ...scriptRel.split(/[\\/]/));
415
+
416
+ // Sanity check: refuse to spawn if the script is missing.
417
+ if (!fs.existsSync(scriptAbs)) {
418
+ console.error(`ERROR: setup script not found at ${scriptAbs}`);
419
+ return 1;
420
+ }
421
+
422
+ lines.push(`Detected platform: ${system}`);
423
+ lines.push('');
424
+ lines.push(`Setup script:`);
425
+ lines.push(` ${scriptRel}`);
426
+ lines.push('');
427
+ lines.push(`Full path (in case cwd is not project root):`);
428
+ lines.push(` ${scriptAbs}`);
429
+ lines.push('');
430
+
431
+ // Skip prompt in non-TTY contexts unless --yes is set
432
+ if (!yes && !process.stdin.isTTY) {
433
+ lines.push('Non-interactive shell detected — re-run with --yes to actually run the script,');
434
+ lines.push('or copy the command above and run it yourself.');
435
+ console.log(lines.join('\n'));
436
+ return 0;
437
+ }
438
+
439
+ if (!yes) {
440
+ const readline = await import('node:readline/promises');
441
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
442
+ try {
443
+ // Accept any string starting with 'y' (with optional whitespace) as yes.
444
+ // This handles terminals that echo input or repeat keystrokes (e.g. "yy").
445
+ const ans = (await rl.question('Run the script now? This will modify your system. (y/n): ')).trim().toLowerCase();
446
+ if (!ans.startsWith('y')) {
447
+ console.log('Aborted. You can run the script manually using the command above.');
448
+ return 0;
449
+ }
450
+ } finally {
451
+ rl.close();
452
+ }
453
+ }
454
+
455
+ // Print a separator so the user can see the boundary between Node and the child script.
456
+ console.log('---');
457
+ console.log(`Running: ${scriptCmd} ${scriptArgs.join(' ')}`);
458
+ console.log('---');
459
+
460
+ return new Promise((resolve) => {
461
+ // Use the script's absolute path so the child can find it regardless of cwd.
462
+ // The leading forward slash in Windows paths is fine: Node normalizes it.
463
+ const absArgs = process.platform === 'win32'
464
+ ? [scriptAbs]
465
+ : [scriptAbs];
466
+ const child = spawn(scriptCmd, absArgs, {
467
+ cwd: projectRoot,
468
+ stdio: 'inherit',
469
+ shell: false,
470
+ });
471
+ child.on('error', (err) => {
472
+ console.error(`ERROR: failed to spawn setup script: ${err.message}`);
473
+ resolve(1);
474
+ });
475
+ child.on('exit', (code) => {
476
+ resolve(code ?? 1);
477
+ });
478
+ });
479
+ }
480
+
481
+ export async function runConfig() {
482
+ const readline = await import('node:readline/promises');
483
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
484
+
485
+ console.log('\n9Router Manager — Global Configuration');
486
+ console.log('====================================\n');
487
+
488
+ // Ensure we have current values loaded
489
+ loadEnv();
490
+
491
+ const currentPassword = process.env.NINEROUTER_PASSWORD || '';
492
+ const currentBaseUrl = process.env.NINEROUTER_BASE_URL || 'http://localhost:20128';
493
+
494
+ // Don't show the actual password if it exists
495
+ const passwordPrompt = currentPassword && currentPassword !== '***ISI_PASSWORD_DISINI***'
496
+ ? 'Enter 9Router Password (leave empty to keep current): '
497
+ : 'Enter 9Router Password: ';
498
+
499
+ let newPassword;
500
+ try {
501
+ // We don't mask here because we want a simple wizard, but we could use the masked prompt
502
+ // from password.js if we wanted to. For config wizard, standard question is often fine,
503
+ // but let's be secure and import the masked prompt.
504
+ const { promptMasked } = await import('./password.js');
505
+ newPassword = await promptMasked(passwordPrompt);
506
+ } catch (e) {
507
+ console.error(`\nError: ${e.message}`);
508
+ rl.close();
509
+ return 1;
510
+ }
511
+
512
+ const newBaseUrl = await rl.question(`Enter 9Router Base URL [${currentBaseUrl}]: `);
513
+ rl.close();
514
+
515
+ const finalPassword = newPassword || currentPassword;
516
+ const finalBaseUrl = newBaseUrl || currentBaseUrl;
517
+
518
+ if (!finalPassword || finalPassword === '***ISI_PASSWORD_DISINI***') {
519
+ console.error('\n❌ Configuration cancelled: Password is required.');
520
+ return 1;
521
+ }
522
+
523
+ const configPath = getUserConfigPath();
524
+ const configDir = path.dirname(configPath);
525
+
526
+ try {
527
+ fs.mkdirSync(configDir, { recursive: true });
528
+
529
+ // Read existing to preserve other variables if they exist
530
+ let envContent = '';
531
+ if (fs.existsSync(configPath)) {
532
+ envContent = fs.readFileSync(configPath, 'utf8');
533
+ }
534
+
535
+ // Update or append variables
536
+ const varsToUpdate = {
537
+ NINEROUTER_PASSWORD: finalPassword,
538
+ NINEROUTER_BASE_URL: finalBaseUrl
539
+ };
540
+
541
+ let newContent = envContent;
542
+
543
+ for (const [key, value] of Object.entries(varsToUpdate)) {
544
+ const regex = new RegExp(`^${key}=.*$`, 'm');
545
+ if (regex.test(newContent)) {
546
+ newContent = newContent.replace(regex, `${key}=${value}`);
547
+ } else {
548
+ // Ensure ends with newline before appending
549
+ if (newContent && !newContent.endsWith('\n')) newContent += '\n';
550
+ newContent += `${key}=${value}\n`;
551
+ }
552
+ }
553
+
554
+ fs.writeFileSync(configPath, newContent, { mode: 0o600 }); // strict permissions for password
555
+ console.log(`\n✅ Configuration saved to ${configPath}`);
556
+ return 0;
557
+ } catch (e) {
558
+ console.error(`\n❌ Failed to save configuration: ${e.message}`);
559
+ return 1;
560
+ }
561
+ }
562
+
563
+ function cmdVersion() {
564
+ console.log(`9Router Manager v${VERSION}`);
565
+ return 0;
566
+ }
567
+
568
+ // ============================================================
569
+ // PROGRAM
570
+ // ============================================================
571
+ function buildProgram() {
572
+ const program = new Command();
573
+
574
+ program
575
+ .name(PROG_NAME)
576
+ .description('9Router AI Gateway Local - Combo Model Scanner')
577
+ .version(VERSION, '-V, --version', 'output the version number')
578
+ .helpOption('-h, --help', 'display help for command')
579
+ .showHelpAfterError(false);
580
+
581
+ program
582
+ .command('scan')
583
+ .description('Run daily combo model scan')
584
+ .option('-p, --password <pwd>', 'NINEROUTER_PASSWORD (insecure: visible in process list; for testing only)')
585
+ .action(async (opts) => {
586
+ process.exitCode = await runScan({ password: opts.password });
587
+ });
588
+
589
+ program
590
+ .command('config')
591
+ .description('Configure global settings (interactive)')
592
+ .action(async () => {
593
+ process.exitCode = await runConfig();
594
+ });
595
+
596
+ program
597
+ .command('list')
598
+ .description('List combos in database (use --details to show models)')
599
+ .option('--details', 'show models in each combo')
600
+ .action(async (opts) => {
601
+ process.exitCode = opts.details ? await listComboDetails() : await listCombo();
602
+ });
603
+
604
+ program
605
+ .command('results')
606
+ .alias('check')
607
+ .alias('status')
608
+ .description('Show last scan results')
609
+ .action(async () => {
610
+ loadEnv();
611
+ try {
612
+ process.exitCode = await resultsMain();
613
+ } catch (e) {
614
+ // resultsMain may call process.exit directly; only hit this if it threw
615
+ console.error(`ERROR: ${e.message}`);
616
+ process.exitCode = 1;
617
+ }
618
+ });
619
+
620
+ program
621
+ .command('test <model>')
622
+ .description('Test a single model')
623
+ .option('-p, --password <pwd>', 'NINEROUTER_PASSWORD (insecure: visible in process list; for testing only)')
624
+ .action(async (model, opts) => {
625
+ process.exitCode = await runTest(model, { password: opts.password });
626
+ });
627
+
628
+ program
629
+ .command('setup')
630
+ .description('Setup auto-scan scheduler')
631
+ .option('-y, --yes', 'skip the confirmation prompt before running the platform setup script')
632
+ .action(async (opts) => {
633
+ process.exitCode = await runSetup({ yes: !!opts.yes });
634
+ });
635
+
636
+ program
637
+ .command('version')
638
+ .description('Show version info')
639
+ .action(() => {
640
+ process.exitCode = cmdVersion();
641
+ });
642
+
643
+ program
644
+ .command('help')
645
+ .description('Show help')
646
+ .action(() => {
647
+ program.help();
648
+ });
649
+
650
+ return program;
651
+ }
652
+
653
+ // ============================================================
654
+ // INTERACTIVE
655
+ // ============================================================
656
+ const INTERACTIVE_OPTIONS = [
657
+ ['scan', 'Run Model Scan'],
658
+ ['list', 'List Combos'],
659
+ ['results', 'Show Last Scan Results'],
660
+ ['test', 'Test Single Model'],
661
+ ['setup', 'Setup Auto-Scan Scheduler'],
662
+ ['config', 'Configure Settings'],
663
+ ['version', 'Version Info'],
664
+ ['help', 'Show Help'],
665
+ ['quit', 'Quit'],
666
+ ];
667
+
668
+ async function runInteractive(program) {
669
+ const readline = await import('node:readline/promises');
670
+ const rl = readline.createInterface({
671
+ input: process.stdin,
672
+ output: process.stdout,
673
+ });
674
+ // Map letter shortcuts to commands
675
+ const LETTER_SHORTCUTS = {
676
+ q: 'quit',
677
+ h: 'help',
678
+ v: 'version',
679
+ };
680
+ try {
681
+ while (true) {
682
+ console.log(banner(VERSION));
683
+ // Split menu items into "main" (numbered 1..N) and "shortcuts" (v/h/q).
684
+ // Main items are listed vertically; shortcuts (version, help, quit) are
685
+ // appended inline on the last line so they don't eat a row each.
686
+ const mainItems = INTERACTIVE_OPTIONS.filter(([k]) => k !== 'version' && k !== 'help' && k !== 'quit');
687
+ mainItems.forEach(([key, label], i) => {
688
+ console.log(` [${i + 1}] ${label}`);
689
+ });
690
+ // Footer line: 2 columns of shortcuts, right-aligned to a fixed width so
691
+ // the layout stays tidy in narrow terminals.
692
+ console.log('─'.repeat(60));
693
+ console.log(` [v] Version Info [h] Show Help [q] Quit`);
694
+
695
+ const ans = (await rl.question('\n Pilihan (nomor/v/h/q): ')).trim().toLowerCase();
696
+ if (ans === 'q' || ans === 'quit') {
697
+ console.log('Bye.');
698
+ break;
699
+ }
700
+ if (ans === 'h' || ans === 'help') {
701
+ program.help();
702
+ await rl.question('\n Press Enter to continue...');
703
+ continue;
704
+ }
705
+ if (ans === 'v' || ans === 'version') {
706
+ cmdVersion();
707
+ await rl.question('\n Press Enter to continue...');
708
+ continue;
709
+ }
710
+ const n = parseInt(ans, 10);
711
+ if (!Number.isFinite(n) || n < 1 || n > INTERACTIVE_OPTIONS.length) {
712
+ console.log(' Pilihan tidak valid.');
713
+ await rl.question(' Press Enter to continue...');
714
+ continue;
715
+ }
716
+ const [cmd] = INTERACTIVE_OPTIONS[n - 1];
717
+ if (cmd === 'quit') {
718
+ console.log('Bye.');
719
+ break;
720
+ }
721
+ if (cmd === 'help') {
722
+ program.help();
723
+ await rl.question('\n Press Enter to continue...');
724
+ continue;
725
+ }
726
+ if (cmd === 'version') {
727
+ cmdVersion();
728
+ await rl.question('\n Press Enter to continue...');
729
+ continue;
730
+ }
731
+ if (cmd === 'test') {
732
+ // Test single model — fetch from API and show as paginated numbered list.
733
+ // Handled inline (not via synthetic argv) because commander requires a <model>
734
+ // argument and we want to let the user pick interactively, not type an ID.
735
+ await runTestInteractive();
736
+ await rl.question('\n Press Enter to continue...');
737
+ continue;
738
+ }
739
+ console.log();
740
+ // For commands that need their own pre-argv prompting (e.g. list --details),
741
+ // we re-invoke via synthetic argv. test and config are handled inline above.
742
+ const fakeArgs = [process.argv[0] || 'node', process.argv[1] || 'src/cli.js', cmd];
743
+ if (cmd === 'list') {
744
+ const showDetails = (await rl.question(' Tampilkan model dalam combo? (y/n): ')).trim().toLowerCase();
745
+ if (showDetails === 'y' || showDetails === 'yes') {
746
+ fakeArgs.push('--details');
747
+ }
748
+ }
749
+ // Reset exitCode so interactive loop can re-enter
750
+ process.exitCode = 0;
751
+ await program.parseAsync(fakeArgs);
752
+ // Don't break on non-zero — let the user choose again
753
+ await rl.question('\n Press Enter to continue...');
754
+ }
755
+ } finally {
756
+ rl.close();
757
+ }
758
+ return 0;
759
+ }
760
+
761
+ // ============================================================
762
+ // MAIN
763
+ // ============================================================
764
+ export async function main(argv = process.argv) {
765
+ const args = argv.slice(2);
766
+
767
+ // No args → interactive mode
768
+ if (args.length === 0) {
769
+ const program = buildProgram();
770
+ return runInteractive(program);
771
+ }
772
+
773
+ // Load .env — silent when missing.
774
+ loadEnv();
775
+
776
+ const program = buildProgram();
777
+ await program.parseAsync(argv);
778
+ return process.exitCode ?? 0;
779
+ }
780
+
781
+ // Entry point: only run when this file is executed directly,
782
+ // not when it's imported as a module (e.g. by tests or by bin/9router-manager.js).
783
+ function resolveArgv1() {
784
+ try {
785
+ return fileURLToPath(`file://${process.argv[1]}`);
786
+ } catch {
787
+ return process.argv[1];
788
+ }
789
+ }
790
+
791
+ const isDirectInvocation =
792
+ process.argv[1] && fileURLToPath(import.meta.url) === resolveArgv1();
793
+
794
+ if (isDirectInvocation) {
795
+ main().catch((err) => {
796
+ console.error('FATAL:', err?.message ?? err);
797
+ process.exit(1);
798
+ });
799
+ }