@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/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]} --setup --force`);
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 = 1;
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. */
@@ -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
  // ---------------------------------------------------------------------------