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/LICENSE +21 -0
- package/README.md +327 -0
- package/bat/check-results.bat +60 -0
- package/bat/run-scan.bat +65 -0
- package/bat/setup-scheduler.bat +75 -0
- package/bin/9router-manager.js +7 -0
- package/metadata.json +1536 -0
- package/package.json +65 -0
- package/sh/check-results.sh +54 -0
- package/sh/run-scan.sh +57 -0
- package/sh/setup-scheduler-macos.sh +109 -0
- package/sh/setup-scheduler.sh +114 -0
- package/src/cli.js +799 -0
- package/src/combo.js +165 -0
- package/src/env.js +126 -0
- package/src/metadata.js +137 -0
- package/src/password.js +142 -0
- package/src/results.js +134 -0
- package/src/scan.js +293 -0
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
|
+
}
|