@tikoci/rosetta 0.3.0 → 0.4.0

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/query.test.ts CHANGED
@@ -87,13 +87,15 @@ beforeAll(() => {
87
87
  license_level, operating_system, ram, ram_mb, storage, storage_mb,
88
88
  poe_in, poe_out, wireless_24_chains, wireless_5_chains,
89
89
  eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
90
- eth_multigig, usb_ports, sim_slots, msrp_usd)
90
+ eth_multigig, usb_ports, sim_slots, msrp_usd,
91
+ product_url, block_diagram_url)
91
92
  VALUES
92
93
  ('hAP ax3', 'C53UiG+5HPaxD2HPaxD', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
93
94
  4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
94
95
  '802.3af/at', NULL, 2, 2,
95
96
  NULL, 4, 1, NULL, NULL,
96
- NULL, 1, NULL, 139.00)`);
97
+ NULL, 1, NULL, 139.00,
98
+ 'https://mikrotik.com/product/hap_ax3', 'https://cdn.mikrotik.com/web-assets/product_files/hap_ax3_123.png')`);
97
99
 
98
100
  db.run(`INSERT INTO devices
99
101
  (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
@@ -161,6 +163,17 @@ beforeAll(() => {
161
163
  NULL, 13, NULL, NULL, NULL,
162
164
  NULL, NULL, NULL, 369.00)`);
163
165
 
166
+ // Device test results fixtures (hAP ax3 = id 1)
167
+ db.run(`INSERT INTO device_test_results
168
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
169
+ VALUES (1, 'ethernet', 'Routing', '25 ip filter rules', 512, 755.9, 3096.2)`);
170
+ db.run(`INSERT INTO device_test_results
171
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
172
+ VALUES (1, 'ethernet', 'Routing', 'none (fast path)', 512, 2332.0, 9551.9)`);
173
+ db.run(`INSERT INTO device_test_results
174
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
175
+ VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
176
+
164
177
  // Page 3: a "large" page with sections for TOC testing
165
178
  // Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
166
179
  db.run(`INSERT INTO pages
@@ -783,6 +796,37 @@ describe("searchDevices", () => {
783
796
  const res = searchDevices("");
784
797
  expect(res.results).toHaveLength(0);
785
798
  });
799
+
800
+ test("exact match includes test_results", () => {
801
+ const res = searchDevices("hAP ax3");
802
+ expect(res.mode).toBe("exact");
803
+ expect(res.results).toHaveLength(1);
804
+ const dev = res.results[0];
805
+ expect(dev.test_results).toBeDefined();
806
+ expect(dev.test_results!.length).toBe(3);
807
+ expect(dev.test_results!.some((t) => t.test_type === "ethernet")).toBe(true);
808
+ expect(dev.test_results!.some((t) => t.test_type === "ipsec")).toBe(true);
809
+ });
810
+
811
+ test("LIKE match with ≤5 results includes test_results", () => {
812
+ const res = searchDevices("RB1100");
813
+ expect(res.mode).toBe("like");
814
+ expect(res.results.length).toBeLessThanOrEqual(5);
815
+ // RB1100 devices have no test results, but the field should still be populated (empty array)
816
+ expect(res.results.every((d) => Array.isArray(d.test_results))).toBe(true);
817
+ });
818
+
819
+ test("exact match includes product_url and block_diagram_url", () => {
820
+ const res = searchDevices("hAP ax3");
821
+ expect(res.results[0].product_url).toBe("https://mikrotik.com/product/hap_ax3");
822
+ expect(res.results[0].block_diagram_url).toContain("cdn.mikrotik.com");
823
+ });
824
+
825
+ test("devices without product_url return null", () => {
826
+ const res = searchDevices("CCR2216-1G-12XS-2XQ");
827
+ expect(res.results[0].product_url).toBeNull();
828
+ expect(res.results[0].block_diagram_url).toBeNull();
829
+ });
786
830
  });
787
831
 
788
832
  // ---------------------------------------------------------------------------
