@tikoci/rosetta 0.5.2 → 0.6.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/README.md +9 -1
- package/package.json +1 -1
- package/src/browse.ts +90 -0
- package/src/db.ts +130 -7
- package/src/extract-commands.ts +55 -10
- package/src/extract-dude.ts +409 -0
- package/src/mcp-http.test.ts +7 -5
- package/src/mcp.ts +287 -2
- package/src/paths.ts +1 -1
- package/src/query.test.ts +124 -6
- package/src/query.ts +93 -2
- package/src/release.test.ts +32 -0
- package/src/setup.ts +2 -2
package/src/mcp.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* CLI flags (for compiled binary or `bun run src/mcp.ts`):
|
|
9
9
|
* browse Interactive terminal browser (REPL)
|
|
10
10
|
* --setup [--force] Download database + print MCP client config
|
|
11
|
+
* --refresh Shortcut for --setup --force (refresh DB)
|
|
11
12
|
* --version Print version
|
|
12
13
|
* --help Print usage
|
|
13
14
|
* --http Start with Streamable HTTP transport (instead of stdio)
|
|
@@ -59,6 +60,7 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
59
60
|
console.log(" rosetta browse Interactive terminal browser");
|
|
60
61
|
console.log(" rosetta --setup Download database + print MCP client config");
|
|
61
62
|
console.log(" rosetta --setup --force Re-download database");
|
|
63
|
+
console.log(" rosetta --refresh Shortcut for --setup --force");
|
|
62
64
|
console.log(" rosetta --version Print version");
|
|
63
65
|
console.log(" rosetta --help Print this help");
|
|
64
66
|
console.log();
|
|
@@ -95,6 +97,12 @@ if (args.includes("--setup")) {
|
|
|
95
97
|
process.exit(0);
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
if (args.includes("--refresh")) {
|
|
101
|
+
const { runSetup } = await import("./setup.ts");
|
|
102
|
+
await runSetup(true);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
// ── MCP Server ──
|
|
99
107
|
|
|
100
108
|
const useHttp = args.includes("--http");
|
|
@@ -158,12 +166,12 @@ if (_dbSchemaVersion !== SCHEMA_VERSION) {
|
|
|
158
166
|
log("Database updated successfully.");
|
|
159
167
|
} catch (e) {
|
|
160
168
|
log(`Auto-download failed: ${e}`);
|
|
161
|
-
log(`Run: ${process.argv[0]} --
|
|
169
|
+
log(`Run: ${process.argv[0]} --refresh`);
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
// Now import db.ts (opens the DB) and query.ts
|
|
166
|
-
const { getDbStats, initDb } = await import("./db.ts");
|
|
174
|
+
const { db, getDbStats, initDb } = await import("./db.ts");
|
|
167
175
|
const {
|
|
168
176
|
browseCommands,
|
|
169
177
|
browseCommandsAtVersion,
|
|
@@ -182,6 +190,8 @@ const {
|
|
|
182
190
|
searchPages,
|
|
183
191
|
searchProperties,
|
|
184
192
|
searchVideos,
|
|
193
|
+
searchDude,
|
|
194
|
+
getDudePage,
|
|
185
195
|
} = await import("./query.ts");
|
|
186
196
|
|
|
187
197
|
initDb();
|
|
@@ -229,6 +239,200 @@ server.registerResource(
|
|
|
229
239
|
}),
|
|
230
240
|
);
|
|
231
241
|
|
|
242
|
+
server.registerResource(
|
|
243
|
+
"schema-sql",
|
|
244
|
+
"rosetta://schema.sql",
|
|
245
|
+
{
|
|
246
|
+
title: "Database Schema DDL",
|
|
247
|
+
description: "Full SQLite DDL (CREATE TABLE/VIRTUAL TABLE/TRIGGER/INDEX statements) for ros-help.db. Read this before constructing raw SQL queries.",
|
|
248
|
+
mimeType: "application/sql",
|
|
249
|
+
},
|
|
250
|
+
async () => {
|
|
251
|
+
const rows = db
|
|
252
|
+
.prepare(
|
|
253
|
+
"SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type DESC, name ASC",
|
|
254
|
+
)
|
|
255
|
+
.all() as Array<{ sql: string }>;
|
|
256
|
+
const ddl = rows.map((r) => `${r.sql};`).join("\n\n");
|
|
257
|
+
return {
|
|
258
|
+
contents: [{
|
|
259
|
+
uri: "rosetta://schema.sql",
|
|
260
|
+
mimeType: "application/sql",
|
|
261
|
+
text: ddl,
|
|
262
|
+
}],
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
server.registerResource(
|
|
268
|
+
"schema-guide",
|
|
269
|
+
"rosetta://schema-guide.md",
|
|
270
|
+
{
|
|
271
|
+
title: "Schema Guide",
|
|
272
|
+
description: "How to query ros-help.db: table relationships, FTS5 tokenizer differences, BM25 weights, and example query patterns.",
|
|
273
|
+
mimeType: "text/markdown",
|
|
274
|
+
},
|
|
275
|
+
async () => ({
|
|
276
|
+
contents: [{
|
|
277
|
+
uri: "rosetta://schema-guide.md",
|
|
278
|
+
mimeType: "text/markdown",
|
|
279
|
+
text: `# ros-help.db Schema Guide
|
|
280
|
+
|
|
281
|
+
Read \`rosetta://schema.sql\` for full DDL. This guide explains relationships, FTS5 quirks, and good query patterns.
|
|
282
|
+
|
|
283
|
+
## Table Map
|
|
284
|
+
|
|
285
|
+
| Table | Rows (approx) | Description |
|
|
286
|
+
|-------|-------------|-------------|
|
|
287
|
+
| \`pages\` | 317 | One row per Confluence HTML page. Primary content store. |
|
|
288
|
+
| \`sections\` | 2,984 | h1–h3 chunks of pages with anchor IDs for deep-linking. |
|
|
289
|
+
| \`properties\` | 4,860 | CLI property rows extracted from confluenceTable elements. |
|
|
290
|
+
| \`callouts\` | 1,034 | Note/Warning/Info/Tip blocks from Confluence callout macros. |
|
|
291
|
+
| \`commands\` | ~40K | RouterOS command tree entries (dir/cmd/arg) from inspect.json. |
|
|
292
|
+
| \`command_versions\` | 1.67M | Junction: which command paths exist in which RouterOS versions. |
|
|
293
|
+
| \`ros_versions\` | 46 | Metadata per extracted RouterOS version (7.9–7.23beta2). |
|
|
294
|
+
| \`devices\` | 144 | MikroTik hardware specs from product matrix CSV. |
|
|
295
|
+
| \`device_test_results\` | 2,874 | Ethernet/IPSec benchmark rows from mikrotik.com product pages. |
|
|
296
|
+
| \`changelogs\` | varies | Parsed per-entry changelog lines from MikroTik download server. |
|
|
297
|
+
| \`videos\` | 518 | MikroTik YouTube video metadata. |
|
|
298
|
+
| \`video_segments\` | ~1,890 | Chapter-level transcript segments (one per chapter or one per video). |
|
|
299
|
+
|
|
300
|
+
## Foreign Keys
|
|
301
|
+
|
|
302
|
+
\`\`\`
|
|
303
|
+
pages ←── sections.page_id
|
|
304
|
+
pages ←── properties.page_id
|
|
305
|
+
pages ←── callouts.page_id
|
|
306
|
+
pages ←── commands.page_id (nullable — not all commands link to a page)
|
|
307
|
+
devices ←── device_test_results.device_id
|
|
308
|
+
ros_versions ←── command_versions.ros_version
|
|
309
|
+
videos ←── video_segments.video_id (INTEGER FK to videos.id, NOT videos.video_id TEXT)
|
|
310
|
+
\`\`\`
|
|
311
|
+
|
|
312
|
+
## FTS5 Virtual Tables
|
|
313
|
+
|
|
314
|
+
Each table has a companion \`*_fts\` virtual table kept in sync via INSERT/UPDATE/DELETE triggers.
|
|
315
|
+
|
|
316
|
+
| FTS table | Source | Tokenizer | Indexed columns |
|
|
317
|
+
|-----------|--------|-----------|----------------|
|
|
318
|
+
| \`pages_fts\` | \`pages\` | \`porter unicode61\` | title (3×), path (2×), text (1×), code (0.5×) |
|
|
319
|
+
| \`properties_fts\` | \`properties\` | \`porter unicode61\` | name, description |
|
|
320
|
+
| \`callouts_fts\` | \`callouts\` | \`porter unicode61\` | content |
|
|
321
|
+
| \`changelogs_fts\` | \`changelogs\` | \`porter unicode61\` | category, description |
|
|
322
|
+
| \`videos_fts\` | \`videos\` | \`porter unicode61\` | title, description |
|
|
323
|
+
| \`video_segments_fts\` | \`video_segments\` | \`porter unicode61\` | chapter_title, transcript |
|
|
324
|
+
| \`devices_fts\` | \`devices\` | **\`unicode61\` only** | product_name, product_code, architecture, cpu |
|
|
325
|
+
|
|
326
|
+
**Why devices use \`unicode61\` without porter:** Model numbers like "RB5009" and "hAP ax3" must not be stemmed. Porter would corrupt them.
|
|
327
|
+
|
|
328
|
+
## BM25 Column Weights (pages_fts)
|
|
329
|
+
|
|
330
|
+
The MCP tools use \`bm25(pages_fts, 3.0, 2.0, 1.0, 0.5)\` — title gets 3× weight, path 2×, body text 1×, code blocks 0.5×. In SQLite FTS5 BM25, **lower (more negative) scores rank better**.
|
|
331
|
+
|
|
332
|
+
\`\`\`sql
|
|
333
|
+
SELECT p.id, p.title, p.url,
|
|
334
|
+
bm25(pages_fts, 3.0, 2.0, 1.0, 0.5) AS rank
|
|
335
|
+
FROM pages_fts
|
|
336
|
+
JOIN pages p ON p.id = pages_fts.rowid
|
|
337
|
+
WHERE pages_fts MATCH 'firewall filter'
|
|
338
|
+
ORDER BY rank -- ascending = best match first
|
|
339
|
+
LIMIT 10;
|
|
340
|
+
\`\`\`
|
|
341
|
+
|
|
342
|
+
## FTS5 Query Syntax
|
|
343
|
+
|
|
344
|
+
\`\`\`sql
|
|
345
|
+
-- Phrase search (exact sequence)
|
|
346
|
+
WHERE pages_fts MATCH '"firewall filter"'
|
|
347
|
+
|
|
348
|
+
-- AND (default — all terms must appear)
|
|
349
|
+
WHERE pages_fts MATCH 'dhcp relay'
|
|
350
|
+
|
|
351
|
+
-- OR
|
|
352
|
+
WHERE pages_fts MATCH 'dhcp OR relay'
|
|
353
|
+
|
|
354
|
+
-- Column-scoped search
|
|
355
|
+
WHERE pages_fts MATCH 'title:firewall'
|
|
356
|
+
|
|
357
|
+
-- NEAR (terms within N tokens of each other)
|
|
358
|
+
WHERE pages_fts MATCH 'NEAR(firewall filter, 5)'
|
|
359
|
+
|
|
360
|
+
-- Prefix match
|
|
361
|
+
WHERE pages_fts MATCH 'route*'
|
|
362
|
+
\`\`\`
|
|
363
|
+
|
|
364
|
+
Porter stemming is automatic — "configuring" matches "configuration", "configured", "configure".
|
|
365
|
+
|
|
366
|
+
## Common Join Patterns
|
|
367
|
+
|
|
368
|
+
### Page + its properties
|
|
369
|
+
\`\`\`sql
|
|
370
|
+
SELECT p.title, pr.name, pr.type, pr.default_val, pr.description
|
|
371
|
+
FROM pages p
|
|
372
|
+
JOIN properties pr ON pr.page_id = p.id
|
|
373
|
+
WHERE p.id = 328220;
|
|
374
|
+
\`\`\`
|
|
375
|
+
|
|
376
|
+
### FTS search → full section content
|
|
377
|
+
\`\`\`sql
|
|
378
|
+
SELECT p.title, s.heading, s.text, s.anchor_id
|
|
379
|
+
FROM pages_fts
|
|
380
|
+
JOIN pages p ON p.id = pages_fts.rowid
|
|
381
|
+
JOIN sections s ON s.page_id = p.id
|
|
382
|
+
WHERE pages_fts MATCH 'mangle routing mark'
|
|
383
|
+
ORDER BY bm25(pages_fts, 3.0, 2.0, 1.0, 0.5)
|
|
384
|
+
LIMIT 5;
|
|
385
|
+
\`\`\`
|
|
386
|
+
|
|
387
|
+
### Command path → linked documentation page
|
|
388
|
+
\`\`\`sql
|
|
389
|
+
SELECT c.path, c.type, p.title, p.url
|
|
390
|
+
FROM commands c
|
|
391
|
+
LEFT JOIN pages p ON p.id = c.page_id
|
|
392
|
+
WHERE c.path = '/ip/firewall/filter';
|
|
393
|
+
\`\`\`
|
|
394
|
+
|
|
395
|
+
### Commands available in a specific RouterOS version
|
|
396
|
+
\`\`\`sql
|
|
397
|
+
SELECT c.path, c.type
|
|
398
|
+
FROM commands c
|
|
399
|
+
JOIN command_versions cv ON cv.command_path = c.path
|
|
400
|
+
WHERE cv.ros_version = '7.22'
|
|
401
|
+
AND c.path LIKE '/ip/firewall/%'
|
|
402
|
+
ORDER BY c.path;
|
|
403
|
+
\`\`\`
|
|
404
|
+
|
|
405
|
+
### Device hardware lookup + benchmarks
|
|
406
|
+
\`\`\`sql
|
|
407
|
+
SELECT d.product_name, d.ram_mb, d.cpu,
|
|
408
|
+
t.test_type, t.mode, t.packet_size, t.throughput_mbps
|
|
409
|
+
FROM devices d
|
|
410
|
+
JOIN device_test_results t ON t.device_id = d.id
|
|
411
|
+
WHERE d.product_name LIKE '%RB5009%'
|
|
412
|
+
ORDER BY t.test_type, t.packet_size;
|
|
413
|
+
\`\`\`
|
|
414
|
+
|
|
415
|
+
### Changelog entries for a version range, breaking changes only
|
|
416
|
+
\`\`\`sql
|
|
417
|
+
SELECT version, released, category, description
|
|
418
|
+
FROM changelogs
|
|
419
|
+
WHERE is_breaking = 1
|
|
420
|
+
AND version >= '7.20' AND version <= '7.22'
|
|
421
|
+
ORDER BY version, sort_order;
|
|
422
|
+
\`\`\`
|
|
423
|
+
|
|
424
|
+
## Gotchas
|
|
425
|
+
|
|
426
|
+
- **Version sorting:** \`ORDER BY version\` is lexicographic, not numeric. '7.9' > '7.10' lexicographically. Use the \`compareVersions()\` helper in query.ts or fetch all and sort in application code.
|
|
427
|
+
- **content= FTS tables:** Do not SELECT directly from \`*_fts\` tables — they are content tables and must be JOINed via rowid to the source table to get non-indexed columns.
|
|
428
|
+
- **video_segments.video_id** is an INTEGER FK to \`videos.id\`, not the TEXT \`videos.video_id\` (YouTube ID). Join on \`video_segments.video_id = videos.id\`.
|
|
429
|
+
- **NULL page_id in commands:** ~8% of command dirs have no linked page (\`page_id IS NULL\`). Use LEFT JOIN when joining commands to pages.
|
|
430
|
+
- **devices_fts LIKE fallback:** For model numbers ending in ™/® or containing superscripts, FTS may miss them. Use \`LIKE '%RB5009%'\` as a fallback on \`devices.product_name\`.
|
|
431
|
+
`,
|
|
432
|
+
}],
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
|
|
232
436
|
// ---- routeros_search ----
|
|
233
437
|
|
|
234
438
|
server.registerTool(
|
|
@@ -725,6 +929,87 @@ and explanations that complement the text documentation.
|
|
|
725
929
|
},
|
|
726
930
|
);
|
|
727
931
|
|
|
932
|
+
// ---- routeros_dude_search ----
|
|
933
|
+
|
|
934
|
+
server.registerTool(
|
|
935
|
+
"routeros_dude_search",
|
|
936
|
+
{
|
|
937
|
+
description: `Search archived "The Dude" network monitor documentation (from wiki.mikrotik.com via Wayback Machine).
|
|
938
|
+
|
|
939
|
+
The Dude GUI client is retired, but the Dude server/database remains in RouterOS under /dude.
|
|
940
|
+
These are archived wiki pages covering the Dude v6 GUI (primary) and legacy v3/v4 (reference).
|
|
941
|
+
Many pages include GUI screenshots — use routeros_dude_get_page to see image references.
|
|
942
|
+
|
|
943
|
+
Separate from routeros_search (which covers current RouterOS v7 docs only).
|
|
944
|
+
For current RouterOS /dude command-line interface, use routeros_command_tree with path "/dude".
|
|
945
|
+
|
|
946
|
+
→ routeros_dude_get_page: read full page text + screenshot list
|
|
947
|
+
→ routeros_command_tree: browse /dude commands in current RouterOS
|
|
948
|
+
→ routeros_search: search current RouterOS v7 documentation`,
|
|
949
|
+
inputSchema: {
|
|
950
|
+
query: z.string().describe("Search terms (e.g., 'probes SNMP', 'device discovery', 'notifications')"),
|
|
951
|
+
limit: z
|
|
952
|
+
.number()
|
|
953
|
+
.int()
|
|
954
|
+
.min(1)
|
|
955
|
+
.max(20)
|
|
956
|
+
.default(8)
|
|
957
|
+
.optional()
|
|
958
|
+
.describe("Max results (1–20, default 8)"),
|
|
959
|
+
},
|
|
960
|
+
},
|
|
961
|
+
async ({ query, limit }) => {
|
|
962
|
+
const results = searchDude(query, limit ?? 8);
|
|
963
|
+
if (results.length === 0) {
|
|
964
|
+
return {
|
|
965
|
+
content: [
|
|
966
|
+
{
|
|
967
|
+
type: "text",
|
|
968
|
+
text: `No Dude wiki results for: "${query}"\n\nTry:\n- Broader search terms (e.g., 'monitor' instead of 'monitoring')\n- routeros_search for current RouterOS documentation\n- routeros_command_tree with path "/dude" for current /dude commands`,
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
975
|
+
};
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// ---- routeros_dude_get_page ----
|
|
980
|
+
|
|
981
|
+
server.registerTool(
|
|
982
|
+
"routeros_dude_get_page",
|
|
983
|
+
{
|
|
984
|
+
description: `Get full content of an archived Dude wiki page by ID or title.
|
|
985
|
+
|
|
986
|
+
Returns the complete page text, code blocks, and a list of GUI screenshots with local file paths.
|
|
987
|
+
Screenshots are downloaded images from the archived wiki — use a file viewer for multimodal analysis.
|
|
988
|
+
|
|
989
|
+
→ routeros_dude_search: find pages by topic
|
|
990
|
+
→ routeros_command_tree: browse /dude commands in current RouterOS`,
|
|
991
|
+
inputSchema: {
|
|
992
|
+
id: z.union([z.number().int(), z.string()]).describe("Page ID (number) or title/slug (string)"),
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
async ({ id }) => {
|
|
996
|
+
const page = getDudePage(typeof id === "string" && /^\d+$/.test(id) ? Number.parseInt(id, 10) : id);
|
|
997
|
+
if (!page) {
|
|
998
|
+
return {
|
|
999
|
+
content: [
|
|
1000
|
+
{
|
|
1001
|
+
type: "text",
|
|
1002
|
+
text: `No Dude page found for: "${id}"\n\nTry routeros_dude_search to find available pages.`,
|
|
1003
|
+
},
|
|
1004
|
+
],
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
content: [{ type: "text", text: JSON.stringify(page, null, 2) }],
|
|
1009
|
+
};
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
1012
|
+
|
|
728
1013
|
// ---- routeros_command_version_check ----
|
|
729
1014
|
|
|
730
1015
|
server.registerTool(
|
package/src/paths.ts
CHANGED
|
@@ -79,7 +79,7 @@ export function detectMode(srcDir: string): InvocationMode {
|
|
|
79
79
|
* Stamped into the DB via `PRAGMA user_version` by initDb() and checked at MCP
|
|
80
80
|
* startup to detect stale DBs for bunx users who auto-update the package.
|
|
81
81
|
*/
|
|
82
|
-
export const SCHEMA_VERSION =
|
|
82
|
+
export const SCHEMA_VERSION = 2;
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Resolve the version string.
|
package/src/query.test.ts
CHANGED
|
@@ -32,6 +32,8 @@ const {
|
|
|
32
32
|
getTestResultMeta,
|
|
33
33
|
normalizeDeviceQuery,
|
|
34
34
|
searchVideos,
|
|
35
|
+
searchDude,
|
|
36
|
+
getDudePage,
|
|
35
37
|
} = await import("./query.ts");
|
|
36
38
|
const { parseChangelog } = await import("./extract-changelogs.ts");
|
|
37
39
|
const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
|
|
@@ -75,12 +77,12 @@ beforeAll(() => {
|
|
|
75
77
|
(id, page_id, name, type, default_val, description, section, sort_order)
|
|
76
78
|
VALUES (2, 1, 'address-pool', 'string', '', 'Name of the address pool to use', NULL, 1)`);
|
|
77
79
|
|
|
78
|
-
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
79
|
-
VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
|
|
80
|
-
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
81
|
-
VALUES ('7.9', 'stable', 0, '2023-01-01T00:00:00Z')`);
|
|
82
|
-
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
83
|
-
VALUES ('7.10.2', 'stable', 0, '2023-06-01T00:00:00Z')`);
|
|
80
|
+
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
81
|
+
VALUES ('7.22', 'x86', 'stable', 0, '2024-01-01T00:00:00Z')`);
|
|
82
|
+
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
83
|
+
VALUES ('7.9', 'x86', 'stable', 0, '2023-01-01T00:00:00Z')`);
|
|
84
|
+
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
85
|
+
VALUES ('7.10.2', 'x86', 'stable', 0, '2023-06-01T00:00:00Z')`);
|
|
84
86
|
|
|
85
87
|
db.run(`INSERT INTO commands
|
|
86
88
|
(id, path, name, type, parent_path, page_id, description, ros_version)
|
|
@@ -350,6 +352,41 @@ beforeAll(() => {
|
|
|
350
352
|
(id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
|
|
351
353
|
VALUES
|
|
352
354
|
(3, 2, NULL, 0, NULL, 'BGP peering and route reflection allow scalable routing in large networks.', 0)`);
|
|
355
|
+
|
|
356
|
+
// Dude wiki page fixtures for searchDude tests
|
|
357
|
+
db.run(`INSERT INTO dude_pages
|
|
358
|
+
(id, slug, title, path, version, url, wayback_url, text, code, word_count)
|
|
359
|
+
VALUES
|
|
360
|
+
(1, 'Probes', 'Probes', 'The Dude > v6 > Probes', 'v6',
|
|
361
|
+
'https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes',
|
|
362
|
+
'https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes',
|
|
363
|
+
'Probes are used to monitor specific services on devices. SNMP probes query MIB values. TCP probes check port availability. The Dude supports custom probe definitions with thresholds and alerts.',
|
|
364
|
+
NULL, 30)`);
|
|
365
|
+
db.run(`INSERT INTO dude_pages
|
|
366
|
+
(id, slug, title, path, version, url, wayback_url, text, code, word_count)
|
|
367
|
+
VALUES
|
|
368
|
+
(2, 'Device_discovery', 'Device Discovery', 'The Dude > v6 > Device Discovery', 'v6',
|
|
369
|
+
'https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Device_discovery',
|
|
370
|
+
'https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Device_discovery',
|
|
371
|
+
'Device discovery scans networks using SNMP, TCP, and ICMP. The Dude can automatically discover routers, switches, and other network devices on specified subnets.',
|
|
372
|
+
'/dude discovery add address=10.0.0.0/24', 25)`);
|
|
373
|
+
db.run(`INSERT INTO dude_pages
|
|
374
|
+
(id, slug, title, path, version, url, wayback_url, text, code, word_count)
|
|
375
|
+
VALUES
|
|
376
|
+
(3, 'Notifications', 'Notifications', 'The Dude > v3 > Notifications', 'v3',
|
|
377
|
+
'https://wiki.mikrotik.com/wiki/Manual:The_Dude/Notifications',
|
|
378
|
+
'https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude/Notifications',
|
|
379
|
+
'The Dude can send email and SMS notifications when device status changes. Configure SMTP settings for email alerts.',
|
|
380
|
+
NULL, 20)`);
|
|
381
|
+
db.run(`INSERT INTO dude_images (page_id, filename, alt_text, caption, local_path, original_url, sort_order)
|
|
382
|
+
VALUES (1, 'Dude-probes-all.JPG', 'Probes list', 'All probes view', 'dude/images/Dude-probes-all.JPG',
|
|
383
|
+
'https://wiki.mikrotik.com/wiki/File:Dude-probes-all.JPG', 0)`);
|
|
384
|
+
db.run(`INSERT INTO dude_images (page_id, filename, alt_text, caption, local_path, original_url, sort_order)
|
|
385
|
+
VALUES (1, 'Dude-probe-settings.JPG', 'Probe settings', 'Probe configuration dialog', 'dude/images/Dude-probe-settings.JPG',
|
|
386
|
+
'https://wiki.mikrotik.com/wiki/File:Dude-probe-settings.JPG', 1)`);
|
|
387
|
+
db.run(`INSERT INTO dude_images (page_id, filename, alt_text, caption, local_path, original_url, sort_order)
|
|
388
|
+
VALUES (2, 'Dude-discovery.JPG', 'Discovery settings', NULL, 'dude/images/Dude-discovery.JPG',
|
|
389
|
+
'https://wiki.mikrotik.com/wiki/File:Dude-discovery.JPG', 0)`);
|
|
353
390
|
});
|
|
354
391
|
|
|
355
392
|
// ---------------------------------------------------------------------------
|
|
@@ -1502,6 +1539,13 @@ describe("schema", () => {
|
|
|
1502
1539
|
expect(triggers).toContain("video_segs_au");
|
|
1503
1540
|
});
|
|
1504
1541
|
|
|
1542
|
+
test("content-sync triggers exist for dude_pages", () => {
|
|
1543
|
+
const triggers = triggerNames();
|
|
1544
|
+
expect(triggers).toContain("dude_pages_ai");
|
|
1545
|
+
expect(triggers).toContain("dude_pages_ad");
|
|
1546
|
+
expect(triggers).toContain("dude_pages_au");
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1505
1549
|
test("PRAGMA user_version matches SCHEMA_VERSION", () => {
|
|
1506
1550
|
const result = checkSchemaVersion();
|
|
1507
1551
|
expect(result.ok).toBe(true);
|
|
@@ -1667,3 +1711,77 @@ describe("searchVideos", () => {
|
|
|
1667
1711
|
expect(results.length).toBeLessThanOrEqual(1);
|
|
1668
1712
|
});
|
|
1669
1713
|
});
|
|
1714
|
+
|
|
1715
|
+
// ---------------------------------------------------------------------------
|
|
1716
|
+
// searchDude: FTS against dude_pages_fts (integration, uses fixture DB)
|
|
1717
|
+
// ---------------------------------------------------------------------------
|
|
1718
|
+
|
|
1719
|
+
describe("searchDude", () => {
|
|
1720
|
+
test("finds pages matching query", () => {
|
|
1721
|
+
const results = searchDude("probes SNMP");
|
|
1722
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1723
|
+
expect(results[0].title).toBe("Probes");
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
test("returns image_count for pages with images", () => {
|
|
1727
|
+
const results = searchDude("probes");
|
|
1728
|
+
expect(results[0].image_count).toBe(2);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
test("returns version field", () => {
|
|
1732
|
+
const results = searchDude("probes");
|
|
1733
|
+
expect(results[0].version).toBe("v6");
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
test("finds v3 pages", () => {
|
|
1737
|
+
const results = searchDude("notifications email");
|
|
1738
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1739
|
+
expect(results[0].version).toBe("v3");
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
test("AND→OR fallback finds results", () => {
|
|
1743
|
+
const results = searchDude("device discovery subnet ICMP");
|
|
1744
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
test("returns empty array for empty query", () => {
|
|
1748
|
+
expect(searchDude("")).toEqual([]);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
test("respects limit parameter", () => {
|
|
1752
|
+
const results = searchDude("dude", 1);
|
|
1753
|
+
expect(results.length).toBeLessThanOrEqual(1);
|
|
1754
|
+
});
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
// ---------------------------------------------------------------------------
|
|
1758
|
+
// getDudePage: full page retrieval with images (integration, uses fixture DB)
|
|
1759
|
+
// ---------------------------------------------------------------------------
|
|
1760
|
+
|
|
1761
|
+
describe("getDudePage", () => {
|
|
1762
|
+
test("returns page by ID with images", () => {
|
|
1763
|
+
const page = getDudePage(1);
|
|
1764
|
+
expect(page).not.toBeNull();
|
|
1765
|
+
expect(page!.title).toBe("Probes");
|
|
1766
|
+
expect(page!.images.length).toBe(2);
|
|
1767
|
+
expect(page!.images[0].filename).toBe("Dude-probes-all.JPG");
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
test("returns page by title", () => {
|
|
1771
|
+
const page = getDudePage("Probes");
|
|
1772
|
+
expect(page).not.toBeNull();
|
|
1773
|
+
expect(page!.id).toBe(1);
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
test("returns page by slug", () => {
|
|
1777
|
+
const page = getDudePage("Device_discovery");
|
|
1778
|
+
expect(page).not.toBeNull();
|
|
1779
|
+
expect(page!.title).toBe("Device Discovery");
|
|
1780
|
+
expect(page!.images.length).toBe(1);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
test("returns null for non-existent page", () => {
|
|
1784
|
+
expect(getDudePage(999)).toBeNull();
|
|
1785
|
+
expect(getDudePage("NonExistent")).toBeNull();
|
|
1786
|
+
});
|
|
1787
|
+
});
|
package/src/query.ts
CHANGED
|
@@ -615,7 +615,7 @@ export function diffCommandVersions(
|
|
|
615
615
|
pathPrefix?: string,
|
|
616
616
|
): CommandDiffResult {
|
|
617
617
|
const allVersionRows = db
|
|
618
|
-
.prepare("SELECT version FROM ros_versions")
|
|
618
|
+
.prepare("SELECT DISTINCT version FROM ros_versions")
|
|
619
619
|
.all() as Array<{ version: string }>;
|
|
620
620
|
const knownVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
|
|
621
621
|
|
|
@@ -689,7 +689,7 @@ export function checkCommandVersions(
|
|
|
689
689
|
const versions = rows.map((r) => r.ros_version).sort(compareVersions);
|
|
690
690
|
|
|
691
691
|
const allVersionRows = db
|
|
692
|
-
.prepare("SELECT version FROM ros_versions")
|
|
692
|
+
.prepare("SELECT DISTINCT version FROM ros_versions")
|
|
693
693
|
.all() as Array<{ version: string }>;
|
|
694
694
|
const allVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
|
|
695
695
|
const minTracked = allVersions[0] ?? null;
|
|
@@ -1490,6 +1490,97 @@ function runVideosFtsQuery(ftsQuery: string, limit: number): VideoSearchResult[]
|
|
|
1490
1490
|
|
|
1491
1491
|
// ── Current versions ──
|
|
1492
1492
|
|
|
1493
|
+
// ── Dude wiki search ──
|
|
1494
|
+
|
|
1495
|
+
export type DudeSearchResult = {
|
|
1496
|
+
id: number;
|
|
1497
|
+
title: string;
|
|
1498
|
+
path: string;
|
|
1499
|
+
version: string;
|
|
1500
|
+
url: string;
|
|
1501
|
+
word_count: number | null;
|
|
1502
|
+
image_count: number;
|
|
1503
|
+
excerpt: string;
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
export type DudeImageResult = {
|
|
1507
|
+
id: number;
|
|
1508
|
+
filename: string;
|
|
1509
|
+
alt_text: string | null;
|
|
1510
|
+
caption: string | null;
|
|
1511
|
+
local_path: string;
|
|
1512
|
+
original_url: string | null;
|
|
1513
|
+
wayback_url: string | null;
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
export type DudePageResult = {
|
|
1517
|
+
id: number;
|
|
1518
|
+
slug: string;
|
|
1519
|
+
title: string;
|
|
1520
|
+
path: string;
|
|
1521
|
+
version: string;
|
|
1522
|
+
url: string;
|
|
1523
|
+
wayback_url: string;
|
|
1524
|
+
text: string;
|
|
1525
|
+
code: string | null;
|
|
1526
|
+
last_edited: string | null;
|
|
1527
|
+
word_count: number | null;
|
|
1528
|
+
images: DudeImageResult[];
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
/** Search archived Dude wiki pages via FTS, with AND→OR fallback. */
|
|
1532
|
+
export function searchDude(query: string, limit = 8): DudeSearchResult[] {
|
|
1533
|
+
const terms = extractTerms(query);
|
|
1534
|
+
if (terms.length === 0) return [];
|
|
1535
|
+
|
|
1536
|
+
let ftsQuery = buildFtsQuery(terms, "AND");
|
|
1537
|
+
if (!ftsQuery) return [];
|
|
1538
|
+
let results = runDudeFtsQuery(ftsQuery, limit);
|
|
1539
|
+
|
|
1540
|
+
if (results.length === 0 && terms.length > 1) {
|
|
1541
|
+
ftsQuery = buildFtsQuery(terms, "OR");
|
|
1542
|
+
results = runDudeFtsQuery(ftsQuery, limit);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return results;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function runDudeFtsQuery(ftsQuery: string, limit: number): DudeSearchResult[] {
|
|
1549
|
+
if (!ftsQuery) return [];
|
|
1550
|
+
try {
|
|
1551
|
+
return db
|
|
1552
|
+
.prepare(
|
|
1553
|
+
`SELECT dp.id, dp.title, dp.path, dp.version, dp.url, dp.word_count,
|
|
1554
|
+
(SELECT COUNT(*) FROM dude_images di WHERE di.page_id = dp.id) as image_count,
|
|
1555
|
+
snippet(dude_pages_fts, 2, '**', '**', '...', 25) as excerpt
|
|
1556
|
+
FROM dude_pages_fts fts
|
|
1557
|
+
JOIN dude_pages dp ON dp.id = fts.rowid
|
|
1558
|
+
WHERE dude_pages_fts MATCH ?
|
|
1559
|
+
ORDER BY rank
|
|
1560
|
+
LIMIT ?`,
|
|
1561
|
+
)
|
|
1562
|
+
.all(ftsQuery, limit) as DudeSearchResult[];
|
|
1563
|
+
} catch {
|
|
1564
|
+
return [];
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Get a Dude wiki page by ID or title, with associated images. */
|
|
1569
|
+
export function getDudePage(idOrTitle: number | string): DudePageResult | null {
|
|
1570
|
+
const row =
|
|
1571
|
+
typeof idOrTitle === "number"
|
|
1572
|
+
? db.prepare("SELECT * FROM dude_pages WHERE id = ?").get(idOrTitle)
|
|
1573
|
+
: db.prepare("SELECT * FROM dude_pages WHERE title = ? COLLATE NOCASE OR slug = ? COLLATE NOCASE").get(idOrTitle, idOrTitle);
|
|
1574
|
+
if (!row) return null;
|
|
1575
|
+
const page = row as DudePageResult;
|
|
1576
|
+
page.images = db
|
|
1577
|
+
.prepare("SELECT id, filename, alt_text, caption, local_path, original_url, wayback_url FROM dude_images WHERE page_id = ? ORDER BY sort_order")
|
|
1578
|
+
.all(page.id) as DudeImageResult[];
|
|
1579
|
+
return page;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// ── Current versions (live fetch) ──
|
|
1583
|
+
|
|
1493
1584
|
const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
|
|
1494
1585
|
|
|
1495
1586
|
/** Fetch current RouterOS versions from MikroTik's upgrade server. */
|
package/src/release.test.ts
CHANGED
|
@@ -202,6 +202,21 @@ describe("Makefile", () => {
|
|
|
202
202
|
expect(phonyBlock).toContain("extract-videos");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
test("has extract-dude target", () => {
|
|
206
|
+
expect(makefile).toContain("extract-dude:");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("has extract-dude-from-cache target", () => {
|
|
210
|
+
expect(makefile).toContain("extract-dude-from-cache:");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("extract-dude is in PHONY", () => {
|
|
214
|
+
const phonyStart = makefile.indexOf(".PHONY:");
|
|
215
|
+
const phonyEnd = makefile.indexOf("\n\n", phonyStart);
|
|
216
|
+
const phonyBlock = makefile.slice(phonyStart, phonyEnd);
|
|
217
|
+
expect(phonyBlock).toContain("extract-dude");
|
|
218
|
+
});
|
|
219
|
+
|
|
205
220
|
test("release depends on preflight", () => {
|
|
206
221
|
expect(makefile).toMatch(/^release:.*preflight/m);
|
|
207
222
|
});
|
|
@@ -210,6 +225,14 @@ describe("Makefile", () => {
|
|
|
210
225
|
expect(makefile).toMatch(/^release:.*build-release/m);
|
|
211
226
|
});
|
|
212
227
|
|
|
228
|
+
test("extract target includes Dude cache import", () => {
|
|
229
|
+
expect(makefile).toMatch(/^extract:.*extract-dude-from-cache/m);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("extract-full target includes Dude cache import", () => {
|
|
233
|
+
expect(makefile).toMatch(/^extract-full:.*extract-dude-from-cache/m);
|
|
234
|
+
});
|
|
235
|
+
|
|
213
236
|
test("preflight checks dirty tree", () => {
|
|
214
237
|
expect(makefile).toContain("git diff --quiet");
|
|
215
238
|
});
|
|
@@ -255,6 +278,11 @@ describe("release.yml", () => {
|
|
|
255
278
|
expect(src).toContain("link-commands.ts");
|
|
256
279
|
});
|
|
257
280
|
|
|
281
|
+
test("imports Dude wiki from cache", () => {
|
|
282
|
+
const src = readText(".github/workflows/release.yml");
|
|
283
|
+
expect(src).toContain("extract-dude-from-cache");
|
|
284
|
+
});
|
|
285
|
+
|
|
258
286
|
test("runs quality gate before release", () => {
|
|
259
287
|
const src = readText(".github/workflows/release.yml");
|
|
260
288
|
expect(src).toContain("bun run typecheck");
|
|
@@ -292,6 +320,10 @@ describe("CLI flags", () => {
|
|
|
292
320
|
test("supports --setup flag", () => {
|
|
293
321
|
expect(src).toContain("--setup");
|
|
294
322
|
});
|
|
323
|
+
|
|
324
|
+
test("supports --refresh flag", () => {
|
|
325
|
+
expect(src).toContain("--refresh");
|
|
326
|
+
});
|
|
295
327
|
});
|
|
296
328
|
|
|
297
329
|
// ---------------------------------------------------------------------------
|