@tikoci/rosetta 0.3.1 → 0.4.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/query.test.ts CHANGED
@@ -13,7 +13,7 @@ import { beforeAll, describe, expect, test } from "bun:test";
13
13
  process.env.DB_PATH = ":memory:";
14
14
 
15
15
  // Dynamic imports so the env-var assignment above is visible to db.ts
16
- const { db, initDb } = await import("./db.ts");
16
+ const { db, initDb, getDbStats } = await import("./db.ts");
17
17
  const {
18
18
  extractTerms,
19
19
  buildFtsQuery,
@@ -25,6 +25,8 @@ const {
25
25
  searchChangelogs,
26
26
  checkCommandVersions,
27
27
  searchDevices,
28
+ searchDeviceTests,
29
+ getTestResultMeta,
28
30
  } = await import("./query.ts");
29
31
  const { parseChangelog } = await import("./extract-changelogs.ts");
30
32
 
@@ -69,6 +71,10 @@ beforeAll(() => {
69
71
 
70
72
  db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
71
73
  VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
74
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
75
+ VALUES ('7.9', 'stable', 0, '2023-01-01T00:00:00Z')`);
76
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
77
+ VALUES ('7.10.2', 'stable', 0, '2023-06-01T00:00:00Z')`);
72
78
 
73
79
  db.run(`INSERT INTO commands
74
80
  (id, path, name, type, parent_path, page_id, description, ros_version)
@@ -80,6 +86,8 @@ beforeAll(() => {
80
86
 
81
87
  db.run(`INSERT INTO command_versions (command_path, ros_version)
82
88
  VALUES ('/ip/dhcp-server', '7.22')`);
89
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
90
+ VALUES ('/ip/dhcp-server', '7.9')`);
83
91
 
84
92
  // Device fixtures for searchDevices tests
85
93
  db.run(`INSERT INTO devices
@@ -87,13 +95,15 @@ beforeAll(() => {
87
95
  license_level, operating_system, ram, ram_mb, storage, storage_mb,
88
96
  poe_in, poe_out, wireless_24_chains, wireless_5_chains,
89
97
  eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
90
- eth_multigig, usb_ports, sim_slots, msrp_usd)
98
+ eth_multigig, usb_ports, sim_slots, msrp_usd,
99
+ product_url, block_diagram_url)
91
100
  VALUES
92
101
  ('hAP ax3', 'C53UiG+5HPaxD2HPaxD', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
93
102
  4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
94
103
  '802.3af/at', NULL, 2, 2,
95
104
  NULL, 4, 1, NULL, NULL,
96
- NULL, 1, NULL, 139.00)`);
105
+ NULL, 1, NULL, 139.00,
106
+ 'https://mikrotik.com/product/hap_ax3', 'https://cdn.mikrotik.com/web-assets/product_files/hap_ax3_123.png')`);
97
107
 
98
108
  db.run(`INSERT INTO devices
99
109
  (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
@@ -161,6 +171,17 @@ beforeAll(() => {
161
171
  NULL, 13, NULL, NULL, NULL,
162
172
  NULL, NULL, NULL, 369.00)`);
163
173
 
174
+ // Device test results fixtures (hAP ax3 = id 1)
175
+ db.run(`INSERT INTO device_test_results
176
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
177
+ VALUES (1, 'ethernet', 'Routing', '25 ip filter rules', 512, 755.9, 3096.2)`);
178
+ db.run(`INSERT INTO device_test_results
179
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
180
+ VALUES (1, 'ethernet', 'Routing', 'none (fast path)', 512, 2332.0, 9551.9)`);
181
+ db.run(`INSERT INTO device_test_results
182
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
183
+ VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
184
+
164
185
  // Page 3: a "large" page with sections for TOC testing
165
186
  // Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
166
187
  db.run(`INSERT INTO pages
@@ -622,8 +643,8 @@ describe("searchCallouts", () => {
622
643
  describe("checkCommandVersions", () => {
623
644
  test("returns versions for known command path", () => {
624
645
  const res = checkCommandVersions("/ip/dhcp-server");
625
- expect(res.versions).toEqual(["7.22"]);
626
- expect(res.first_seen).toBe("7.22");
646
+ expect(res.versions).toEqual(["7.9", "7.22"]);
647
+ expect(res.first_seen).toBe("7.9");
627
648
  expect(res.last_seen).toBe("7.22");
628
649
  });
629
650
 
@@ -783,6 +804,122 @@ describe("searchDevices", () => {
783
804
  const res = searchDevices("");
784
805
  expect(res.results).toHaveLength(0);
785
806
  });
807
+
808
+ test("exact match includes test_results", () => {
809
+ const res = searchDevices("hAP ax3");
810
+ expect(res.mode).toBe("exact");
811
+ expect(res.results).toHaveLength(1);
812
+ const dev = res.results[0];
813
+ expect(dev.test_results).toBeDefined();
814
+ expect(dev.test_results!.length).toBe(3);
815
+ expect(dev.test_results!.some((t) => t.test_type === "ethernet")).toBe(true);
816
+ expect(dev.test_results!.some((t) => t.test_type === "ipsec")).toBe(true);
817
+ });
818
+
819
+ test("LIKE match with ≤5 results includes test_results", () => {
820
+ const res = searchDevices("RB1100");
821
+ expect(res.mode).toBe("like");
822
+ expect(res.results.length).toBeLessThanOrEqual(5);
823
+ // RB1100 devices have no test results, but the field should still be populated (empty array)
824
+ expect(res.results.every((d) => Array.isArray(d.test_results))).toBe(true);
825
+ });
826
+
827
+ test("exact match includes product_url and block_diagram_url", () => {
828
+ const res = searchDevices("hAP ax3");
829
+ expect(res.results[0].product_url).toBe("https://mikrotik.com/product/hap_ax3");
830
+ expect(res.results[0].block_diagram_url).toContain("cdn.mikrotik.com");
831
+ });
832
+
833
+ test("devices without product_url return null", () => {
834
+ const res = searchDevices("CCR2216-1G-12XS-2XQ");
835
+ expect(res.results[0].product_url).toBeNull();
836
+ expect(res.results[0].block_diagram_url).toBeNull();
837
+ });
838
+
839
+ test("has_more is false when all results fit", () => {
840
+ const res = searchDevices("", { architecture: "ARM 64bit" }, 50);
841
+ expect(res.has_more).toBe(false);
842
+ });
843
+
844
+ test("has_more is true when results are truncated", () => {
845
+ // We have multiple devices; limit to 1 with a broad filter
846
+ const res = searchDevices("", { architecture: "ARM 64bit" }, 1);
847
+ expect(res.results).toHaveLength(1);
848
+ expect(res.has_more).toBe(true);
849
+ });
850
+
851
+ test("single FTS match attaches test_results", () => {
852
+ // "hAP ax3" as FTS should find exactly one match and attach test results
853
+ const res = searchDevices("hAP ax3");
854
+ if (res.mode === "exact" || res.results.length === 1) {
855
+ expect(res.results[0].test_results).toBeDefined();
856
+ }
857
+ });
858
+ });
859
+
860
+ // ---------------------------------------------------------------------------
861
+ // searchDeviceTests: cross-device test queries
862
+ // ---------------------------------------------------------------------------
863
+
864
+ describe("searchDeviceTests", () => {
865
+ test("returns all test results with no filters", () => {
866
+ const res = searchDeviceTests({});
867
+ expect(res.results.length).toBe(3);
868
+ expect(res.total).toBe(3);
869
+ });
870
+
871
+ test("filters by test_type", () => {
872
+ const res = searchDeviceTests({ test_type: "ethernet" });
873
+ expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
874
+ expect(res.results.length).toBe(2);
875
+ });
876
+
877
+ test("filters by test_type and mode", () => {
878
+ const res = searchDeviceTests({ test_type: "ipsec", mode: "Single tunnel" });
879
+ expect(res.results).toHaveLength(1);
880
+ expect(res.results[0].configuration).toBe("AES-128-CBC + SHA1");
881
+ });
882
+
883
+ test("configuration uses LIKE matching", () => {
884
+ const res = searchDeviceTests({ configuration: "25 ip filter" });
885
+ expect(res.results).toHaveLength(1);
886
+ expect(res.results[0].configuration).toBe("25 ip filter rules");
887
+ });
888
+
889
+ test("filters by packet_size", () => {
890
+ const res = searchDeviceTests({ packet_size: 512 });
891
+ expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
892
+ });
893
+
894
+ test("sorts by mbps descending by default", () => {
895
+ const res = searchDeviceTests({ test_type: "ethernet" });
896
+ if (res.results.length >= 2) {
897
+ const mbps = res.results.map((r) => r.throughput_mbps ?? 0);
898
+ expect(mbps[0]).toBeGreaterThanOrEqual(mbps[1]);
899
+ }
900
+ });
901
+
902
+ test("includes device info in results", () => {
903
+ const res = searchDeviceTests({ test_type: "ethernet" });
904
+ expect(res.results[0].product_name).toBeDefined();
905
+ expect(res.results[0].architecture).toBeDefined();
906
+ });
907
+
908
+ test("respects limit", () => {
909
+ const res = searchDeviceTests({}, 1);
910
+ expect(res.results).toHaveLength(1);
911
+ expect(res.total).toBe(3);
912
+ });
913
+ });
914
+
915
+ describe("getTestResultMeta", () => {
916
+ test("returns distinct values", () => {
917
+ const meta = getTestResultMeta();
918
+ expect(meta.test_types).toContain("ethernet");
919
+ expect(meta.test_types).toContain("ipsec");
920
+ expect(meta.modes).toContain("Routing");
921
+ expect(meta.packet_sizes).toContain(512);
922
+ });
786
923
  });
787
924
 
788
925
  // ---------------------------------------------------------------------------
@@ -943,7 +1080,7 @@ describe("schema", () => {
943
1080
  const expected = [
944
1081
  "pages", "properties", "callouts", "sections",
945
1082
  "commands", "command_versions", "ros_versions",
946
- "devices", "changelogs", "schema_migrations",
1083
+ "devices", "device_test_results", "changelogs", "schema_migrations",
947
1084
  ];
948
1085
  for (const table of expected) {
949
1086
  expect(names).toContain(table);
@@ -993,3 +1130,16 @@ describe("schema", () => {
993
1130
  expect(triggers).toContain("changelogs_au");
994
1131
  });
995
1132
  });
1133
+
1134
+ // ---------------------------------------------------------------------------
1135
+ // getDbStats: version range uses semantic sort (not lexicographic)
1136
+ // ---------------------------------------------------------------------------
1137
+
1138
+ describe("getDbStats", () => {
1139
+ test("version range is semantically sorted (7.9 < 7.10.2 < 7.22)", () => {
1140
+ const stats = getDbStats();
1141
+ // Fixtures have 7.9, 7.10.2, 7.22 — lexicographic MIN would give "7.10.2", not "7.9"
1142
+ expect(stats.ros_version_min).toBe("7.9");
1143
+ expect(stats.ros_version_max).toBe("7.22");
1144
+ });
1145
+ });
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). */
@@ -732,14 +764,14 @@ export function searchDevices(
732
764
  query: string,
733
765
  filters: DeviceFilters = {},
734
766
  limit = 10,
735
- ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number } {
767
+ ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number; has_more: boolean } {
736
768
  // 1. Try exact match on product_name or product_code
737
769
  if (query) {
738
770
  const exact = db
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, has_more: false };
743
775
  }
744
776
  }
745
777
 
@@ -757,10 +789,17 @@ export function searchDevices(
757
789
  () => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
758
790
  );
759
791
  const likeParams = likeTerms.flatMap((t) => [t, t]);
792
+ // Fetch limit+1 to detect truncation
760
793
  const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
761
- const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
794
+ const likeResults = db.prepare(likeSql).all(...likeParams, limit + 1) as DeviceResult[];
762
795
  if (likeResults.length > 0) {
763
- return { results: likeResults, mode: "like", total: likeResults.length };
796
+ const hasMore = likeResults.length > limit;
797
+ const trimmed = hasMore ? likeResults.slice(0, limit) : likeResults;
798
+ // Attach test results for small result sets (likely specific device lookups)
799
+ if (trimmed.length <= 5) attachTestResults(trimmed);
800
+ // Single match in any mode gets test results
801
+ else if (trimmed.length === 1) attachTestResults(trimmed);
802
+ return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore };
764
803
  }
765
804
  }
766
805
  }
@@ -807,15 +846,20 @@ export function searchDevices(
807
846
  d.ram, d.ram_mb, d.storage, d.storage_mb, d.dimensions, d.poe_in, d.poe_out,
808
847
  d.max_power_w, d.wireless_24_chains, d.wireless_5_chains,
809
848
  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
849
+ d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd,
850
+ d.product_url, d.block_diagram_url
811
851
  FROM devices_fts fts
812
852
  JOIN devices d ON d.id = fts.rowid
813
853
  WHERE devices_fts MATCH ?${filterWhere}
814
854
  ORDER BY rank LIMIT ?`;
815
855
  try {
816
- const results = db.prepare(sql).all(ftsQuery, ...params, limit) as DeviceResult[];
856
+ const results = db.prepare(sql).all(ftsQuery, ...params, limit + 1) as DeviceResult[];
817
857
  if (results.length > 0) {
818
- return { results, mode: "fts", total: results.length };
858
+ const hasMore = results.length > limit;
859
+ const trimmed = hasMore ? results.slice(0, limit) : results;
860
+ // Single FTS match gets test results (same behavior as exact)
861
+ if (trimmed.length === 1) attachTestResults(trimmed);
862
+ return { results: trimmed, mode: "fts", total: trimmed.length, has_more: hasMore };
819
863
  }
820
864
  } catch { /* fall through to OR */ }
821
865
 
@@ -823,9 +867,12 @@ export function searchDevices(
823
867
  if (terms.length > 1) {
824
868
  const orQuery = buildDeviceFtsQuery(terms, "OR");
825
869
  try {
826
- const results = db.prepare(sql).all(orQuery, ...params, limit) as DeviceResult[];
870
+ const results = db.prepare(sql).all(orQuery, ...params, limit + 1) as DeviceResult[];
827
871
  if (results.length > 0) {
828
- return { results, mode: "fts+or", total: results.length };
872
+ const hasMore = results.length > limit;
873
+ const trimmed = hasMore ? results.slice(0, limit) : results;
874
+ if (trimmed.length === 1) attachTestResults(trimmed);
875
+ return { results: trimmed, mode: "fts+or", total: trimmed.length, has_more: hasMore };
829
876
  }
830
877
  } catch { /* fall through */ }
831
878
  }
@@ -835,11 +882,98 @@ export function searchDevices(
835
882
  // 4. Filter-only (no FTS query)
836
883
  if (whereClauses.length > 0) {
837
884
  const sql = `${DEVICE_SELECT} d WHERE ${whereClauses.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
838
- const results = db.prepare(sql).all(...params, limit) as DeviceResult[];
839
- return { results, mode: "filter", total: results.length };
885
+ const results = db.prepare(sql).all(...params, limit + 1) as DeviceResult[];
886
+ const hasMore = results.length > limit;
887
+ const trimmed = hasMore ? results.slice(0, limit) : results;
888
+ return { results: trimmed, mode: "filter", total: trimmed.length, has_more: hasMore };
889
+ }
890
+
891
+ return { results: [], mode: "fts", total: 0, has_more: false };
892
+ }
893
+
894
+ // ── Cross-device test result queries ──
895
+
896
+ export type DeviceTestRow = {
897
+ product_name: string;
898
+ product_code: string | null;
899
+ architecture: string;
900
+ cpu: string | null;
901
+ cpu_cores: number | null;
902
+ cpu_frequency: string | null;
903
+ test_type: string;
904
+ mode: string;
905
+ configuration: string;
906
+ packet_size: number;
907
+ throughput_kpps: number | null;
908
+ throughput_mbps: number | null;
909
+ };
910
+
911
+ export function searchDeviceTests(
912
+ filters: {
913
+ test_type?: string;
914
+ mode?: string;
915
+ configuration?: string;
916
+ packet_size?: number;
917
+ sort_by?: "mbps" | "kpps";
918
+ },
919
+ limit = 50,
920
+ ): { results: DeviceTestRow[]; total: number } {
921
+ const where: string[] = [];
922
+ const params: (string | number)[] = [];
923
+
924
+ if (filters.test_type) {
925
+ where.push("t.test_type = ?");
926
+ params.push(filters.test_type);
927
+ }
928
+ if (filters.mode) {
929
+ where.push("t.mode = ?");
930
+ params.push(filters.mode);
931
+ }
932
+ if (filters.configuration) {
933
+ where.push("t.configuration LIKE ?");
934
+ params.push(`%${filters.configuration}%`);
840
935
  }
936
+ if (filters.packet_size) {
937
+ where.push("t.packet_size = ?");
938
+ params.push(filters.packet_size);
939
+ }
940
+
941
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
942
+ const orderCol = filters.sort_by === "kpps" ? "t.throughput_kpps" : "t.throughput_mbps";
943
+
944
+ // Total count (before limit)
945
+ const totalSql = `SELECT COUNT(*) AS c FROM device_test_results t
946
+ JOIN devices d ON d.id = t.device_id ${whereClause}`;
947
+ const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
948
+
949
+ const sql = `SELECT d.product_name, d.product_code, d.architecture, d.cpu,
950
+ d.cpu_cores, d.cpu_frequency,
951
+ t.test_type, t.mode, t.configuration, t.packet_size,
952
+ t.throughput_kpps, t.throughput_mbps
953
+ FROM device_test_results t
954
+ JOIN devices d ON d.id = t.device_id
955
+ ${whereClause}
956
+ ORDER BY ${orderCol} DESC NULLS LAST
957
+ LIMIT ?`;
958
+
959
+ const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
960
+ return { results, total };
961
+ }
841
962
 
842
- return { results: [], mode: "fts", total: 0 };
963
+ /** Get distinct values for test result fields (for discovery). */
964
+ export function getTestResultMeta(): {
965
+ test_types: string[];
966
+ modes: string[];
967
+ configurations: string[];
968
+ packet_sizes: number[];
969
+ } {
970
+ const col = (sql: string) => (db.prepare(sql).all() as Array<{ v: string }>).map((r) => r.v);
971
+ return {
972
+ test_types: col("SELECT DISTINCT test_type AS v FROM device_test_results ORDER BY v"),
973
+ modes: col("SELECT DISTINCT mode AS v FROM device_test_results ORDER BY v"),
974
+ configurations: col("SELECT DISTINCT configuration AS v FROM device_test_results ORDER BY v"),
975
+ packet_sizes: (db.prepare("SELECT DISTINCT packet_size AS v FROM device_test_results ORDER BY v DESC").all() as Array<{ v: number }>).map((r) => r.v),
976
+ };
843
977
  }
844
978
 
845
979
  const VERSION_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
@@ -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
  });
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");