@@ -943,7 +987,7 @@ describe("schema", () => {
943
987
  const expected = [
944
988
  "pages", "properties", "callouts", "sections",
945
989
  "commands", "command_versions", "ros_versions",
946
- "devices", "changelogs", "schema_migrations",
990
+ "devices", "device_test_results", "changelogs", "schema_migrations",
947
991
  ];
948
992
  for (const table of expected) {
949
993
  expect(names).toContain(table);
package/src/query.ts CHANGED
@@ -669,6 +669,15 @@ export function browseCommandsAtVersion(
669
669
 
670
670
  // ── Device lookup and search ──
671
671
 
672
+ export type DeviceTestResult = {
673
+ test_type: string;
674
+ mode: string;
675
+ configuration: string;
676
+ packet_size: number;
677
+ throughput_kpps: number | null;
678
+ throughput_mbps: number | null;
679
+ };
680
+
672
681
  export type DeviceResult = {
673
682
  id: number;
674
683
  product_name: string;
@@ -698,6 +707,9 @@ export type DeviceResult = {
698
707
  usb_ports: number | null;
699
708
  sim_slots: number | null;
700
709
  msrp_usd: number | null;
710
+ product_url: string | null;
711
+ block_diagram_url: string | null;
712
+ test_results?: DeviceTestResult[];
701
713
  };
702
714
 
703
715
  export type DeviceFilters = {
@@ -715,9 +727,29 @@ const DEVICE_SELECT = `SELECT id, product_name, product_code, architecture, cpu,
715
727
  ram, ram_mb, storage, storage_mb, dimensions, poe_in, poe_out,
716
728
  max_power_w, wireless_24_chains, wireless_5_chains,
717
729
  eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
718
- eth_multigig, usb_ports, sim_slots, msrp_usd
730
+ eth_multigig, usb_ports, sim_slots, msrp_usd,
731
+ product_url, block_diagram_url
719
732
  FROM devices`;
720
733
 
734
+ /** Get test results for a device by ID. */
735
+ function getDeviceTestResults(deviceId: number): DeviceTestResult[] {
736
+ return db.prepare(
737
+ `SELECT test_type, mode, configuration, packet_size,
738
+ throughput_kpps, throughput_mbps
739
+ FROM device_test_results
740
+ WHERE device_id = ?
741
+ ORDER BY test_type, mode, configuration, packet_size DESC`
742
+ ).all(deviceId) as DeviceTestResult[];
743
+ }
744
+
745
+ /** Attach test results to device results (for single/exact lookups). */
746
+ function attachTestResults(devices: DeviceResult[]): DeviceResult[] {
747
+ for (const dev of devices) {
748
+ dev.test_results = getDeviceTestResults(dev.id);
749
+ }
750
+ return devices;
751
+ }
752
+
721
753
  /** Build FTS5 query for devices — appends prefix '*' to every term.
722
754
  * Model numbers like "RB1100" need prefix matching to find "RB1100AHx4".
723
755
  * No compound term handling (not relevant for device names). */
@@ -739,7 +771,7 @@ export function searchDevices(
739
771
  .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
740
772
  .all(query, query) as DeviceResult[];
741
773
  if (exact.length > 0) {
742
- return { results: exact, mode: "exact", total: exact.length };
774
+ return { results: attachTestResults(exact), mode: "exact", total: exact.length };
743
775
  }
744
776
  }
745
777
 
@@ -760,6 +792,8 @@ export function searchDevices(
760
792
  const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
761
793
  const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
762
794
  if (likeResults.length > 0) {
795
+ // Attach test results for small result sets (likely specific device lookups)
796
+ if (likeResults.length <= 5) attachTestResults(likeResults);
763
797
  return { results: likeResults, mode: "like", total: likeResults.length };
764
798
  }
765
799
  }
@@ -807,7 +841,8 @@ export function searchDevices(
807
841
  d.ram, d.ram_mb, d.storage, d.storage_mb, d.dimensions, d.poe_in, d.poe_out,
808
842
  d.max_power_w, d.wireless_24_chains, d.wireless_5_chains,
809
843
  d.eth_fast, d.eth_gigabit, d.eth_2500, d.sfp_ports, d.sfp_plus_ports,
810
- d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd
844
+ d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd,
845
+ d.product_url, d.block_diagram_url
811
846
  FROM devices_fts fts
812
847
  JOIN devices d ON d.id = fts.rowid
813
848
  WHERE devices_fts MATCH ?${filterWhere}
@@ -238,6 +238,7 @@ describe("release.yml", () => {
238
238
  expect(src).toContain("extract-properties.ts");
239
239
  expect(src).toContain("extract-commands.ts");
240
240
  expect(src).toContain("extract-devices.ts");
241
+ expect(src).toContain("extract-test-results.ts");
241
242
  expect(src).toContain("extract-changelogs.ts");
242
243
  expect(src).toContain("link-commands.ts");
243
244
  });
@@ -280,3 +281,108 @@ describe("CLI flags", () => {
280
281
  expect(src).toContain("--setup");
281
282
  });
282
283
  });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // HTTP transport structural checks — catch per-session breakage at build time
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe("HTTP transport structure", () => {
290
+ const src = readText("src/mcp.ts");
291
+
292
+ test("uses per-session transport routing (not single shared transport)", () => {
293
+ // The single-transport pattern was: `await server.connect(httpTransport)` at module level
294
+ // followed by `httpTransport.handleRequest(req)`. The per-session pattern has a
295
+ // transports Map and creates transport/server per session.
296
+ expect(src).toContain("new Map");
297
+ expect(src).toContain("transports.set");
298
+ expect(src).toContain("transports.get");
299
+ });
300
+
301
+ test("creates new McpServer per session, not one shared instance", () => {
302
+ // createServer() factory must exist and be called per-session
303
+ expect(src).toContain("function createServer()");
304
+ expect(src).toContain("createServer()");
305
+ });
306
+
307
+ test("checks isInitializeRequest before creating transport", () => {
308
+ expect(src).toContain("isInitializeRequest");
309
+ });
310
+
311
+ test("registers onsessioninitialized callback", () => {
312
+ expect(src).toContain("onsessioninitialized");
313
+ });
314
+
315
+ test("cleans up transport on close", () => {
316
+ expect(src).toContain("transport.onclose");
317
+ expect(src).toContain("transports.delete");
318
+ });
319
+
320
+ test("passes parsedBody to handleRequest after consuming body", () => {
321
+ // Once we req.json() for isInitializeRequest check, the body is consumed.
322
+ // Must pass parsedBody so the transport doesn't try to re-parse.
323
+ expect(src).toContain("parsedBody");
324
+ });
325
+
326
+ test("handles missing session ID on non-initialize requests", () => {
327
+ expect(src).toContain("No valid session ID provided");
328
+ });
329
+
330
+ test("handles invalid session ID with 404", () => {
331
+ expect(src).toContain("Session not found");
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Container / entrypoint checks
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe("container entrypoint", () => {
340
+ test("entrypoint script exists", () => {
341
+ expect(existsSync(path.join(ROOT, "scripts/container-entrypoint.sh"))).toBe(true);
342
+ });
343
+
344
+ test("defaults to --http mode", () => {
345
+ const src = readText("scripts/container-entrypoint.sh");
346
+ expect(src).toContain("--http");
347
+ });
348
+
349
+ test("defaults to 0.0.0.0 host binding", () => {
350
+ const src = readText("scripts/container-entrypoint.sh");
351
+ expect(src).toContain("0.0.0.0");
352
+ });
353
+
354
+ test("supports TLS via env vars", () => {
355
+ const src = readText("scripts/container-entrypoint.sh");
356
+ expect(src).toContain("TLS_CERT_PATH");
357
+ expect(src).toContain("TLS_KEY_PATH");
358
+ });
359
+ });
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Dockerfile structure
363
+ // ---------------------------------------------------------------------------
364
+
365
+ describe("Dockerfile.release", () => {
366
+ test("copies entrypoint script", () => {
367
+ const src = readText("Dockerfile.release");
368
+ expect(src).toContain("container-entrypoint.sh");
369
+ expect(src).toContain("ENTRYPOINT");
370
+ });
371
+
372
+ test("copies database into image", () => {
373
+ const src = readText("Dockerfile.release");
374
+ expect(src).toContain("ros-help.db");
375
+ });
376
+
377
+ test("exposes port 8080", () => {
378
+ const src = readText("Dockerfile.release");
379
+ expect(src).toContain("EXPOSE 8080");
380
+ });
381
+
382
+ test("injects build constants", () => {
383
+ const src = readText("Dockerfile.release");
384
+ expect(src).toContain("IS_COMPILED");
385
+ expect(src).toContain("VERSION");
386
+ expect(src).toContain("REPO_URL");
387
+ });
388
+ });
package/src/setup.ts CHANGED
@@ -65,6 +65,7 @@ export async function runSetup(force = false) {
65
65
  const dbPath = resolveDbPath(import.meta.dirname);
66
66
 
67
67
  console.log(`rosetta ${RELEASE_VERSION}`);
68
+ console.log(` ${link("https://github.com/tikoci/rosetta")}`);
68
69
  console.log();
69
70
 
70
71
  // ── Download DB if needed ──
@@ -301,8 +302,29 @@ function printHttpConfig(startCmd: string) {
301
302
  console.log(" For LAN access, replace localhost with the server's IP address.");
302
303
  console.log(" Use a reverse proxy (nginx, caddy) for production HTTPS.");
303
304
  console.log();
305
+
306
+ printMikroTikConfig();
307
+ }
308
+
309
+ /** Format a clickable terminal hyperlink using OSC 8 escape sequences. */
310
+ function link(url: string, display?: string): string {
311
+ return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
304
312
  }
305
313
 
314
+ function printMikroTikConfig() {
315
+ console.log("─".repeat(60));
316
+ console.log("MikroTik /app container (RouterOS 7.22+, x86 or ARM64):");
317
+ console.log("─".repeat(60));
318
+ console.log();
319
+ console.log(" Run directly on your MikroTik router — any MCP client on");
320
+ console.log(" the network can connect to the URL shown in the router UI.");
321
+ console.log();
322
+ console.log(" Requires: container package + device-mode enabled.");
323
+ console.log(` See: ${link("https://github.com/tikoci/rosetta#install-on-mikrotik-app", "README — Install on MikroTik")}`);
324
+ console.log();
325
+ }
326
+
327
+
306
328
  // Run directly
307
329
  if (import.meta.main) {
308
330
  const force = process.argv.includes("--force");