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/combo.js ADDED
@@ -0,0 +1,165 @@
1
+ // src/combo.js
2
+ // SQLite combo management for 9Router Manager.
3
+ // Uses better-sqlite3 (synchronous API). All exported functions are async
4
+ // to keep the event loop free during retry/sleep back-off.
5
+ import Database from 'better-sqlite3';
6
+ import { _resolveDbPath, _getDefaultDbPath } from './env.js';
7
+ import { getModelPriority, getModelReleaseSortKey } from './metadata.js';
8
+
9
+ // Re-evaluate per call so tests can swap NINEROUTER_DB_PATH env var.
10
+ export function _getDbPath() {
11
+ return process.env.NINEROUTER_DB_PATH
12
+ ? _resolveDbPath(process.env.NINEROUTER_DB_PATH)
13
+ : _getDefaultDbPath();
14
+ }
15
+
16
+ export async function getDbConnection(maxRetries = 3, delay = 1.0) {
17
+ const dbPath = _getDbPath();
18
+ let lastError = null;
19
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
20
+ try {
21
+ return new Database(dbPath, { timeout: 10000 });
22
+ } catch (e) {
23
+ lastError = e;
24
+ console.error(`[combo] SQLite lock attempt ${attempt + 1}/${maxRetries}: ${e.message}`);
25
+ if (attempt < maxRetries - 1) {
26
+ await new Promise((resolve) => setTimeout(resolve, delay * 1000));
27
+ }
28
+ }
29
+ }
30
+ throw new Error(`Gagal koneksi SQLite setelah ${maxRetries}x retry: ${lastError?.message ?? 'unknown'}`);
31
+ }
32
+
33
+ export async function getAvailableProviders() {
34
+ let conn;
35
+ try {
36
+ conn = await getDbConnection();
37
+ } catch (e) {
38
+ console.error(`[combo] Cannot get providers: ${e.message}`);
39
+ return new Set();
40
+ }
41
+ try {
42
+ const rows = conn.prepare('SELECT provider, isActive, data FROM providerConnections').all();
43
+ const active = new Set();
44
+ for (const { provider, isActive, data: dataStr } of rows) {
45
+ const data = dataStr ? JSON.parse(dataStr) : {};
46
+ if (isActive && data.testStatus === 'active') active.add(provider);
47
+ }
48
+ conn.close();
49
+ return active;
50
+ } catch (e) {
51
+ console.error(`[combo] Error querying providers: ${e.message}`);
52
+ if (conn) {
53
+ try { conn.close(); } catch {}
54
+ }
55
+ return new Set();
56
+ }
57
+ }
58
+
59
+ export async function getComboNames() {
60
+ let conn;
61
+ try {
62
+ conn = await getDbConnection();
63
+ } catch (e) {
64
+ throw new Error(`Cannot get combos: ${e.message}`);
65
+ }
66
+ try {
67
+ const rows = conn.prepare('SELECT name FROM combos').all();
68
+ conn.close();
69
+ return rows.map((r) => r.name).filter(Boolean);
70
+ } catch (e) {
71
+ try { conn.close(); } catch {}
72
+ throw new Error(`Cannot query combos: ${e.message}`);
73
+ }
74
+ }
75
+
76
+ export async function getComboDetails() {
77
+ let conn;
78
+ try {
79
+ conn = await getDbConnection();
80
+ } catch (e) {
81
+ throw new Error(`Cannot get combos: ${e.message}`);
82
+ }
83
+ try {
84
+ const rows = conn.prepare('SELECT name, models, updatedAt FROM combos').all();
85
+ conn.close();
86
+ return rows
87
+ .filter((r) => r.name)
88
+ .map((r) => ({
89
+ name: r.name,
90
+ models: JSON.parse(r.models || '[]'),
91
+ updatedAt: r.updatedAt,
92
+ }));
93
+ } catch (e) {
94
+ if (conn) {
95
+ try { conn.close(); } catch {}
96
+ }
97
+ throw new Error(`Cannot query combos: ${e.message}`);
98
+ }
99
+ }
100
+
101
+ export async function detectTargetCombo(models) {
102
+ const combos = await getComboNames();
103
+ const modelSet = new Set(models);
104
+ const detected = combos.filter((name) => modelSet.has(name));
105
+ if (detected.length === 1) return detected[0];
106
+ if (detected.length === 0) {
107
+ throw new Error(
108
+ `Tidak ada combo yang terdeteksi dari /v1/models. Combo di DB: ${combos.join(', ') || '(none)'}`
109
+ );
110
+ }
111
+ throw new Error(
112
+ `Lebih dari satu combo terdeteksi dari /v1/models; target ambigu: ${detected.join(', ')}`
113
+ );
114
+ }
115
+
116
+ export async function updateCombo(results, comboName) {
117
+ const okResults = results.filter((r) => r.ok);
118
+ for (const r of okResults) {
119
+ r.priority = getModelPriority(r.model);
120
+ r.release_sort_key = getModelReleaseSortKey(r.model);
121
+ }
122
+ const sortedResults = [...okResults].sort((a, b) => {
123
+ const pa = a.priority ?? 99;
124
+ const pb = b.priority ?? 99;
125
+ if (pa !== pb) return pa - pb;
126
+ const ra = a.release_sort_key ?? [9999, 99, 99];
127
+ const rb = b.release_sort_key ?? [9999, 99, 99];
128
+ for (let i = 0; i < 3; i++) {
129
+ if (ra[i] !== rb[i]) return ra[i] - rb[i];
130
+ }
131
+ return (a.elapsed_ms ?? Infinity) - (b.elapsed_ms ?? Infinity);
132
+ });
133
+ const okModels = sortedResults.map((r) => r.model);
134
+
135
+ const conn = await getDbConnection();
136
+ try {
137
+ const row = conn.prepare('SELECT id, models FROM combos WHERE name = ?').get(comboName);
138
+ if (!row) {
139
+ conn.close();
140
+ throw new Error(`Combo '${comboName}' not found`);
141
+ }
142
+ const { id: comboId, models: oldModelsStr } = row;
143
+ const oldModels = JSON.parse(oldModelsStr);
144
+
145
+ const filtered = okModels.filter((m) => m && m !== comboName);
146
+ const seen = new Set();
147
+ const uniqueModels = [];
148
+ for (const m of filtered) {
149
+ if (!seen.has(m)) {
150
+ seen.add(m);
151
+ uniqueModels.push(m);
152
+ }
153
+ }
154
+
155
+ const now = new Date().toISOString();
156
+ conn.prepare('UPDATE combos SET models = ?, updatedAt = ? WHERE id = ?')
157
+ .run(JSON.stringify(uniqueModels), now, comboId);
158
+ conn.close();
159
+
160
+ return { oldModels, newModels: uniqueModels };
161
+ } catch (e) {
162
+ try { conn.close(); } catch {}
163
+ throw new Error(`Update combo '${comboName}' failed: ${e.message}`);
164
+ }
165
+ }
package/src/env.js ADDED
@@ -0,0 +1,126 @@
1
+ // src/env.js
2
+ // Shared .env loader + path/token helpers. Single source of truth across all
3
+ // entry points (src/cli.js, bin/9router-manager.js, etc.).
4
+ //
5
+ // Lookup order for .env (first match wins):
6
+ // 1. $NINEROUTER_ENV_FILE (if set)
7
+ // 2. ./.env (cwd)
8
+ // 3. <this_dir>/../.env (project root, since this file is in src/)
9
+ // 4. <this_dir>/.env
10
+ //
11
+ // Does NOT override existing process.env values (env vars take precedence).
12
+ //
13
+ // Path/token helpers:
14
+ // - _resolveDbPath: expand ~ and $VAR, absolutize
15
+ // - _getDefaultDbPath: per-OS candidate list, first existing wins
16
+ // - _getDefaultAuditPath: per-OS audit log path
17
+ // - _generateVerifyToken: random [A-Z0-9] token
18
+ import dotenv from 'dotenv';
19
+ import path from 'node:path';
20
+ import os from 'node:os';
21
+ import fs from 'node:fs';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = path.dirname(__filename);
26
+
27
+ export function getUserConfigPath() {
28
+ return path.join(os.homedir(), '.9router', '.env');
29
+ }
30
+
31
+ function _iterCandidatePaths() {
32
+ const override = process.env.NINEROUTER_ENV_FILE;
33
+ if (override) {
34
+ return [path.resolve(override)];
35
+ }
36
+
37
+ return [
38
+ path.join(process.cwd(), '.env'),
39
+ getUserConfigPath(),
40
+ path.join(__dirname, '..', '.env'),
41
+ path.join(__dirname, '.env'),
42
+ ];
43
+ }
44
+
45
+ export function loadEnv(opts = { strict: false }) {
46
+ const { strict = false } = opts;
47
+
48
+ for (const envPath of _iterCandidatePaths()) {
49
+ const result = dotenv.config({ path: envPath });
50
+ if (result.error) {
51
+ if (result.error.code === 'ENOENT') continue;
52
+ console.error(`[env] failed reading ${envPath}: ${result.error.message}`);
53
+ continue;
54
+ }
55
+ console.error(`[env] loaded ${envPath}`);
56
+ return envPath;
57
+ }
58
+
59
+ if (strict) {
60
+ console.error('[env] no .env file found in any of the search paths');
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // ============================================================
66
+ // PATH / TOKEN HELPERS
67
+ // ============================================================
68
+
69
+ function _expandPath(p) {
70
+ if (p.startsWith('~')) {
71
+ p = os.homedir() + p.slice(1);
72
+ }
73
+ p = p.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, name) => process.env[name] ?? '');
74
+ return p;
75
+ }
76
+
77
+ export function _resolveDbPath(rawPath) {
78
+ return path.resolve(_expandPath(rawPath));
79
+ }
80
+
81
+ export function _getDefaultDbPath() {
82
+ const system = os.platform(); // 'linux' | 'darwin' | 'win32' | ...
83
+
84
+ if (system === 'win32') {
85
+ return path.join(os.homedir(), 'AppData', 'Roaming', '9router', 'db', 'data.sqlite');
86
+ }
87
+
88
+ const home = os.homedir();
89
+ if (system === 'linux' || system === 'darwin') {
90
+ const candidates = [
91
+ path.join(home, '.9router', 'db', 'data.sqlite'),
92
+ path.join(home, '.config', '9router', 'data.sqlite'),
93
+ path.join(home, '.local', 'share', '9router', 'data.sqlite'),
94
+ path.join(home, '.snap', '9router', 'current', '.config', '9router', 'data.sqlite'),
95
+ ];
96
+ for (const c of candidates) {
97
+ if (fs.existsSync(c)) return c;
98
+ }
99
+ return candidates[0];
100
+ }
101
+
102
+ return path.join(home, '.9router', 'db', 'data.sqlite');
103
+ }
104
+
105
+ export function _getDefaultAuditPath() {
106
+ const system = os.platform();
107
+
108
+ if (system === 'win32') {
109
+ return path.join(os.homedir(), 'AppData', 'Local', 'hermes', 'scripts', '9router_daily_combo_model_scan_last.json');
110
+ }
111
+ if (system === 'darwin') {
112
+ return path.join(os.homedir(), 'Library', 'Logs', '9router-scan.log');
113
+ }
114
+ // linux (and other unix-likes)
115
+ return path.join(os.homedir(), '.local', 'share', '9router', 'audit', '9router_daily_combo_model_scan_last.json');
116
+ }
117
+
118
+ const _TOKEN_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
119
+
120
+ export function _generateVerifyToken(length = 8) {
121
+ let out = '';
122
+ for (let i = 0; i < length; i++) {
123
+ out += _TOKEN_CHARS[Math.floor(Math.random() * _TOKEN_CHARS.length)];
124
+ }
125
+ return out;
126
+ }
@@ -0,0 +1,137 @@
1
+ // src/metadata.js
2
+ // Pattern matching + sort-key logic, backed by metadata.json.
3
+ // - Loads metadata.json once at startup (priority/release-date lookups).
4
+ // - normalizeModelKey(id): strip provider prefix, lowercase.
5
+ // - getModelPriority(id): 1..5 (frontier models get 1, unknown=5).
6
+ // - extractModelReleaseDate(id): [y, m, d] | null from MODEL_RELEASE_PATTERNS.
7
+ // - extractModelRecency(id): 0..4 heuristic tier (newest=0, unknown=4).
8
+ // - getModelReleaseSortKey(id): [-y, -m, -d] for known date | [1000+r, 0, 0].
9
+ // - getProviderForModel(id): PREFIX_TO_PROVIDER lookup (insertion-ordered).
10
+
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import fs from 'node:fs';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const METADATA_PATH = path.join(__dirname, '..', 'metadata.json');
17
+
18
+ let _cache = null;
19
+
20
+ function _load() {
21
+ if (_cache) return _cache;
22
+ try {
23
+ const raw = fs.readFileSync(METADATA_PATH, 'utf-8');
24
+ const data = JSON.parse(raw);
25
+ _cache = {
26
+ PREFIX_TO_PROVIDER: data.PREFIX_TO_PROVIDER || {},
27
+ SKIP_MODEL_PREFIXES: data.SKIP_MODEL_PREFIXES || [],
28
+ FRONTIER_PATTERNS: data.FRONTIER_PATTERNS || [],
29
+ MODEL_RELEASE_PATTERNS: (data.MODEL_RELEASE_PATTERNS || []).map(
30
+ ([p, [y, m, d]]) => [p, [y, m, d]]
31
+ ),
32
+ };
33
+ } catch (e) {
34
+ console.error('[metadata] failed to load metadata.json:', e.message);
35
+ _cache = {
36
+ PREFIX_TO_PROVIDER: {},
37
+ SKIP_MODEL_PREFIXES: [],
38
+ FRONTIER_PATTERNS: [],
39
+ MODEL_RELEASE_PATTERNS: [],
40
+ };
41
+ }
42
+ return _cache;
43
+ }
44
+
45
+ export function loadMetadata() {
46
+ return _load();
47
+ }
48
+
49
+ export function normalizeModelKey(id) {
50
+ const lower = String(id).toLowerCase();
51
+ const idx = lower.indexOf('/');
52
+ return idx === -1 ? lower : lower.slice(idx + 1);
53
+ }
54
+
55
+ export function extractModelReleaseDate(id) {
56
+ const { MODEL_RELEASE_PATTERNS } = _load();
57
+ const normalized = normalizeModelKey(id);
58
+ for (const [pattern, [y, m, d]] of MODEL_RELEASE_PATTERNS) {
59
+ if (normalized.includes(pattern)) return [y, m, d];
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function extractModelRecency(id) {
65
+ const normalized = normalizeModelKey(id);
66
+ if (
67
+ [
68
+ 'gpt-4o',
69
+ 'gpt-4.1',
70
+ 'gpt-4.5',
71
+ 'claude-3.5',
72
+ 'claude-3.7',
73
+ 'gemini-2.5',
74
+ 'gemini-3',
75
+ 'llama-4',
76
+ 'grok-4',
77
+ 'minimax-m2',
78
+ ].some((p) => normalized.includes(p))
79
+ )
80
+ return 0;
81
+ if (
82
+ [
83
+ 'gpt-4-turbo',
84
+ 'gpt-4-32k',
85
+ 'claude-3-sonnet',
86
+ 'claude-3-opus',
87
+ 'mistral-large-3',
88
+ 'mixtral-8x22b',
89
+ ].some((p) => normalized.includes(p))
90
+ )
91
+ return 1;
92
+ if (
93
+ [
94
+ 'gpt-4',
95
+ 'gpt-3.5-turbo-16k',
96
+ 'claude-3-haiku',
97
+ 'mistral-medium',
98
+ 'mistral-small',
99
+ 'mixtral-8x7b',
100
+ ].some((p) => normalized.includes(p))
101
+ )
102
+ return 2;
103
+ if (
104
+ ['gpt-3.5-turbo', 'text-davinci', 'ada-002'].some((p) =>
105
+ normalized.includes(p)
106
+ )
107
+ )
108
+ return 3;
109
+ return 4;
110
+ }
111
+
112
+ export function getModelPriority(id) {
113
+ const { FRONTIER_PATTERNS } = _load();
114
+ for (const [pattern, priority] of FRONTIER_PATTERNS) {
115
+ if (pattern.endsWith('/')) {
116
+ if (id.startsWith(pattern)) return priority;
117
+ } else {
118
+ if (id.includes(pattern)) return priority;
119
+ }
120
+ }
121
+ return 5;
122
+ }
123
+
124
+ export function getModelReleaseSortKey(id) {
125
+ const d = extractModelReleaseDate(id);
126
+ if (d) return [-d[0], -d[1], -d[2]];
127
+ const r = extractModelRecency(id);
128
+ return [1000 + r, 0, 0];
129
+ }
130
+
131
+ export function getProviderForModel(id) {
132
+ const { PREFIX_TO_PROVIDER } = _load();
133
+ for (const [prefix, provider] of Object.entries(PREFIX_TO_PROVIDER)) {
134
+ if (id.startsWith(prefix)) return provider;
135
+ }
136
+ return null;
137
+ }
@@ -0,0 +1,142 @@
1
+ // src/password.js
2
+ // Centralized resolver for NINEROUTER_PASSWORD.
3
+ //
4
+ // Resolution order (first non-empty, non-placeholder wins):
5
+ // 1. CLI flag `--password <value>`
6
+ // 2. process.env.NINEROUTER_PASSWORD (set via shell or .env via dotenv)
7
+ // 3. Interactive masked TTY prompt (only when stdin & stdout are TTYs)
8
+ // 4. Otherwise: return null (caller handles the error message)
9
+ //
10
+ // Why a single module: the previous code had three different places read
11
+ // process.env directly (cli.js runScan, cli.js runTest, scan.js _requirePassword).
12
+ // Centralizing lets us:
13
+ // - Add a CLI flag or TTY prompt in one place
14
+ // - Expose a friendly "here's what to do" error message
15
+ // - Unit-test the pure resolution logic without a real TTY
16
+ import process from 'node:process';
17
+
18
+ export const PASSWORD_ENV = 'NINEROUTER_PASSWORD';
19
+ export const PASSWORD_PLACEHOLDER = '***ISI_PASSWORD_DISINI***';
20
+
21
+ function _isUsable(p) {
22
+ return typeof p === 'string' && p.length > 0 && p !== PASSWORD_PLACEHOLDER;
23
+ }
24
+
25
+ /**
26
+ * Pure, synchronous resolution from non-interactive sources only.
27
+ * @param {{ cliPassword?: string }} opts
28
+ * @returns {string|null}
29
+ */
30
+ export function resolvePasswordFromSources({ cliPassword } = {}) {
31
+ // 1) CLI flag wins (explicit user intent)
32
+ if (_isUsable(cliPassword)) return cliPassword;
33
+ // 2) Env var (shell or .env via dotenv)
34
+ const envPwd = process.env[PASSWORD_ENV];
35
+ if (_isUsable(envPwd)) return envPwd;
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Masked password prompt. Reads from stdin in raw mode and echoes `*`
41
+ * for each character so the password never appears in terminal scrollback.
42
+ *
43
+ * Returns the entered string (without the trailing newline).
44
+ * Throws on Ctrl-C; resolves to '' on Enter without input.
45
+ */
46
+ export function promptMasked(question, io = process) {
47
+ return new Promise((resolve, reject) => {
48
+ const stdin = io.stdin;
49
+ const stdout = io.stdout;
50
+ if (!stdin.isTTY || !stdout.isTTY) {
51
+ reject(new Error('promptMasked requires a TTY'));
52
+ return;
53
+ }
54
+ stdout.write(question);
55
+ const wasRaw = stdin.isRaw;
56
+ if (!wasRaw && typeof stdin.setRawMode === 'function') {
57
+ stdin.setRawMode(true);
58
+ }
59
+ stdin.resume();
60
+ stdin.setEncoding('utf8');
61
+
62
+ let buf = '';
63
+ const onData = (chunk) => {
64
+ for (const c of chunk) {
65
+ const code = c.charCodeAt(0);
66
+ if (c === '\r' || c === '\n' || code === 0x04) {
67
+ // Enter or Ctrl-D — finish
68
+ if (!wasRaw && typeof stdin.setRawMode === 'function') {
69
+ stdin.setRawMode(false);
70
+ }
71
+ stdin.removeListener('data', onData);
72
+ stdin.pause();
73
+ stdout.write('\n');
74
+ resolve(buf);
75
+ return;
76
+ }
77
+ if (code === 0x03) {
78
+ // Ctrl-C
79
+ stdout.write('\n');
80
+ reject(new Error('SIGINT'));
81
+ if (typeof process !== 'undefined' && process.exit) process.exit(130);
82
+ return;
83
+ }
84
+ if (c === '\b' || c === '\x7f') {
85
+ // Backspace
86
+ if (buf.length > 0) {
87
+ buf = buf.slice(0, -1);
88
+ stdout.write('\b \b');
89
+ }
90
+ continue;
91
+ }
92
+ // Ignore non-printable control characters
93
+ if (code < 0x20) continue;
94
+ buf += c;
95
+ stdout.write('*');
96
+ }
97
+ };
98
+ stdin.on('data', onData);
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Full resolution: non-interactive sources first, then masked TTY prompt.
104
+ * @param {{
105
+ * cliPassword?: string,
106
+ * io?: { stdin: NodeJS.ReadableStream, stdout: NodeJS.WriteStream },
107
+ * prompt?: (q: string) => Promise<string>,
108
+ * }} opts
109
+ * @returns {Promise<string|null>}
110
+ */
111
+ export async function resolvePassword({ cliPassword, io, prompt } = {}) {
112
+ const fromSources = resolvePasswordFromSources({ cliPassword });
113
+ if (fromSources) return fromSources;
114
+
115
+ // If neither TTY is present, we can't prompt.
116
+ const stdin = io?.stdin ?? process.stdin;
117
+ const stdout = io?.stdout ?? process.stdout;
118
+ if (!stdin.isTTY || !stdout.isTTY) return null;
119
+
120
+ const ask = prompt ?? ((q) => promptMasked(q, io ?? process));
121
+ const answer = await ask(`Enter NINEROUTER_PASSWORD: `);
122
+ if (!_isUsable(answer)) return null;
123
+ return answer;
124
+ }
125
+
126
+ /**
127
+ * The friendly "password is not set, here's what to do" message.
128
+ * Exported so tests can pin the wording.
129
+ */
130
+ export function passwordNotSetMessage() {
131
+ return [
132
+ 'ERROR: NINEROUTER_PASSWORD is not set.',
133
+ ' Run the configuration wizard to set it up:',
134
+ ' 9router-manager config',
135
+ '',
136
+ ' Alternatively, provide it via:',
137
+ ' 1. Shell env var: NINEROUTER_PASSWORD=xxx 9router-manager scan',
138
+ ' 2. .env file: Create/edit .env in your project',
139
+ ' 3. CLI flag: 9router-manager scan --password xxx (visible in process list)',
140
+ ' In a non-interactive shell (CI/cron), only options 1 and 2 work.',
141
+ ].join('\n');
142
+ }
package/src/results.js ADDED
@@ -0,0 +1,134 @@
1
+ // src/results.js
2
+ // Read audit JSON (written by src/scan.js after a scan run), summarize, and print.
3
+ import fs from 'node:fs';
4
+ import { _resolveDbPath, _getDefaultAuditPath, loadEnv } from './env.js';
5
+
6
+ function _resolveAuditPath() {
7
+ return process.env.NINEROUTER_AUDIT_PATH
8
+ ? _resolveDbPath(process.env.NINEROUTER_AUDIT_PATH)
9
+ : _getDefaultAuditPath();
10
+ }
11
+
12
+ function _safeInt(val, def = 0) {
13
+ const n = parseInt(val, 10);
14
+ return Number.isFinite(n) ? n : def;
15
+ }
16
+
17
+ export function readAudit(auditPath) {
18
+ const text = fs.readFileSync(auditPath, 'utf-8');
19
+ return JSON.parse(text);
20
+ }
21
+
22
+ export function summarizeAudit(d) {
23
+ const discovered = _safeInt(d.discovered_count);
24
+ const candidates = _safeInt(d.candidate_count);
25
+ const skipped = _safeInt(d.skipped_count);
26
+ const ok = _safeInt(d.ok_count);
27
+ const failed = _safeInt(d.failed_count);
28
+ const oldCombo = _safeInt(d.old_combo_count);
29
+ const newCombo = _safeInt(d.new_combo_count);
30
+ const added = d.added || [];
31
+ const removed = d.removed || [];
32
+ const results = d.results || [];
33
+
34
+ // Per-provider breakdown
35
+ const byProvider = {};
36
+ for (const r of results) {
37
+ if (r.ok) {
38
+ const prefix = String(r.model).split('/')[0];
39
+ byProvider[prefix] = (byProvider[prefix] || 0) + 1;
40
+ }
41
+ }
42
+ const failedModels = results.filter((r) => !r.ok && !(r.detail || '').startsWith('skipped:'));
43
+
44
+ return {
45
+ header: {
46
+ started_at: d.started_at || '-',
47
+ finished_at: d.finished_at || '-',
48
+ duration_seconds: d.duration_seconds,
49
+ },
50
+ metrics: { discovered, candidates, skipped, ok, failed },
51
+ combo: { old_count: oldCombo, new_count: newCombo, added_count: added.length, removed_count: removed.length },
52
+ added,
53
+ removed,
54
+ by_provider: byProvider,
55
+ failed: failedModels,
56
+ };
57
+ }
58
+
59
+ export function printSummary(summary) {
60
+ const W = 56;
61
+ const hr = () => console.log('-'.repeat(W));
62
+ hr();
63
+ console.log(' 9Router - Hasil Scan Terakhir');
64
+ hr();
65
+ console.log(` Start : ${summary.header.started_at}`);
66
+ console.log(` Finish: ${summary.header.finished_at}`);
67
+ if (summary.header.duration_seconds) {
68
+ console.log(` Duration: ${summary.header.duration_seconds}s`);
69
+ }
70
+ hr();
71
+ console.log(` ${'Metric'.padEnd(20)} ${'Value'.padStart(10)}`);
72
+ for (const [k, v] of Object.entries(summary.metrics)) {
73
+ console.log(` ${k.padEnd(20)} ${String(v).padStart(10)}`);
74
+ }
75
+ hr();
76
+ console.log(` ${'Old combo count'.padEnd(20)} ${String(summary.combo.old_count).padStart(10)}`);
77
+ console.log(` ${'New combo count'.padEnd(20)} ${String(summary.combo.new_count).padStart(10)}`);
78
+ console.log(` ${'Added'.padEnd(20)} ${String(summary.combo.added_count).padStart(10)}`);
79
+ console.log(` ${'Removed'.padEnd(20)} ${String(summary.combo.removed_count).padStart(10)}`);
80
+ hr();
81
+ if (summary.added.length) {
82
+ console.log(` + Added to combo (${summary.added.length}):`);
83
+ for (const m of summary.added) console.log(` - ${m}`);
84
+ } else {
85
+ console.log(' + Added to combo: (none)');
86
+ }
87
+ if (summary.removed.length) {
88
+ console.log(` - Removed from combo (${summary.removed.length}):`);
89
+ for (const m of summary.removed) console.log(` - ${m}`);
90
+ } else {
91
+ console.log(' - Removed from combo: (none)');
92
+ }
93
+ hr();
94
+ console.log(' Per-provider breakdown (new combo):');
95
+ for (const [provider, cnt] of Object.entries(summary.by_provider).sort()) {
96
+ console.log(` ${provider.padEnd(22)} ${String(cnt).padStart(3)} models`);
97
+ }
98
+ if (summary.failed.length) {
99
+ hr();
100
+ console.log(` Failed models (${summary.failed.length}):`);
101
+ for (const r of summary.failed.slice(0, 10)) {
102
+ const detail = (r.detail || 'unknown').slice(0, 60);
103
+ console.log(` - ${r.model}: ${detail}`);
104
+ }
105
+ if (summary.failed.length > 10) {
106
+ console.log(` ... and ${summary.failed.length - 10} more`);
107
+ }
108
+ }
109
+ hr();
110
+ }
111
+
112
+ export function main() {
113
+ loadEnv();
114
+ const auditPath = _resolveAuditPath();
115
+ if (!fs.existsSync(auditPath)) {
116
+ console.log('ERROR: Audit log tidak ditemukan:');
117
+ console.log(` ${auditPath}`);
118
+ console.log('\nJalankan scan terlebih dahulu:');
119
+ console.log(' npm run scan (or) node src/cli.js scan');
120
+ process.exit(1);
121
+ }
122
+ try {
123
+ const d = readAudit(auditPath);
124
+ const summary = summarizeAudit(d);
125
+ printSummary(summary);
126
+ } catch (e) {
127
+ if (e instanceof SyntaxError) {
128
+ console.log(`ERROR: Invalid JSON di audit log: ${e.message}`);
129
+ } else {
130
+ console.log(`ERROR: Gagal baca audit log: ${e.message}`);
131
+ }
132
+ process.exit(1);
133
+ }
134
+ }