@tikoci/rosetta 0.4.2 → 0.4.4
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/README.md +11 -3
- package/package.json +1 -1
- package/src/browse.ts +1234 -0
- package/src/db.ts +82 -0
- package/src/extract-devices.ts +14 -1
- package/src/extract-videos.test.ts +356 -0
- package/src/extract-videos.ts +734 -0
- package/src/mcp-http.test.ts +5 -5
- package/src/mcp.ts +61 -0
- package/src/query.test.ts +187 -1
- package/src/query.ts +51 -0
- package/src/release.test.ts +12 -0
package/src/browse.ts
ADDED
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* browse.ts — Interactive terminal browser for RouterOS documentation.
|
|
4
|
+
*
|
|
5
|
+
* Card-catalog REPL: search → numbered results → select to drill in → hints lead to next query.
|
|
6
|
+
* Wraps all query functions from query.ts — no new SQL, purely a presentation layer.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun run src/browse.ts Interactive mode
|
|
10
|
+
* bun run src/browse.ts "firewall filter" Search then interactive
|
|
11
|
+
* bun run src/browse.ts --once "dhcp" Search, print, exit (for piping)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as readline from "node:readline";
|
|
15
|
+
import { db, getDbStats, initDb } from "./db.ts";
|
|
16
|
+
import { resolveVersion } from "./paths.ts";
|
|
17
|
+
import type {
|
|
18
|
+
ChangelogResult,
|
|
19
|
+
DeviceResult,
|
|
20
|
+
DeviceTestRow,
|
|
21
|
+
SearchResponse,
|
|
22
|
+
SearchResult,
|
|
23
|
+
SectionTocEntry,
|
|
24
|
+
VideoSearchResult,
|
|
25
|
+
} from "./query.ts";
|
|
26
|
+
import {
|
|
27
|
+
browseCommands,
|
|
28
|
+
checkCommandVersions,
|
|
29
|
+
diffCommandVersions,
|
|
30
|
+
fetchCurrentVersions,
|
|
31
|
+
getPage,
|
|
32
|
+
getTestResultMeta,
|
|
33
|
+
lookupProperty,
|
|
34
|
+
searchCallouts,
|
|
35
|
+
searchChangelogs,
|
|
36
|
+
searchDevices,
|
|
37
|
+
searchDeviceTests,
|
|
38
|
+
searchPages,
|
|
39
|
+
searchProperties,
|
|
40
|
+
searchVideos,
|
|
41
|
+
} from "./query.ts";
|
|
42
|
+
|
|
43
|
+
// ── ANSI utilities (zero deps) ──
|
|
44
|
+
|
|
45
|
+
const ESC = "\x1b";
|
|
46
|
+
const bold = (s: string) => `${ESC}[1m${s}${ESC}[0m`;
|
|
47
|
+
const dim = (s: string) => `${ESC}[2m${s}${ESC}[0m`;
|
|
48
|
+
const _italic = (s: string) => `${ESC}[3m${s}${ESC}[0m`;
|
|
49
|
+
const _underline = (s: string) => `${ESC}[4m${s}${ESC}[0m`;
|
|
50
|
+
const cyan = (s: string) => `${ESC}[36m${s}${ESC}[0m`;
|
|
51
|
+
const yellow = (s: string) => `${ESC}[33m${s}${ESC}[0m`;
|
|
52
|
+
const green = (s: string) => `${ESC}[32m${s}${ESC}[0m`;
|
|
53
|
+
const red = (s: string) => `${ESC}[31m${s}${ESC}[0m`;
|
|
54
|
+
const magenta = (s: string) => `${ESC}[35m${s}${ESC}[0m`;
|
|
55
|
+
const blue = (s: string) => `${ESC}[34m${s}${ESC}[0m`;
|
|
56
|
+
const _bgDim = (s: string) => `${ESC}[48;5;236m${s}${ESC}[0m`;
|
|
57
|
+
|
|
58
|
+
/** OSC 8 clickable hyperlink (iTerm2, macOS Terminal, Windows Terminal, etc.) */
|
|
59
|
+
function link(url: string, display?: string): string {
|
|
60
|
+
return `${ESC}]8;;${url}\x07${display ?? url}${ESC}]8;;\x07`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Terminal width, with fallback */
|
|
64
|
+
function termWidth(): number {
|
|
65
|
+
return process.stdout.columns || 80;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Terminal height, with fallback */
|
|
69
|
+
function termHeight(): number {
|
|
70
|
+
return process.stdout.rows || 24;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Truncate string to width, adding … if needed */
|
|
74
|
+
function truncate(s: string, max: number): string {
|
|
75
|
+
if (s.length <= max) return s;
|
|
76
|
+
return `${s.slice(0, max - 1)}…`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Right-pad to width */
|
|
80
|
+
function pad(s: string, w: number): string {
|
|
81
|
+
const visible = stripAnsi(s);
|
|
82
|
+
return visible.length >= w ? s : s + " ".repeat(w - visible.length);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Strip ANSI escape codes for length calculation */
|
|
86
|
+
function stripAnsi(s: string): string {
|
|
87
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape code matching requires \x1b and \x07
|
|
88
|
+
return s.replace(/\x1b\][^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Format number with comma separators */
|
|
92
|
+
function fmt(n: number): string {
|
|
93
|
+
return n.toLocaleString("en-US");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Draw a box with unicode box-drawing characters */
|
|
97
|
+
function box(lines: string[], title?: string): string {
|
|
98
|
+
const w = termWidth();
|
|
99
|
+
const inner = w - 4;
|
|
100
|
+
const top = title
|
|
101
|
+
? `╭─ ${bold(title)} ${"─".repeat(Math.max(0, inner - stripAnsi(title).length - 2))}╮`
|
|
102
|
+
: `╭${"─".repeat(w - 2)}╮`;
|
|
103
|
+
const bottom = `╰${"─".repeat(w - 2)}╯`;
|
|
104
|
+
const body = lines.map((l) => {
|
|
105
|
+
const visible = stripAnsi(l);
|
|
106
|
+
const padding = Math.max(0, inner - visible.length);
|
|
107
|
+
return `│ ${l}${" ".repeat(padding)} │`;
|
|
108
|
+
});
|
|
109
|
+
return [top, ...body, bottom].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Format a horizontal rule */
|
|
113
|
+
function hr(): string {
|
|
114
|
+
return dim("─".repeat(termWidth()));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Callout type icon + color */
|
|
118
|
+
function calloutPrefix(type: string): string {
|
|
119
|
+
switch (type.toLowerCase()) {
|
|
120
|
+
case "warning": return yellow("⚠ Warning:");
|
|
121
|
+
case "note": return blue("📝 Note:");
|
|
122
|
+
case "info": return cyan("ℹ Info:");
|
|
123
|
+
case "tip": return green("✓ Tip:");
|
|
124
|
+
default: return dim(`[${type}]`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Format seconds as HH:MM:SS or MM:SS */
|
|
129
|
+
function formatTime(seconds: number): string {
|
|
130
|
+
const h = Math.floor(seconds / 3600);
|
|
131
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
132
|
+
const s = seconds % 60;
|
|
133
|
+
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
134
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Pager ──
|
|
138
|
+
|
|
139
|
+
/** Output lines with paging. Returns true if user quit early. */
|
|
140
|
+
async function paged(output: string): Promise<boolean> {
|
|
141
|
+
const lines = output.split("\n");
|
|
142
|
+
const pageSize = termHeight() - 2;
|
|
143
|
+
if (lines.length <= pageSize || !process.stdout.isTTY) {
|
|
144
|
+
process.stdout.write(`${output}\n`);
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
let offset = 0;
|
|
148
|
+
while (offset < lines.length) {
|
|
149
|
+
const chunk = lines.slice(offset, offset + pageSize);
|
|
150
|
+
process.stdout.write(`${chunk.join("\n")}\n`);
|
|
151
|
+
offset += pageSize;
|
|
152
|
+
if (offset < lines.length) {
|
|
153
|
+
const remaining = lines.length - offset;
|
|
154
|
+
process.stdout.write(dim(`── ${remaining} more lines (Enter=next, q=stop) ──`));
|
|
155
|
+
const key = await waitForKey();
|
|
156
|
+
// Clear the "more" line
|
|
157
|
+
process.stdout.write(`\r${" ".repeat(termWidth())}\r`);
|
|
158
|
+
if (key === "q" || key === "Q") return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function waitForKey(): Promise<string> {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
if (!process.stdin.isTTY) { resolve("\n"); return; }
|
|
167
|
+
const wasRaw = process.stdin.isRaw;
|
|
168
|
+
process.stdin.setRawMode(true);
|
|
169
|
+
process.stdin.resume();
|
|
170
|
+
process.stdin.once("data", (data) => {
|
|
171
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
172
|
+
const ch = data.toString();
|
|
173
|
+
// Ctrl-C / Ctrl-D
|
|
174
|
+
if (ch === "\x03" || ch === "\x04") { process.exit(0); }
|
|
175
|
+
resolve(ch);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── REPL State ──
|
|
181
|
+
|
|
182
|
+
type Context =
|
|
183
|
+
| { type: "home" }
|
|
184
|
+
| { type: "search"; response: SearchResponse; results: SearchResult[] }
|
|
185
|
+
| { type: "page"; pageId: number; title: string; commandPath?: string }
|
|
186
|
+
| { type: "sections"; pageId: number; title: string; sections: SectionTocEntry[] }
|
|
187
|
+
| { type: "properties"; query: string; pageId?: number }
|
|
188
|
+
| { type: "commands"; path: string }
|
|
189
|
+
| { type: "devices"; query: string; results: DeviceResult[] }
|
|
190
|
+
| { type: "device"; device: DeviceResult }
|
|
191
|
+
| { type: "tests" }
|
|
192
|
+
| { type: "callouts"; query: string }
|
|
193
|
+
| { type: "changelogs" }
|
|
194
|
+
| { type: "videos"; query: string }
|
|
195
|
+
| { type: "diff" }
|
|
196
|
+
| { type: "vcheck"; path: string };
|
|
197
|
+
|
|
198
|
+
let ctx: Context = { type: "home" };
|
|
199
|
+
const history: Context[] = [];
|
|
200
|
+
|
|
201
|
+
function pushCtx(next: Context) {
|
|
202
|
+
history.push(ctx);
|
|
203
|
+
ctx = next;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function popCtx(): boolean {
|
|
207
|
+
const prev = history.pop();
|
|
208
|
+
if (prev) { ctx = prev; return true; }
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Renderers ──
|
|
213
|
+
|
|
214
|
+
function renderWelcome(): string {
|
|
215
|
+
const stats = getDbStats();
|
|
216
|
+
const version = resolveVersion(import.meta.dirname);
|
|
217
|
+
const lines = [
|
|
218
|
+
`RouterOS Documentation Browser ${dim(`v${version}`)}`,
|
|
219
|
+
`${fmt(stats.pages)} pages · ${fmt(stats.properties)} properties · ${fmt(stats.commands)} commands`,
|
|
220
|
+
`${fmt(stats.devices)} devices · ${fmt(stats.callouts)} callouts · ${fmt(stats.ros_versions)} versions`,
|
|
221
|
+
...(stats.videos > 0 ? [`${fmt(stats.videos)} videos · ${fmt(stats.video_segments)} transcript segments`] : []),
|
|
222
|
+
"",
|
|
223
|
+
`Type a search query, or ${bold("help")} for commands.`,
|
|
224
|
+
];
|
|
225
|
+
return box(lines, "rosetta");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderSearchResults(resp: SearchResponse): string {
|
|
229
|
+
const out: string[] = [];
|
|
230
|
+
const modeNote = resp.fallbackMode === "or" ? dim(" (OR fallback)") : "";
|
|
231
|
+
out.push(` ${bold(String(resp.results.length))} of ${resp.total} results for ${cyan(`"${resp.query}"`)}${modeNote}`);
|
|
232
|
+
out.push("");
|
|
233
|
+
|
|
234
|
+
const w = termWidth();
|
|
235
|
+
for (let i = 0; i < resp.results.length; i++) {
|
|
236
|
+
const r = resp.results[i];
|
|
237
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
238
|
+
const title = bold(truncate(r.title, 30));
|
|
239
|
+
const path = dim(truncate(r.path, Math.max(20, w - 55)));
|
|
240
|
+
const meta = dim(`${fmt(r.word_count)}w`);
|
|
241
|
+
out.push(`${num}${pad(title, 32)} ${pad(path, Math.max(20, w - 55))} ${meta}`);
|
|
242
|
+
if (r.url) {
|
|
243
|
+
out.push(` ${cyan(link(r.url, dim(r.url)))}`);
|
|
244
|
+
}
|
|
245
|
+
// Show excerpt with highlight markers converted to bold
|
|
246
|
+
const excerpt = r.excerpt.replace(/>>>/g, `${ESC}[1m`).replace(/<<</g, `${ESC}[0m`);
|
|
247
|
+
out.push(` ${dim(truncate(excerpt, w - 8))}`);
|
|
248
|
+
out.push("");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Navigation hints
|
|
252
|
+
const hints = [
|
|
253
|
+
`${cyan("[N]")} view page`,
|
|
254
|
+
`${cyan("[s <query>]")} search`,
|
|
255
|
+
`${cyan("[p <query>]")} properties`,
|
|
256
|
+
`${cyan("[cmd <path>]")} commands`,
|
|
257
|
+
];
|
|
258
|
+
out.push(` ${hints.join(" ")}`);
|
|
259
|
+
return out.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
263
|
+
const out: string[] = [];
|
|
264
|
+
const w = termWidth();
|
|
265
|
+
|
|
266
|
+
out.push("");
|
|
267
|
+
out.push(` ${bold("══")} ${bold(page.title)} ${bold("═".repeat(Math.max(0, w - stripAnsi(page.title).length - 8)))}`);
|
|
268
|
+
out.push(` ${dim(page.path)}`);
|
|
269
|
+
out.push(` ${cyan(link(page.url))}`);
|
|
270
|
+
|
|
271
|
+
const meta: string[] = [`${fmt(page.word_count)} words`];
|
|
272
|
+
if (page.code_lines) meta.push(`${page.code_lines} code lines`);
|
|
273
|
+
if (page.callouts.length > 0) {
|
|
274
|
+
const types: Record<string, number> = {};
|
|
275
|
+
for (const c of page.callouts) types[c.type] = (types[c.type] || 0) + 1;
|
|
276
|
+
const parts = Object.entries(types).map(([t, n]) => {
|
|
277
|
+
const icon = t === "Warning" ? "⚠" : t === "Note" ? "📝" : t === "Info" ? "ℹ" : "✓";
|
|
278
|
+
return `${n}${icon}`;
|
|
279
|
+
});
|
|
280
|
+
meta.push(`${page.callouts.length} callouts (${parts.join(" ")})`);
|
|
281
|
+
}
|
|
282
|
+
out.push(` ${dim(meta.join(" · "))}`);
|
|
283
|
+
out.push("");
|
|
284
|
+
|
|
285
|
+
// Sections TOC (if available)
|
|
286
|
+
if (page.sections && page.sections.length > 0) {
|
|
287
|
+
out.push(` ${bold("Sections:")}`);
|
|
288
|
+
for (let i = 0; i < page.sections.length; i++) {
|
|
289
|
+
const s = page.sections[i];
|
|
290
|
+
const indent = " ".repeat(Math.max(0, s.level - 1));
|
|
291
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
292
|
+
const chars = dim(`(${fmt(s.char_count)} chars)`);
|
|
293
|
+
out.push(` ${num}${indent}${s.heading} ${chars}`);
|
|
294
|
+
}
|
|
295
|
+
out.push("");
|
|
296
|
+
if (page.note) out.push(` ${dim(page.note)}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Callout summary or full callouts
|
|
300
|
+
if (page.callout_summary) {
|
|
301
|
+
const cs = page.callout_summary;
|
|
302
|
+
const typeParts = Object.entries(cs.types).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
303
|
+
out.push(` ${dim(`Callouts: ${cs.count} total (${typeParts}) — view with`)} ${cyan("cal")}`);
|
|
304
|
+
out.push("");
|
|
305
|
+
} else if (page.callouts.length > 0) {
|
|
306
|
+
for (const c of page.callouts) {
|
|
307
|
+
const prefix = calloutPrefix(c.type);
|
|
308
|
+
const content = truncate(c.content, w - 6);
|
|
309
|
+
out.push(` ${prefix} ${content}`);
|
|
310
|
+
}
|
|
311
|
+
out.push("");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Text content (if present and not a TOC-only view)
|
|
315
|
+
if (page.text && !page.sections) {
|
|
316
|
+
out.push(hr());
|
|
317
|
+
out.push(page.text);
|
|
318
|
+
if (page.code) {
|
|
319
|
+
out.push("");
|
|
320
|
+
out.push(dim("── code ──"));
|
|
321
|
+
out.push(page.code);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (page.truncated) {
|
|
326
|
+
out.push("");
|
|
327
|
+
out.push(dim(` [truncated: ${fmt(page.truncated.text_total)} text chars, ${fmt(page.truncated.code_total)} code chars]`));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Section content (when section was requested)
|
|
331
|
+
if (page.section) {
|
|
332
|
+
out.push(hr());
|
|
333
|
+
out.push(` ${bold(`§ ${page.section.heading}`)} ${dim(`(level ${page.section.level})`)}`);
|
|
334
|
+
out.push("");
|
|
335
|
+
if (page.text) out.push(page.text);
|
|
336
|
+
if (page.code) {
|
|
337
|
+
out.push("");
|
|
338
|
+
out.push(dim("── code ──"));
|
|
339
|
+
out.push(page.code);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Navigation hints
|
|
344
|
+
out.push("");
|
|
345
|
+
const hints: string[] = [];
|
|
346
|
+
if (page.sections && page.sections.length > 0) hints.push(`${cyan("[N]")} section`);
|
|
347
|
+
hints.push(`${cyan("[p]")} properties`);
|
|
348
|
+
hints.push(`${cyan("[cmd]")} command tree`);
|
|
349
|
+
hints.push(`${cyan("[cal]")} callouts`);
|
|
350
|
+
hints.push(`${cyan("[b]")} back`);
|
|
351
|
+
out.push(` ${hints.join(" ")}`);
|
|
352
|
+
|
|
353
|
+
return out.join("\n");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renderProperties(results: Array<{
|
|
357
|
+
name: string;
|
|
358
|
+
type: string | null;
|
|
359
|
+
default_val: string | null;
|
|
360
|
+
description: string;
|
|
361
|
+
section: string | null;
|
|
362
|
+
page_title: string;
|
|
363
|
+
page_url: string;
|
|
364
|
+
excerpt?: string;
|
|
365
|
+
}>): string {
|
|
366
|
+
const out: string[] = [];
|
|
367
|
+
if (results.length === 0) {
|
|
368
|
+
out.push(` ${dim("No properties found.")}`);
|
|
369
|
+
return out.join("\n");
|
|
370
|
+
}
|
|
371
|
+
const w = termWidth();
|
|
372
|
+
|
|
373
|
+
for (let i = 0; i < results.length; i++) {
|
|
374
|
+
const p = results[i];
|
|
375
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
376
|
+
out.push(`${num}${bold(p.name)} ${dim(p.type ?? "")} ${p.default_val ? dim(`default: ${p.default_val}`) : ""}`);
|
|
377
|
+
const desc = truncate(p.description, w - 8);
|
|
378
|
+
out.push(` ${desc}`);
|
|
379
|
+
out.push(` ${dim(p.page_title)} ${cyan(link(p.page_url, dim("→")))}`);
|
|
380
|
+
out.push("");
|
|
381
|
+
}
|
|
382
|
+
return out.join("\n");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderCommandTree(path: string, children: Array<{
|
|
386
|
+
path: string;
|
|
387
|
+
name: string;
|
|
388
|
+
type: string;
|
|
389
|
+
description: string | null;
|
|
390
|
+
page_title: string | null;
|
|
391
|
+
page_url: string | null;
|
|
392
|
+
}>): string {
|
|
393
|
+
const out: string[] = [];
|
|
394
|
+
out.push(` ${bold(path || "/")} ${dim(`(${children.length} children)`)}`);
|
|
395
|
+
out.push("");
|
|
396
|
+
|
|
397
|
+
const dirs = children.filter((c) => c.type === "dir");
|
|
398
|
+
const cmds = children.filter((c) => c.type === "cmd");
|
|
399
|
+
const args = children.filter((c) => c.type === "arg");
|
|
400
|
+
|
|
401
|
+
for (const group of [
|
|
402
|
+
{ items: dirs, icon: "📁", label: "directories" },
|
|
403
|
+
{ items: cmds, icon: "⚡", label: "commands" },
|
|
404
|
+
{ items: args, icon: " ", label: "arguments" },
|
|
405
|
+
]) {
|
|
406
|
+
if (group.items.length === 0) continue;
|
|
407
|
+
for (const c of group.items) {
|
|
408
|
+
const icon = group.icon;
|
|
409
|
+
const name = c.type === "dir" ? bold(c.name) : c.name;
|
|
410
|
+
const desc = c.description ? dim(` — ${truncate(c.description, 50)}`) : "";
|
|
411
|
+
const pageLink = c.page_url ? ` ${cyan(link(c.page_url, dim("📄")))}` : "";
|
|
412
|
+
out.push(` ${icon} ${name}${desc}${pageLink}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
out.push("");
|
|
417
|
+
const hints: string[] = [
|
|
418
|
+
`${cyan("[cmd <child>]")} drill down`,
|
|
419
|
+
`${cyan("[page <id>]")} view linked page`,
|
|
420
|
+
`${cyan("[b]")} back`,
|
|
421
|
+
];
|
|
422
|
+
out.push(` ${hints.join(" ")}`);
|
|
423
|
+
return out.join("\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderDeviceResults(results: DeviceResult[], mode: string, total: number): string {
|
|
427
|
+
const out: string[] = [];
|
|
428
|
+
out.push(` ${bold(String(results.length))} of ${total} devices ${dim(`(${mode})`)}`);
|
|
429
|
+
out.push("");
|
|
430
|
+
|
|
431
|
+
if (results.length === 1) {
|
|
432
|
+
return out.join("\n") + renderDeviceCard(results[0]);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (let i = 0; i < results.length; i++) {
|
|
436
|
+
const d = results[i];
|
|
437
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
438
|
+
const name = bold(d.product_name);
|
|
439
|
+
const arch = dim(d.architecture ?? "");
|
|
440
|
+
const ram = d.ram_mb ? dim(`${d.ram_mb}MB`) : "";
|
|
441
|
+
const price = d.msrp_usd ? green(`$${d.msrp_usd}`) : "";
|
|
442
|
+
out.push(`${num}${name} ${arch} ${ram} ${price}`);
|
|
443
|
+
const parts: string[] = [];
|
|
444
|
+
if (d.cpu) parts.push(d.cpu);
|
|
445
|
+
if (d.eth_gigabit) parts.push(`${d.eth_gigabit}×GbE`);
|
|
446
|
+
if (d.sfp_plus_ports) parts.push(`${d.sfp_plus_ports}×SFP+`);
|
|
447
|
+
if (d.wireless_5_chains) parts.push(`Wi-Fi`);
|
|
448
|
+
if (parts.length > 0) out.push(` ${dim(parts.join(" · "))}`);
|
|
449
|
+
out.push("");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
out.push(` ${cyan("[N]")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
|
|
453
|
+
return out.join("\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderDeviceCard(d: DeviceResult): string {
|
|
457
|
+
const out: string[] = [];
|
|
458
|
+
out.push(` ${bold("══")} ${bold(d.product_name)} ${bold("══")}`);
|
|
459
|
+
if (d.product_code) out.push(` ${dim(`Code: ${d.product_code}`)}`);
|
|
460
|
+
if (d.product_url) out.push(` ${cyan(link(d.product_url))}`);
|
|
461
|
+
out.push("");
|
|
462
|
+
|
|
463
|
+
const kv = (label: string, value: string | number | null | undefined) => {
|
|
464
|
+
if (value === null || value === undefined) return;
|
|
465
|
+
out.push(` ${dim(pad(label, 20))} ${value}`);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
kv("Architecture", d.architecture);
|
|
469
|
+
kv("CPU", d.cpu);
|
|
470
|
+
if (d.cpu_cores) kv("Cores / Freq", `${d.cpu_cores} × ${d.cpu_frequency ?? "?"}`);
|
|
471
|
+
kv("License Level", d.license_level);
|
|
472
|
+
kv("RAM", d.ram ? `${d.ram} (${d.ram_mb}MB)` : null);
|
|
473
|
+
kv("Storage", d.storage ? `${d.storage} (${d.storage_mb}MB)` : null);
|
|
474
|
+
if (d.eth_fast || d.eth_gigabit || d.eth_2500) {
|
|
475
|
+
const ports: string[] = [];
|
|
476
|
+
if (d.eth_fast) ports.push(`${d.eth_fast}×100M`);
|
|
477
|
+
if (d.eth_gigabit) ports.push(`${d.eth_gigabit}×1G`);
|
|
478
|
+
if (d.eth_2500) ports.push(`${d.eth_2500}×2.5G`);
|
|
479
|
+
kv("Ethernet", ports.join(" + "));
|
|
480
|
+
}
|
|
481
|
+
if (d.sfp_ports || d.sfp_plus_ports) {
|
|
482
|
+
const sfp: string[] = [];
|
|
483
|
+
if (d.sfp_ports) sfp.push(`${d.sfp_ports}×SFP`);
|
|
484
|
+
if (d.sfp_plus_ports) sfp.push(`${d.sfp_plus_ports}×SFP+`);
|
|
485
|
+
kv("SFP", sfp.join(" + "));
|
|
486
|
+
}
|
|
487
|
+
kv("PoE In", d.poe_in);
|
|
488
|
+
kv("PoE Out", d.poe_out);
|
|
489
|
+
kv("Max Power", d.max_power_w ? `${d.max_power_w}W` : null);
|
|
490
|
+
if (d.wireless_24_chains || d.wireless_5_chains) {
|
|
491
|
+
kv("Wireless", `2.4GHz: ${d.wireless_24_chains ?? 0} chains, 5GHz: ${d.wireless_5_chains ?? 0} chains`);
|
|
492
|
+
}
|
|
493
|
+
if (d.usb_ports) kv("USB", d.usb_ports);
|
|
494
|
+
if (d.sim_slots) kv("SIM Slots", d.sim_slots);
|
|
495
|
+
if (d.msrp_usd) kv("MSRP", green(`$${d.msrp_usd}`));
|
|
496
|
+
if (d.block_diagram_url) {
|
|
497
|
+
out.push("");
|
|
498
|
+
out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Test results (attached for exact matches)
|
|
502
|
+
if (d.test_results && d.test_results.length > 0) {
|
|
503
|
+
out.push("");
|
|
504
|
+
out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
|
|
505
|
+
for (const t of d.test_results.slice(0, 12)) {
|
|
506
|
+
const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
|
|
507
|
+
const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
|
|
508
|
+
out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
|
|
509
|
+
}
|
|
510
|
+
if (d.test_results.length > 12) {
|
|
511
|
+
out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
out.push("");
|
|
516
|
+
out.push(` ${cyan("[tests]")} benchmarks ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
|
|
517
|
+
return out.join("\n");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function renderTests(results: DeviceTestRow[], total: number): string {
|
|
521
|
+
const out: string[] = [];
|
|
522
|
+
out.push(` ${bold(String(results.length))} of ${total} test results`);
|
|
523
|
+
out.push("");
|
|
524
|
+
|
|
525
|
+
// Header
|
|
526
|
+
out.push(` ${dim(pad("Device", 24))} ${dim(pad("Type", 9))} ${dim(pad("Mode", 16))} ${dim(pad("Config", 28))} ${dim(pad("Pkt", 6))} ${dim(pad("Mbps", 10))} ${dim("Kpps")}`);
|
|
527
|
+
out.push(` ${dim("─".repeat(Math.min(termWidth() - 4, 105)))}`);
|
|
528
|
+
|
|
529
|
+
for (const t of results) {
|
|
530
|
+
const mbps = t.throughput_mbps != null ? fmt(t.throughput_mbps) : "—";
|
|
531
|
+
const kpps = t.throughput_kpps != null ? fmt(t.throughput_kpps) : "—";
|
|
532
|
+
out.push(` ${pad(truncate(t.product_name, 24), 24)} ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(truncate(t.configuration, 28), 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(pad(mbps, 10))} ${dim(kpps)}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (total > results.length) {
|
|
536
|
+
out.push(` ${dim(`... ${total - results.length} more results`)}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
out.push("");
|
|
540
|
+
out.push(` ${cyan("[dev <name>]")} device details ${cyan("[b]")} back`);
|
|
541
|
+
return out.join("\n");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function renderCallouts(results: Array<{
|
|
545
|
+
type: string;
|
|
546
|
+
content: string;
|
|
547
|
+
page_title: string;
|
|
548
|
+
page_url: string;
|
|
549
|
+
page_id: number;
|
|
550
|
+
excerpt: string;
|
|
551
|
+
}>): string {
|
|
552
|
+
const out: string[] = [];
|
|
553
|
+
if (results.length === 0) {
|
|
554
|
+
out.push(` ${dim("No callouts found.")}`);
|
|
555
|
+
return out.join("\n");
|
|
556
|
+
}
|
|
557
|
+
const w = termWidth();
|
|
558
|
+
|
|
559
|
+
for (let i = 0; i < results.length; i++) {
|
|
560
|
+
const c = results[i];
|
|
561
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
562
|
+
const prefix = calloutPrefix(c.type);
|
|
563
|
+
// Use excerpt if it has highlights, otherwise truncate content
|
|
564
|
+
const text = c.excerpt.includes("**")
|
|
565
|
+
? c.excerpt.replace(/\*\*/g, `${ESC}[1m`)
|
|
566
|
+
: truncate(c.content, w - 12);
|
|
567
|
+
out.push(`${num}${prefix}`);
|
|
568
|
+
out.push(` ${text}`);
|
|
569
|
+
out.push(` ${dim(c.page_title)} ${cyan(link(c.page_url, dim(`[${c.page_id}]`)))}`);
|
|
570
|
+
out.push("");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
out.push(` ${cyan("[page <id>]")} view page ${cyan("[s <query>]")} search ${cyan("[b]")} back`);
|
|
574
|
+
return out.join("\n");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function renderChangelogs(results: ChangelogResult[]): string {
|
|
578
|
+
const out: string[] = [];
|
|
579
|
+
if (results.length === 0) {
|
|
580
|
+
out.push(` ${dim("No changelog entries found.")}`);
|
|
581
|
+
return out.join("\n");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let lastVersion = "";
|
|
585
|
+
for (const c of results) {
|
|
586
|
+
if (c.version !== lastVersion) {
|
|
587
|
+
out.push("");
|
|
588
|
+
out.push(` ${bold(c.version)} ${dim(c.released ?? "")}`);
|
|
589
|
+
lastVersion = c.version;
|
|
590
|
+
}
|
|
591
|
+
const breaking = c.is_breaking ? red("⚠ ") : " ";
|
|
592
|
+
const cat = dim(pad(c.category, 14));
|
|
593
|
+
const desc = c.excerpt.includes("**")
|
|
594
|
+
? c.excerpt.replace(/\*\*/g, `${ESC}[1m`)
|
|
595
|
+
: truncate(c.description, termWidth() - 22);
|
|
596
|
+
out.push(` ${breaking}${cat} ${desc}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
out.push("");
|
|
600
|
+
out.push(` ${cyan("[cl breaking]")} breaking only ${cyan("[cl <ver>]")} specific version ${cyan("[b]")} back`);
|
|
601
|
+
return out.join("\n");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function renderVideos(results: VideoSearchResult[]): string {
|
|
605
|
+
const out: string[] = [];
|
|
606
|
+
if (results.length === 0) {
|
|
607
|
+
out.push(` ${dim("No video results found.")}`);
|
|
608
|
+
return out.join("\n");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
for (let i = 0; i < results.length; i++) {
|
|
612
|
+
const v = results[i];
|
|
613
|
+
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
614
|
+
const title = bold(truncate(v.title, 60));
|
|
615
|
+
const date = v.upload_date ? dim(v.upload_date.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3")) : "";
|
|
616
|
+
out.push(`${num}${title} ${date}`);
|
|
617
|
+
if (v.chapter_title) {
|
|
618
|
+
const ts = formatTime(v.start_s);
|
|
619
|
+
out.push(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${ts}`)}`);
|
|
620
|
+
}
|
|
621
|
+
const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
|
|
622
|
+
out.push(` ${cyan(link(timeUrl))}`);
|
|
623
|
+
const excerpt = v.excerpt.replace(/\*\*/g, `${ESC}[1m`);
|
|
624
|
+
out.push(` ${dim(truncate(excerpt, termWidth() - 8))}`);
|
|
625
|
+
out.push("");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
out.push(` ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
|
|
629
|
+
return out.join("\n");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderDiff(result: ReturnType<typeof diffCommandVersions>): string {
|
|
633
|
+
const out: string[] = [];
|
|
634
|
+
out.push(` ${bold("Command diff:")} ${result.from_version} → ${result.to_version}`);
|
|
635
|
+
if (result.path_prefix) out.push(` ${dim(`Scope: ${result.path_prefix}`)}`);
|
|
636
|
+
out.push("");
|
|
637
|
+
|
|
638
|
+
if (result.added.length > 0) {
|
|
639
|
+
out.push(` ${green(`+ ${result.added_count} added:`)}`);
|
|
640
|
+
for (const p of result.added.slice(0, 30)) out.push(` ${green("+")} ${p}`);
|
|
641
|
+
if (result.added.length > 30) out.push(` ${dim(`... and ${result.added.length - 30} more`)}`);
|
|
642
|
+
out.push("");
|
|
643
|
+
}
|
|
644
|
+
if (result.removed.length > 0) {
|
|
645
|
+
out.push(` ${red(`- ${result.removed_count} removed:`)}`);
|
|
646
|
+
for (const p of result.removed.slice(0, 30)) out.push(` ${red("-")} ${p}`);
|
|
647
|
+
if (result.removed.length > 30) out.push(` ${dim(`... and ${result.removed.length - 30} more`)}`);
|
|
648
|
+
out.push("");
|
|
649
|
+
}
|
|
650
|
+
if (result.added.length === 0 && result.removed.length === 0) {
|
|
651
|
+
out.push(` ${dim("No structural differences found.")}`);
|
|
652
|
+
}
|
|
653
|
+
if (result.note) out.push(` ${dim(result.note)}`);
|
|
654
|
+
|
|
655
|
+
out.push("");
|
|
656
|
+
out.push(` ${cyan("[cl <from>..<to>]")} changelogs ${cyan("[vc <path>]")} version check ${cyan("[b]")} back`);
|
|
657
|
+
return out.join("\n");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function renderVersionCheck(result: ReturnType<typeof checkCommandVersions>): string {
|
|
661
|
+
const out: string[] = [];
|
|
662
|
+
out.push(` ${bold(result.command_path)}`);
|
|
663
|
+
out.push("");
|
|
664
|
+
|
|
665
|
+
if (result.versions.length === 0) {
|
|
666
|
+
out.push(` ${dim("No version data found.")}`);
|
|
667
|
+
} else {
|
|
668
|
+
out.push(` ${dim("First seen:")} ${bold(result.first_seen ?? "?")} ${dim("Last seen:")} ${bold(result.last_seen ?? "?")}`);
|
|
669
|
+
out.push(` ${dim("Present in")} ${bold(String(result.versions.length))} ${dim("versions")}`);
|
|
670
|
+
// Show version range compactly
|
|
671
|
+
const display = result.versions.length <= 10
|
|
672
|
+
? result.versions.join(", ")
|
|
673
|
+
: `${result.versions.slice(0, 5).join(", ")} … ${result.versions.slice(-3).join(", ")}`;
|
|
674
|
+
out.push(` ${dim(display)}`);
|
|
675
|
+
}
|
|
676
|
+
if (result.note) {
|
|
677
|
+
out.push("");
|
|
678
|
+
out.push(` ${dim(result.note)}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
out.push("");
|
|
682
|
+
out.push(` ${cyan("[diff <from> <to>]")} version diff ${cyan("[cmd <path>]")} command tree ${cyan("[b]")} back`);
|
|
683
|
+
return out.join("\n");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function renderStats(): string {
|
|
687
|
+
const stats = getDbStats();
|
|
688
|
+
const out: string[] = [];
|
|
689
|
+
out.push(` ${bold("Database Statistics")}`);
|
|
690
|
+
out.push(` ${dim("Path:")} ${stats.db_path}`);
|
|
691
|
+
out.push(` ${dim("Export:")} ${stats.doc_export}`);
|
|
692
|
+
out.push("");
|
|
693
|
+
|
|
694
|
+
const kv = (label: string, value: string | number) => {
|
|
695
|
+
out.push(` ${dim(pad(label, 24))} ${bold(String(typeof value === "number" ? fmt(value) : value))}`);
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
kv("Pages", stats.pages);
|
|
699
|
+
kv("Sections", stats.sections);
|
|
700
|
+
kv("Properties", stats.properties);
|
|
701
|
+
kv("Callouts", stats.callouts);
|
|
702
|
+
kv("Commands", stats.commands);
|
|
703
|
+
kv("Commands linked", stats.commands_linked);
|
|
704
|
+
kv("Devices", stats.devices);
|
|
705
|
+
kv("Device test results", stats.device_test_results);
|
|
706
|
+
kv("Devices with tests", stats.devices_with_tests);
|
|
707
|
+
kv("Changelogs", stats.changelogs);
|
|
708
|
+
kv("Changelog versions", stats.changelog_versions);
|
|
709
|
+
kv("RouterOS versions", stats.ros_versions);
|
|
710
|
+
kv("Videos", stats.videos);
|
|
711
|
+
kv("Video segments", stats.video_segments);
|
|
712
|
+
kv("Version range", `${stats.ros_version_min ?? "?"}–${stats.ros_version_max ?? "?"}`);
|
|
713
|
+
|
|
714
|
+
return out.join("\n");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function renderHelp(): string {
|
|
718
|
+
const out: string[] = [];
|
|
719
|
+
out.push(` ${bold("Commands")} ${dim("(bare text = search)")}`);
|
|
720
|
+
out.push("");
|
|
721
|
+
|
|
722
|
+
const cmd = (name: string, alias: string, desc: string) => {
|
|
723
|
+
out.push(` ${cyan(pad(name, 26))} ${dim(pad(alias, 6))} ${desc}`);
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
cmd("<query>", "", "Search pages (default action)");
|
|
727
|
+
cmd("search <query>", "s", "Explicit page search");
|
|
728
|
+
cmd("page <id|title>", "", "View full page");
|
|
729
|
+
cmd("prop <name>", "p", "Look up property (scoped to current page)");
|
|
730
|
+
cmd("props <query>", "sp", "Search properties by FTS");
|
|
731
|
+
cmd("cmd [path]", "tree", "Browse command tree");
|
|
732
|
+
cmd("device <query>", "dev", "Look up device specs");
|
|
733
|
+
cmd("tests [type] [mode]", "", "Cross-device benchmarks");
|
|
734
|
+
cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)");
|
|
735
|
+
cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)");
|
|
736
|
+
cmd("videos <query>", "vid", "Search video transcripts");
|
|
737
|
+
cmd("diff <from> <to> [path]", "", "Command tree diff between versions");
|
|
738
|
+
cmd("vcheck <path>", "vc", "Version range for a command path");
|
|
739
|
+
cmd("versions", "ver", "Live-fetch current RouterOS versions");
|
|
740
|
+
cmd("stats", "", "Database health / counts");
|
|
741
|
+
cmd("back", "b", "Go to previous view");
|
|
742
|
+
cmd("help", "?", "This help");
|
|
743
|
+
cmd("quit", "q", "Exit");
|
|
744
|
+
|
|
745
|
+
out.push("");
|
|
746
|
+
out.push(` ${dim("Navigation: type a number to select from results.")}`);
|
|
747
|
+
out.push(` ${dim("After viewing a page, [p] = properties for that page.")}`);
|
|
748
|
+
out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
|
|
749
|
+
|
|
750
|
+
return out.join("\n");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Command dispatcher ──
|
|
754
|
+
|
|
755
|
+
async function dispatch(input: string): Promise<void> {
|
|
756
|
+
const trimmed = input.trim();
|
|
757
|
+
if (!trimmed) return;
|
|
758
|
+
|
|
759
|
+
// Parse command + args
|
|
760
|
+
const parts = trimmed.split(/\s+/);
|
|
761
|
+
const command = parts[0].toLowerCase();
|
|
762
|
+
const rest = parts.slice(1).join(" ");
|
|
763
|
+
|
|
764
|
+
// ── Bare number: select from current results ──
|
|
765
|
+
if (/^\d+$/.test(trimmed)) {
|
|
766
|
+
const idx = Number.parseInt(trimmed, 10) - 1;
|
|
767
|
+
await handleNumberSelect(idx);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── Commands ──
|
|
772
|
+
switch (command) {
|
|
773
|
+
case "q":
|
|
774
|
+
case "quit":
|
|
775
|
+
case "exit":
|
|
776
|
+
process.exit(0);
|
|
777
|
+
return; // unreachable — satisfies no-fallthrough lint
|
|
778
|
+
|
|
779
|
+
case "?":
|
|
780
|
+
case "help":
|
|
781
|
+
await paged(renderHelp());
|
|
782
|
+
return;
|
|
783
|
+
|
|
784
|
+
case "b":
|
|
785
|
+
case "back":
|
|
786
|
+
if (!popCtx()) {
|
|
787
|
+
console.log(dim(" Already at top."));
|
|
788
|
+
} else {
|
|
789
|
+
console.log(dim(` ← back to ${ctx.type}`));
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
|
|
793
|
+
case "stats":
|
|
794
|
+
await paged(renderStats());
|
|
795
|
+
return;
|
|
796
|
+
|
|
797
|
+
case "s":
|
|
798
|
+
case "search":
|
|
799
|
+
if (!rest) { console.log(dim(" Usage: search <query>")); return; }
|
|
800
|
+
await doSearch(rest);
|
|
801
|
+
return;
|
|
802
|
+
|
|
803
|
+
case "page": {
|
|
804
|
+
if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
|
|
805
|
+
await doPage(rest);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
case "p":
|
|
810
|
+
case "prop": {
|
|
811
|
+
if (!rest) {
|
|
812
|
+
// Context-scoped: show properties for current page
|
|
813
|
+
if (ctx.type === "page") {
|
|
814
|
+
const page = getPage(ctx.pageId, 0); // just get metadata
|
|
815
|
+
if (page) {
|
|
816
|
+
await doPropsForPage(ctx.pageId, ctx.title);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
console.log(dim(" Usage: prop <name> — or navigate to a page first"));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
await doLookupProperty(rest);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
case "sp":
|
|
828
|
+
case "props": {
|
|
829
|
+
if (!rest) { console.log(dim(" Usage: props <query>")); return; }
|
|
830
|
+
await doSearchProperties(rest);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
case "cmd":
|
|
835
|
+
case "tree": {
|
|
836
|
+
const path = rest || (ctx.type === "commands" ? ctx.path : "");
|
|
837
|
+
await doCommandTree(path);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
case "dev":
|
|
842
|
+
case "device": {
|
|
843
|
+
if (!rest) { console.log(dim(" Usage: device <query>")); return; }
|
|
844
|
+
await doDeviceLookup(rest);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
case "tests": {
|
|
849
|
+
await doTests(rest);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
case "cal":
|
|
854
|
+
case "callouts": {
|
|
855
|
+
if (!rest && ctx.type === "page") {
|
|
856
|
+
// Show callouts for current page
|
|
857
|
+
const results = searchCallouts("", undefined, 50);
|
|
858
|
+
const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
|
|
859
|
+
if (pageCallouts.length > 0) {
|
|
860
|
+
await paged(renderCallouts(pageCallouts));
|
|
861
|
+
pushCtx({ type: "callouts", query: "" });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
await doSearchCallouts(rest);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
case "cl":
|
|
870
|
+
case "changelog": {
|
|
871
|
+
await doSearchChangelogs(rest);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
case "vid":
|
|
876
|
+
case "videos": {
|
|
877
|
+
if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
|
|
878
|
+
await doSearchVideos(rest);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case "diff": {
|
|
883
|
+
const diffParts = rest.split(/\s+/);
|
|
884
|
+
if (diffParts.length < 2) {
|
|
885
|
+
console.log(dim(" Usage: diff <from_version> <to_version> [path_prefix]"));
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
await doDiff(diffParts[0], diffParts[1], diffParts[2]);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
case "vc":
|
|
893
|
+
case "vcheck": {
|
|
894
|
+
if (!rest) { console.log(dim(" Usage: vcheck <command_path>")); return; }
|
|
895
|
+
await doVersionCheck(rest);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
case "ver":
|
|
900
|
+
case "versions": {
|
|
901
|
+
await doCurrentVersions();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
default:
|
|
906
|
+
// Bare text = search
|
|
907
|
+
await doSearch(trimmed);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ── Number selection handler ──
|
|
913
|
+
|
|
914
|
+
async function handleNumberSelect(idx: number): Promise<void> {
|
|
915
|
+
if (ctx.type === "search" && ctx.results[idx]) {
|
|
916
|
+
const r = ctx.results[idx];
|
|
917
|
+
await doPage(String(r.id));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (ctx.type === "sections" && ctx.sections[idx]) {
|
|
921
|
+
const s = ctx.sections[idx];
|
|
922
|
+
const page = getPage(ctx.pageId, undefined, s.anchor_id || s.heading);
|
|
923
|
+
if (page) {
|
|
924
|
+
await paged(renderPage(page));
|
|
925
|
+
pushCtx({ type: "page", pageId: ctx.pageId, title: ctx.title });
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (ctx.type === "devices" && ctx.results[idx]) {
|
|
930
|
+
const d = ctx.results[idx];
|
|
931
|
+
const lookup = searchDevices(d.product_name, {}, 1);
|
|
932
|
+
if (lookup.results.length > 0) {
|
|
933
|
+
await paged(renderDeviceCard(lookup.results[0]));
|
|
934
|
+
pushCtx({ type: "device", device: lookup.results[0] });
|
|
935
|
+
}
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
console.log(dim(` No item #${idx + 1} in current view.`));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ── Action functions ──
|
|
942
|
+
|
|
943
|
+
async function doSearch(query: string): Promise<void> {
|
|
944
|
+
const resp = searchPages(query);
|
|
945
|
+
if (resp.results.length === 0) {
|
|
946
|
+
console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
await paged(renderSearchResults(resp));
|
|
950
|
+
pushCtx({ type: "search", response: resp, results: resp.results });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
954
|
+
const page = getPage(
|
|
955
|
+
/^\d+$/.test(idOrTitle) ? Number.parseInt(idOrTitle, 10) : idOrTitle,
|
|
956
|
+
undefined,
|
|
957
|
+
sectionName,
|
|
958
|
+
);
|
|
959
|
+
if (!page) {
|
|
960
|
+
console.log(dim(` Page not found: ${idOrTitle}`));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
await paged(renderPage(page));
|
|
964
|
+
|
|
965
|
+
// Determine linked command path (if any)
|
|
966
|
+
let commandPath: string | undefined;
|
|
967
|
+
try {
|
|
968
|
+
const row = db.prepare("SELECT path FROM commands WHERE page_id = ? LIMIT 1").get(page.id) as { path: string } | null;
|
|
969
|
+
if (row) commandPath = row.path;
|
|
970
|
+
} catch {
|
|
971
|
+
// commands table may not exist
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (page.sections && page.sections.length > 0) {
|
|
975
|
+
pushCtx({ type: "sections", pageId: page.id, title: page.title, sections: page.sections });
|
|
976
|
+
} else {
|
|
977
|
+
pushCtx({ type: "page", pageId: page.id, title: page.title, commandPath });
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function doPropsForPage(pageId: number, title: string): Promise<void> {
|
|
982
|
+
// Get all properties for this page
|
|
983
|
+
const results = searchProperties(title, 50);
|
|
984
|
+
const pageProps = results.filter((p) => p.page_title === title);
|
|
985
|
+
if (pageProps.length === 0) {
|
|
986
|
+
console.log(dim(` No properties found for "${title}".`));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
|
|
990
|
+
pushCtx({ type: "properties", query: title, pageId });
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function doLookupProperty(name: string): Promise<void> {
|
|
994
|
+
const commandPath = ctx.type === "page" ? (ctx as { commandPath?: string }).commandPath : undefined;
|
|
995
|
+
const results = lookupProperty(name, commandPath);
|
|
996
|
+
if (results.length === 0) {
|
|
997
|
+
console.log(dim(` Property "${name}" not found.`));
|
|
998
|
+
console.log(` Try: ${cyan("props")} ${name}`);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
await paged(renderProperties(results));
|
|
1002
|
+
pushCtx({ type: "properties", query: name });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function doSearchProperties(query: string): Promise<void> {
|
|
1006
|
+
const results = searchProperties(query);
|
|
1007
|
+
if (results.length === 0) {
|
|
1008
|
+
console.log(dim(` No properties found for "${query}".`));
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
|
|
1012
|
+
pushCtx({ type: "properties", query });
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function doCommandTree(path: string): Promise<void> {
|
|
1016
|
+
const normalized = path.startsWith("/") ? path : path ? `/${path}` : "";
|
|
1017
|
+
// If at a page context and no explicit path, try linked command
|
|
1018
|
+
const cmdPath = !path && ctx.type === "page" && (ctx as { commandPath?: string }).commandPath
|
|
1019
|
+
// biome-ignore lint/style/noNonNullAssertion: narrowed by the truthiness check above
|
|
1020
|
+
? (ctx as { commandPath?: string }).commandPath!
|
|
1021
|
+
: normalized;
|
|
1022
|
+
|
|
1023
|
+
const children = browseCommands(cmdPath);
|
|
1024
|
+
if (children.length === 0) {
|
|
1025
|
+
console.log(dim(` No children at "${cmdPath}".`));
|
|
1026
|
+
// Try as a command version check instead
|
|
1027
|
+
if (cmdPath) {
|
|
1028
|
+
console.log(` Try: ${cyan("vc")} ${cmdPath}`);
|
|
1029
|
+
}
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
await paged(renderCommandTree(cmdPath, children));
|
|
1033
|
+
pushCtx({ type: "commands", path: cmdPath });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function doDeviceLookup(query: string): Promise<void> {
|
|
1037
|
+
const result = searchDevices(query, {});
|
|
1038
|
+
if (result.results.length === 0) {
|
|
1039
|
+
console.log(dim(` No devices found for "${query}".`));
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
await paged(renderDeviceResults(result.results, result.mode, result.total));
|
|
1043
|
+
if (result.results.length === 1) {
|
|
1044
|
+
pushCtx({ type: "device", device: result.results[0] });
|
|
1045
|
+
} else {
|
|
1046
|
+
pushCtx({ type: "devices", query, results: result.results });
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function doTests(argsStr: string): Promise<void> {
|
|
1051
|
+
if (!argsStr) {
|
|
1052
|
+
// Show available filter values
|
|
1053
|
+
const meta = getTestResultMeta();
|
|
1054
|
+
console.log(` ${bold("Test filters:")}`);
|
|
1055
|
+
console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
|
|
1056
|
+
console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
|
|
1057
|
+
console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
|
|
1058
|
+
console.log("");
|
|
1059
|
+
console.log(` ${dim("Usage: tests <type> [mode] [packet_size]")}`);
|
|
1060
|
+
console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const parts = argsStr.split(/\s+/);
|
|
1065
|
+
const filters: Record<string, string | number> = {};
|
|
1066
|
+
if (parts[0]) filters.test_type = parts[0];
|
|
1067
|
+
if (parts[1]) filters.mode = parts[1];
|
|
1068
|
+
if (parts[2] && /^\d+$/.test(parts[2])) filters.packet_size = Number.parseInt(parts[2], 10);
|
|
1069
|
+
|
|
1070
|
+
const result = searchDeviceTests(filters);
|
|
1071
|
+
if (result.results.length === 0) {
|
|
1072
|
+
console.log(dim(" No test results matching those filters."));
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
await paged(renderTests(result.results, result.total));
|
|
1076
|
+
pushCtx({ type: "tests" });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function doSearchCallouts(query: string): Promise<void> {
|
|
1080
|
+
// Parse type filter: "cal warning" or "cal warning dhcp"
|
|
1081
|
+
const types = ["note", "warning", "info", "tip"];
|
|
1082
|
+
const parts = query.split(/\s+/);
|
|
1083
|
+
let type: string | undefined;
|
|
1084
|
+
let searchQuery = query;
|
|
1085
|
+
|
|
1086
|
+
if (parts[0] && types.includes(parts[0].toLowerCase())) {
|
|
1087
|
+
type = parts[0].charAt(0).toUpperCase() + parts[0].slice(1).toLowerCase();
|
|
1088
|
+
searchQuery = parts.slice(1).join(" ");
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const results = searchCallouts(searchQuery, type);
|
|
1092
|
+
if (results.length === 0) {
|
|
1093
|
+
console.log(dim(` No callouts found.`));
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
|
|
1097
|
+
pushCtx({ type: "callouts", query });
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async function doSearchChangelogs(query: string): Promise<void> {
|
|
1101
|
+
const parts = query.split(/\s+/).filter(Boolean);
|
|
1102
|
+
|
|
1103
|
+
// Parse options
|
|
1104
|
+
let breakingOnly = false;
|
|
1105
|
+
let version: string | undefined;
|
|
1106
|
+
let fromVersion: string | undefined;
|
|
1107
|
+
let toVersion: string | undefined;
|
|
1108
|
+
let category: string | undefined;
|
|
1109
|
+
const searchTerms: string[] = [];
|
|
1110
|
+
|
|
1111
|
+
for (const part of parts) {
|
|
1112
|
+
if (part.toLowerCase() === "breaking") { breakingOnly = true; continue; }
|
|
1113
|
+
// Version range: "7.20..7.22"
|
|
1114
|
+
const rangeMatch = part.match(/^(\d+\.\d+[.\w]*)\.\.(\d+\.\d+[.\w]*)$/);
|
|
1115
|
+
if (rangeMatch) { fromVersion = rangeMatch[1]; toVersion = rangeMatch[2]; continue; }
|
|
1116
|
+
// Single version: "7.22"
|
|
1117
|
+
if (/^\d+\.\d+/.test(part)) { version = part; continue; }
|
|
1118
|
+
searchTerms.push(part);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// If we only got a version and nothing else, browse that version
|
|
1122
|
+
const searchQuery = searchTerms.join(" ");
|
|
1123
|
+
|
|
1124
|
+
const results = searchChangelogs(searchQuery, {
|
|
1125
|
+
version,
|
|
1126
|
+
fromVersion,
|
|
1127
|
+
toVersion,
|
|
1128
|
+
category,
|
|
1129
|
+
breakingOnly,
|
|
1130
|
+
limit: 50,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
if (results.length === 0) {
|
|
1134
|
+
console.log(dim(" No changelog entries found."));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
|
|
1138
|
+
pushCtx({ type: "changelogs" });
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function doSearchVideos(query: string): Promise<void> {
|
|
1142
|
+
const results = searchVideos(query, 10);
|
|
1143
|
+
if (results.length === 0) {
|
|
1144
|
+
console.log(dim(` No video results for "${query}".`));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
|
|
1148
|
+
pushCtx({ type: "videos", query });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
|
|
1152
|
+
const result = diffCommandVersions(from, to, pathPrefix);
|
|
1153
|
+
await paged(renderDiff(result));
|
|
1154
|
+
pushCtx({ type: "diff" });
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function doVersionCheck(cmdPath: string): Promise<void> {
|
|
1158
|
+
const normalized = cmdPath.startsWith("/") ? cmdPath : `/${cmdPath}`;
|
|
1159
|
+
const result = checkCommandVersions(normalized);
|
|
1160
|
+
await paged(renderVersionCheck(result));
|
|
1161
|
+
pushCtx({ type: "vcheck", path: normalized });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function doCurrentVersions(): Promise<void> {
|
|
1165
|
+
console.log(dim(" Fetching current versions from MikroTik..."));
|
|
1166
|
+
const result = await fetchCurrentVersions();
|
|
1167
|
+
const out: string[] = [];
|
|
1168
|
+
out.push(` ${bold("Current RouterOS Versions")} ${dim(`(${result.fetched_at})`)}`);
|
|
1169
|
+
out.push("");
|
|
1170
|
+
for (const [channel, version] of Object.entries(result.channels)) {
|
|
1171
|
+
const v = version ?? dim("unavailable");
|
|
1172
|
+
const ch = pad(channel, 14);
|
|
1173
|
+
out.push(` ${dim(ch)} ${bold(String(v))}`);
|
|
1174
|
+
}
|
|
1175
|
+
console.log(out.join("\n"));
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ── Main REPL ──
|
|
1179
|
+
|
|
1180
|
+
async function main() {
|
|
1181
|
+
initDb();
|
|
1182
|
+
|
|
1183
|
+
const args = process.argv.slice(2);
|
|
1184
|
+
const onceMode = args.includes("--once");
|
|
1185
|
+
const queryArgs = args.filter((a) => a !== "--once" && a !== "browse");
|
|
1186
|
+
const initialQuery = queryArgs.join(" ");
|
|
1187
|
+
|
|
1188
|
+
// Welcome banner (only in interactive mode)
|
|
1189
|
+
if (process.stdout.isTTY && !onceMode) {
|
|
1190
|
+
console.log(renderWelcome());
|
|
1191
|
+
console.log("");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Initial query from CLI args
|
|
1195
|
+
if (initialQuery) {
|
|
1196
|
+
await doSearch(initialQuery);
|
|
1197
|
+
if (onceMode) process.exit(0);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Non-TTY: exit after initial query or do nothing
|
|
1201
|
+
if (!process.stdout.isTTY) {
|
|
1202
|
+
if (!initialQuery) {
|
|
1203
|
+
console.error("Non-interactive mode requires a query argument.");
|
|
1204
|
+
process.exit(1);
|
|
1205
|
+
}
|
|
1206
|
+
process.exit(0);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Interactive REPL
|
|
1210
|
+
const rl = readline.createInterface({
|
|
1211
|
+
input: process.stdin,
|
|
1212
|
+
output: process.stdout,
|
|
1213
|
+
prompt: `${cyan("rosetta")}${dim(">")} `,
|
|
1214
|
+
terminal: true,
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
rl.prompt();
|
|
1218
|
+
|
|
1219
|
+
rl.on("line", async (line) => {
|
|
1220
|
+
try {
|
|
1221
|
+
await dispatch(line);
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1224
|
+
}
|
|
1225
|
+
rl.prompt();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
rl.on("close", () => {
|
|
1229
|
+
console.log("");
|
|
1230
|
+
process.exit(0);
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
main();
|