@tikoci/rosetta 0.3.1 → 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/README.md +135 -197
- package/package.json +1 -1
- package/src/db.ts +31 -1
- package/src/extract-test-results.ts +359 -0
- package/src/mcp.ts +25 -7
- package/src/query.test.ts +47 -3
- package/src/query.ts +38 -3
- package/src/release.test.ts +1 -0
- package/src/setup.ts +22 -0
package/README.md
CHANGED
|
@@ -1,66 +1,111 @@
|
|
|
1
1
|
# rosetta
|
|
2
2
|
|
|
3
|
-
MCP server
|
|
3
|
+
MCP server that gives AI assistants searchable access to the complete [MikroTik RouterOS documentation](https://help.mikrotik.com/docs/spaces/ROS/overview) — 317 pages, 4,860 properties, 40,000-entry command tree, hardware specs for 144 products, and direct links to help.mikrotik.com.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
If you need MikroTik docs, you likely have a MikroTik. Install rosetta once as a container on your router using [RouterOS /app](#install-on-mikrotik-app), and any AI assistant on the network can use it. Or [run it locally](#install-locally-with-bun) on your workstation.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### SQL-as-RAG
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Instead of vector embeddings, rosetta uses **SQLite [FTS5](https://www.sqlite.org/fts5.html) full-text search** as the retrieval layer — SQL-as-RAG. For structured technical docs, [BM25 ranking](https://www.sqlite.org/fts5.html#the_bm25_function) with [porter stemming](https://www.sqlite.org/fts5.html#porter_tokenizer) beats vector similarity: terms like `dhcp-snooping` and `/ip/firewall/filter` are exact tokens, not fuzzy embeddings. No API keys, no vector database — just a single SQLite file that searches in milliseconds.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
The data flows: **HTML docs → SQLite extraction → FTS5 indexes → MCP tools → your AI assistant.** The database is built once from MikroTik's official Confluence documentation export, then the MCP server exposes 11 search tools over stdio or HTTP transport.
|
|
14
|
-
|
|
15
|
-
## What's Inside
|
|
11
|
+
### What's Inside
|
|
16
12
|
|
|
17
13
|
- **317 documentation pages** from MikroTik's official help site (~515K words)
|
|
18
14
|
- **4,860 property definitions** with types, defaults, and descriptions
|
|
19
15
|
- **5,114 commands** in the RouterOS command hierarchy (551 directories, 34K arguments)
|
|
20
16
|
- **1,034 callout blocks** — warnings, notes, and tips with important caveats
|
|
21
17
|
- **144 hardware products** — CPU, RAM, storage, ports, PoE, wireless, license level, pricing
|
|
18
|
+
- **2,874 performance benchmarks** — ethernet and IPSec throughput test results for 125 devices (64/512/1518-byte packets, multiple routing/bridging modes), plus block diagrams for 110
|
|
22
19
|
- **46 RouterOS versions tracked** (7.9 through 7.23beta2) for command history
|
|
23
20
|
- Direct links to help.mikrotik.com for every page and section
|
|
24
21
|
|
|
25
|
-
|
|
22
|
+
---
|
|
26
23
|
|
|
27
|
-
##
|
|
24
|
+
## Install on MikroTik (/app)
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
- Official MCP Registry publication: metadata is now prepared in `server.json`
|
|
26
|
+
RouterOS 7.22+ includes the [/app](https://help.mikrotik.com/docs/spaces/ROS/pages/328068) feature for running containers directly on the router. This is the simplest way to deploy rosetta — install once, and any AI assistant on your network can connect to the MCP endpoint URL shown in the router UI.
|
|
31
27
|
|
|
32
|
-
|
|
28
|
+
**Requirements:** RouterOS 7.22+, x86 or ARM64 architecture (CCR, RB5009, hAP ax series, CHR, etc.), container package installed, device-mode enabled.
|
|
33
29
|
|
|
34
|
-
|
|
30
|
+
### 1. Enable containers (two reboots required)
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
If you haven't already enabled the container package and device-mode:
|
|
33
|
+
|
|
34
|
+
```routeros
|
|
35
|
+
# Install the container package (router reboots automatically)
|
|
36
|
+
/system/package/update/check-for-updates duration=10s
|
|
37
|
+
/system/package/enable container
|
|
38
|
+
# Apply changes restarts the router
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
After
|
|
41
|
+
After reboot:
|
|
44
42
|
|
|
45
|
-
```
|
|
46
|
-
|
|
43
|
+
```routeros
|
|
44
|
+
# Enable container device-mode (requires physical power cycle or button press — follow the on-screen prompt)
|
|
45
|
+
/system/device-mode/update mode=advanced container=yes
|
|
47
46
|
```
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
See MikroTik's [Container documentation](https://help.mikrotik.com/docs/spaces/ROS/pages/Container) for full prerequisites and troubleshooting.
|
|
49
|
+
|
|
50
|
+
### 2. Add the rosetta app
|
|
51
|
+
|
|
52
|
+
```routeros
|
|
53
|
+
/app/add use-https=yes disabled=no yaml="name: rosetta
|
|
54
|
+
descr: \"RouterOS Docs for AI assistants - use URL as MCP server\"
|
|
55
|
+
page: https://tikoci.github.io/p/rosetta
|
|
56
|
+
category: development
|
|
57
|
+
icon: https://tikoci.github.io/p/rosetta.svg
|
|
58
|
+
default-credentials: \"none - just use 'ui-url' as the MCP server in your AI assistant\"
|
|
59
|
+
url-path: /mcp
|
|
60
|
+
auto-update: true
|
|
61
|
+
services:
|
|
62
|
+
rosetta:
|
|
63
|
+
image: ghcr.io/tikoci/rosetta:latest
|
|
64
|
+
container_name: mcp-server
|
|
65
|
+
ports:
|
|
66
|
+
- 9803:8080/tcp:web
|
|
67
|
+
"
|
|
68
|
+
```
|
|
50
69
|
|
|
51
|
-
|
|
70
|
+
That's it. RouterOS downloads the container image, configures networking and firewall redirects, and starts the MCP server. The `auto-update: true` setting pulls the latest image on each boot.
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
### 3. Get the MCP endpoint URL
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
# macOS / Linux
|
|
57
|
-
curl -fsSL https://bun.sh/install | bash
|
|
74
|
+
The URL to use with your AI assistant is shown as **UI URL** in WebFig (App → rosetta), or from the CLI:
|
|
58
75
|
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
```routeros
|
|
77
|
+
:put [/app/get rosetta ui-url]
|
|
61
78
|
```
|
|
62
79
|
|
|
63
|
-
|
|
80
|
+
This URL includes the `/mcp` path and is ready to paste into any MCP client that supports HTTP transport. With `use-https=yes`, the URL uses HTTPS with a MikroTik-managed `*.routingthecloud.net` certificate.
|
|
81
|
+
|
|
82
|
+
### 4. Configure your AI assistant
|
|
83
|
+
|
|
84
|
+
Point any HTTP-capable MCP client at the URL from the previous step:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "url": "https://app-rosetta.XXX.routingthecloud.net/mcp" }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> **CHR note:** Cloud Hosted Router in free or trial mode does not include the `/ip/cloud` service needed for HTTPS certificates. Set `use-https=no` on the /app — the URL will use HTTP instead. The UI URL always reflects the correct protocol.
|
|
91
|
+
|
|
92
|
+
> **HTTP option:** On any platform, you may choose `use-https=no` if you prefer HTTP or are on an isolated network.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Install Locally (with Bun)
|
|
97
|
+
|
|
98
|
+
Run rosetta on your workstation using [Bun](https://bun.sh/). The MCP server runs over stdio — no network configuration needed. The database downloads automatically on first launch (~50 MB compressed).
|
|
99
|
+
|
|
100
|
+
### Quick setup
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
bunx @tikoci/rosetta --setup
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This downloads the database and prints config snippets for all supported MCP clients. Copy-paste the config for your client and you're done.
|
|
107
|
+
|
|
108
|
+
### Or configure manually
|
|
64
109
|
|
|
65
110
|
<details>
|
|
66
111
|
<summary><b>VS Code Copilot</b></summary>
|
|
@@ -98,8 +143,6 @@ Edit your Claude Desktop config file:
|
|
|
98
143
|
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
99
144
|
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
100
145
|
|
|
101
|
-
Add (or merge into existing config):
|
|
102
|
-
|
|
103
146
|
```json
|
|
104
147
|
{
|
|
105
148
|
"mcpServers": {
|
|
@@ -111,22 +154,9 @@ Add (or merge into existing config):
|
|
|
111
154
|
}
|
|
112
155
|
```
|
|
113
156
|
|
|
114
|
-
> **PATH note:** Claude Desktop on macOS doesn't always inherit your shell PATH. If `bunx` isn't found, use the full path
|
|
115
|
-
|
|
116
|
-
Then **restart Claude Desktop**.
|
|
117
|
-
|
|
118
|
-
</details>
|
|
119
|
-
|
|
120
|
-
<details>
|
|
121
|
-
<summary><b>GitHub Copilot CLI</b></summary>
|
|
122
|
-
|
|
123
|
-
Inside a `copilot` session, type `/mcp add` to open the interactive form:
|
|
157
|
+
> **PATH note:** Claude Desktop on macOS doesn't always inherit your shell PATH. If `bunx` isn't found, use the full path (typically `~/.bun/bin/bunx`). Run `bunx @tikoci/rosetta --setup` to print the full-path config.
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
- **Server Type:** 2 (STDIO)
|
|
127
|
-
- **Command:** `bunx @tikoci/rosetta`
|
|
128
|
-
|
|
129
|
-
Press <kbd>Tab</kbd> to navigate fields, <kbd>Ctrl+S</kbd> to save.
|
|
159
|
+
Restart Claude Desktop after editing.
|
|
130
160
|
|
|
131
161
|
</details>
|
|
132
162
|
|
|
@@ -155,21 +185,38 @@ Open **Settings → MCP** and add a new server:
|
|
|
155
185
|
codex mcp add rosetta -- bunx @tikoci/rosetta
|
|
156
186
|
```
|
|
157
187
|
|
|
158
|
-
> **Note:** ChatGPT Apps require a remote HTTPS MCP endpoint
|
|
188
|
+
> **Note:** ChatGPT Apps require a remote HTTPS MCP endpoint. Use the [MikroTik /app install](#install-on-mikrotik-app) or another container platform for a hosted endpoint, or Codex CLI for local stdio.
|
|
159
189
|
|
|
160
190
|
</details>
|
|
161
191
|
|
|
162
|
-
|
|
192
|
+
<details>
|
|
193
|
+
<summary><b>GitHub Copilot CLI</b></summary>
|
|
194
|
+
|
|
195
|
+
Inside a `copilot` session, type `/mcp add`:
|
|
196
|
+
|
|
197
|
+
- **Server Name:** `routeros-rosetta`
|
|
198
|
+
- **Server Type:** 2 (STDIO)
|
|
199
|
+
- **Command:** `bunx @tikoci/rosetta`
|
|
200
|
+
|
|
201
|
+
</details>
|
|
202
|
+
|
|
203
|
+
**Install Bun** (if you don't have it):
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
# macOS / Linux
|
|
207
|
+
curl -fsSL https://bun.sh/install | bash
|
|
208
|
+
|
|
209
|
+
# Windows
|
|
210
|
+
powershell -c "irm bun.sh/install.ps1 | iex"
|
|
211
|
+
```
|
|
163
212
|
|
|
164
|
-
> **
|
|
165
|
-
>
|
|
166
|
-
> **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. No manual update needed. (Note: the `~/.rosetta/ros-help.db` database persists across updates — it's re-downloaded only when missing or when you run `--setup --force`.)
|
|
213
|
+
> **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. The database in `~/.rosetta/ros-help.db` persists across updates.
|
|
167
214
|
|
|
168
|
-
|
|
215
|
+
---
|
|
169
216
|
|
|
170
|
-
|
|
217
|
+
## Install from Binary
|
|
171
218
|
|
|
172
|
-
|
|
219
|
+
Download a compiled binary from [Releases](https://github.com/tikoci/rosetta/releases) — no Bun, Node.js, or other runtime needed.
|
|
173
220
|
|
|
174
221
|
| Platform | File |
|
|
175
222
|
|----------|------|
|
|
@@ -178,23 +225,16 @@ Download a compiled binary from [Releases](https://github.com/tikoci/rosetta/rel
|
|
|
178
225
|
| Windows | `rosetta-windows-x64.zip` |
|
|
179
226
|
| Linux | `rosetta-linux-x64.zip` |
|
|
180
227
|
|
|
181
|
-
Extract the ZIP to a permanent location (e.g., `~/rosetta` or `C:\rosetta`).
|
|
182
|
-
|
|
183
|
-
**2. Run setup** to download the database and see MCP client config:
|
|
184
|
-
|
|
185
228
|
```sh
|
|
186
|
-
./rosetta --setup
|
|
229
|
+
./rosetta --setup # downloads DB + prints MCP client config
|
|
187
230
|
```
|
|
188
231
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
> **macOS Gatekeeper:** If macOS blocks the binary: `xattr -d com.apple.quarantine ./rosetta` or go to **System Settings → Privacy & Security → Allow Anyway**.
|
|
192
|
-
>
|
|
232
|
+
> **macOS Gatekeeper:** `xattr -d com.apple.quarantine ./rosetta` or System Settings → Privacy & Security → Allow Anyway.
|
|
193
233
|
> **Windows SmartScreen:** Click **More info → Run anyway**.
|
|
194
234
|
|
|
195
|
-
|
|
235
|
+
---
|
|
196
236
|
|
|
197
|
-
|
|
237
|
+
## Try It
|
|
198
238
|
|
|
199
239
|
Ask your AI assistant questions like:
|
|
200
240
|
|
|
@@ -204,6 +244,7 @@ Ask your AI assistant questions like:
|
|
|
204
244
|
- *"What are the firewall filter default chains?"*
|
|
205
245
|
- *"Show me warnings about hardware offloading"*
|
|
206
246
|
- *"Which MikroTik routers have L3HW offload, and more than 8 ports of 48V PoE? Include cost."*
|
|
247
|
+
- *"Compare the RB5009 and CCR2004 IPSec throughput at 1518-byte packets."*
|
|
207
248
|
|
|
208
249
|
## MCP Tools
|
|
209
250
|
|
|
@@ -219,7 +260,7 @@ The server provides 11 tools, designed to work together:
|
|
|
219
260
|
| `routeros_search_callouts` | Search warnings, notes, and tips across all pages |
|
|
220
261
|
| `routeros_search_changelogs` | Search parsed changelog entries — filter by version range, category, breaking changes |
|
|
221
262
|
| `routeros_command_version_check` | Check which RouterOS versions include a command |
|
|
222
|
-
| `routeros_device_lookup` | Hardware specs for 144 MikroTik products — filter by architecture, RAM, storage, PoE, wireless, LTE |
|
|
263
|
+
| `routeros_device_lookup` | Hardware specs for 144 MikroTik products — filter by architecture, RAM, storage, PoE, wireless, LTE. Includes ethernet/IPSec benchmarks and block diagrams for most devices |
|
|
223
264
|
| `routeros_stats` | Database health: page/property/command counts, coverage stats |
|
|
224
265
|
| `routeros_current_versions` | Fetch current RouterOS versions from MikroTik (live) |
|
|
225
266
|
|
|
@@ -232,15 +273,15 @@ The AI assistant typically starts with `routeros_search`, then drills into speci
|
|
|
232
273
|
| **First launch is slow** | One-time database download (~50 MB). Subsequent starts are instant. |
|
|
233
274
|
| **`npx @tikoci/rosetta` fails** | This package requires Bun, not Node.js. Use `bunx` instead of `npx`. |
|
|
234
275
|
| **`npm install -g` then `rosetta` fails** | Global npm install works if Bun is on PATH — it delegates to `bun` at runtime. But prefer `bunx` — it's simpler and auto-updates. |
|
|
235
|
-
| **ChatGPT Apps can't connect
|
|
276
|
+
| **ChatGPT Apps can't connect** | ChatGPT Apps require a remote HTTPS MCP endpoint. Use the [MikroTik /app install](#install-on-mikrotik-app) for a hosted endpoint, or Codex CLI for local stdio. |
|
|
236
277
|
| **Claude Desktop can't find `bunx`** | Claude Desktop on macOS may not inherit shell PATH. Use the full path to bunx (run `which bunx` to find it, typically `~/.bun/bin/bunx`). `bunx @tikoci/rosetta --setup` prints the full-path config. |
|
|
237
278
|
| **macOS Gatekeeper blocks binary** | Use `bunx` install (no Gatekeeper issues), or: `xattr -d com.apple.quarantine ./rosetta` |
|
|
238
279
|
| **Windows SmartScreen warning** | Use `bunx` install (no SmartScreen issues), or click **More info → Run anyway** |
|
|
239
|
-
| **How to update** | `bunx` always uses the latest published version. For binaries, re-download from [Releases](https://github.com/tikoci/rosetta/releases/latest). |
|
|
280
|
+
| **How to update** | `bunx` always uses the latest published version. For binaries, re-download from [Releases](https://github.com/tikoci/rosetta/releases/latest). MikroTik /app with `auto-update: true` pulls the latest image on each boot. |
|
|
240
281
|
|
|
241
282
|
## HTTP Transport
|
|
242
283
|
|
|
243
|
-
|
|
284
|
+
The [MikroTik /app install](#install-on-mikrotik-app) is the easiest way to get an HTTP endpoint. For other setups, rosetta supports the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) via `--http`:
|
|
244
285
|
|
|
245
286
|
```sh
|
|
246
287
|
rosetta --http # http://localhost:8080/mcp
|
|
@@ -254,155 +295,47 @@ Then point your MCP client at the URL:
|
|
|
254
295
|
{ "url": "http://localhost:8080/mcp" }
|
|
255
296
|
```
|
|
256
297
|
|
|
257
|
-
**
|
|
258
|
-
|
|
259
|
-
- **
|
|
260
|
-
- **
|
|
261
|
-
- **TLS built-in** — for direct HTTPS without a proxy: `--tls-cert cert.pem --tls-key key.pem` (or `TLS_CERT_PATH` + `TLS_KEY_PATH` env vars)
|
|
262
|
-
- **Defaults to localhost** — binding to all interfaces (`--host 0.0.0.0`) requires an explicit flag and logs a warning.
|
|
263
|
-
- **Origin validation** — rejects cross-origin requests to prevent DNS rebinding attacks.
|
|
264
|
-
- **Stdio remains default** — `--http` is opt-in. Existing stdio configs are unaffected.
|
|
265
|
-
|
|
266
|
-
The `PORT`, `HOST`, `TLS_CERT_PATH`, and `TLS_KEY_PATH` environment variables are supported (lower precedence than CLI flags).
|
|
298
|
+
- **Read-only** — queries a local SQLite database, stores nothing.
|
|
299
|
+
- **No authentication** — designed for local/trusted-network use. Use a reverse proxy for public exposure.
|
|
300
|
+
- **TLS built-in** — `--tls-cert cert.pem --tls-key key.pem` for direct HTTPS without a proxy.
|
|
301
|
+
- **Defaults to localhost** — LAN binding (`--host 0.0.0.0`) requires an explicit flag.
|
|
267
302
|
|
|
268
303
|
## Container Images
|
|
269
304
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- Docker Hub: `ammo74/rosetta`
|
|
273
|
-
- GHCR: `ghcr.io/tikoci/rosetta`
|
|
274
|
-
|
|
275
|
-
Tags per release:
|
|
276
|
-
|
|
277
|
-
- `${version}` (example: `v0.2.1`)
|
|
278
|
-
- `latest`
|
|
279
|
-
- `sha-<12-char-commit>`
|
|
305
|
+
Multi-arch OCI images (linux/amd64 + linux/arm64) are published with each release:
|
|
280
306
|
|
|
281
|
-
Container
|
|
282
|
-
|
|
283
|
-
- Starts in HTTP mode (`--http`) on `0.0.0.0`
|
|
284
|
-
- Uses `PORT` if set, otherwise 8080
|
|
285
|
-
- Uses HTTPS only when both `TLS_CERT_PATH` and `TLS_KEY_PATH` are set
|
|
286
|
-
|
|
287
|
-
Examples:
|
|
307
|
+
- `ghcr.io/tikoci/rosetta` (GitHub Container Registry)
|
|
308
|
+
- `ammo74/rosetta` (Docker Hub)
|
|
288
309
|
|
|
289
310
|
```sh
|
|
290
311
|
docker run --rm -p 8080:8080 ghcr.io/tikoci/rosetta:latest
|
|
291
312
|
```
|
|
292
313
|
|
|
293
|
-
|
|
294
|
-
docker run --rm -p 8443:8443 \
|
|
295
|
-
-e PORT=8443 \
|
|
296
|
-
-e TLS_CERT_PATH=/certs/cert.pem \
|
|
297
|
-
-e TLS_KEY_PATH=/certs/key.pem \
|
|
298
|
-
-v "$PWD/certs:/certs:ro" \
|
|
299
|
-
ghcr.io/tikoci/rosetta:latest
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
## Building from Source
|
|
303
|
-
|
|
304
|
-
For contributors or when you have access to the MikroTik HTML documentation export.
|
|
305
|
-
|
|
306
|
-
### Prerequisites
|
|
307
|
-
|
|
308
|
-
- [Bun](https://bun.sh/) v1.1+
|
|
309
|
-
- RouterOS HTML documentation export (Confluence space export)
|
|
310
|
-
- Internet access to [tikoci/restraml GitHub Pages](https://tikoci.github.io/restraml/) for command-tree extraction
|
|
311
|
-
|
|
312
|
-
`make extract` and `make extract-full` fetch `inspect.json` from restraml GitHub Pages by default. You can still pass a local source explicitly:
|
|
313
|
-
|
|
314
|
-
```sh
|
|
315
|
-
bun run src/extract-commands.ts /path/to/restraml/docs/7.22.1/extra/inspect.json
|
|
316
|
-
bun run src/extract-all-versions.ts /path/to/restraml/docs
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
### Build
|
|
320
|
-
|
|
321
|
-
```sh
|
|
322
|
-
git clone https://github.com/tikoci/rosetta.git
|
|
323
|
-
cd rosetta
|
|
324
|
-
bun install
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
Place the Confluence HTML export in `box/documents-export-<date>/ROS/` and symlink `box/latest` to it:
|
|
328
|
-
|
|
329
|
-
```sh
|
|
330
|
-
ln -s documents-export-<date> box/latest
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
Then:
|
|
334
|
-
|
|
335
|
-
```sh
|
|
336
|
-
make extract # HTML → properties → commands (single version) → link
|
|
337
|
-
# or
|
|
338
|
-
make extract-full # Same but with all 46 RouterOS versions
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### Development
|
|
342
|
-
|
|
343
|
-
```sh
|
|
344
|
-
bun test # Run tests (in-memory SQLite, no DB needed)
|
|
345
|
-
bun run typecheck # Type check
|
|
346
|
-
make lint # Biome linter
|
|
347
|
-
bun run src/mcp.ts # Start MCP server in dev mode
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
The repo includes `.vscode/mcp.json` — opening the folder in VS Code automatically configures Copilot to use the dev server.
|
|
351
|
-
|
|
352
|
-
### Creating a Release
|
|
353
|
-
|
|
354
|
-
The Makefile handles the full release flow — preflight checks, cross-compile, git tag, push, and GitHub Release upload:
|
|
355
|
-
|
|
356
|
-
```sh
|
|
357
|
-
make release VERSION=v0.1.0
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
This cross-compiles to macOS (arm64 + x64), Windows (x64), and Linux (x64), creates ZIP archives, compresses the database, tags the commit, and creates a GitHub Release with all artifacts.
|
|
361
|
-
|
|
362
|
-
The release workflow also publishes OCI images to Docker Hub (`ammo74/rosetta`) and GHCR (`ghcr.io/tikoci/rosetta`) using crane (no Docker daemon required in CI).
|
|
363
|
-
|
|
364
|
-
## Project Structure
|
|
365
|
-
|
|
366
|
-
```text
|
|
367
|
-
src/
|
|
368
|
-
├── mcp.ts # MCP server (11 tools, stdio + HTTP) + CLI dispatch
|
|
369
|
-
├── setup.ts # --setup: DB download + MCP client config
|
|
370
|
-
├── query.ts # NL → FTS5 query planner, BM25 ranking
|
|
371
|
-
├── db.ts # SQLite schema, WAL mode, FTS5 triggers
|
|
372
|
-
├── extract-html.ts # Confluence HTML → pages + callouts
|
|
373
|
-
├── extract-properties.ts # Property table extraction
|
|
374
|
-
├── extract-commands.ts # inspect.json → commands (version-aware)
|
|
375
|
-
├── extract-all-versions.ts # Batch extract all 46 versions
|
|
376
|
-
├── extract-devices.ts # Product matrix CSV → devices table
|
|
377
|
-
├── link-commands.ts # Command ↔ page mapping
|
|
378
|
-
└── query.test.ts # Tests (in-memory SQLite fixtures)
|
|
379
|
-
|
|
380
|
-
scripts/
|
|
381
|
-
└── build-release.ts # Cross-compile + package releases
|
|
382
|
-
└── container-entrypoint.sh # OCI image runtime entrypoint (HTTP default)
|
|
383
|
-
```
|
|
314
|
+
These are the same images used by the [MikroTik /app install](#install-on-mikrotik-app). Tags: `latest`, version (e.g., `v0.2.1`), and `sha-<commit>`.
|
|
384
315
|
|
|
385
316
|
## Data Sources
|
|
386
317
|
|
|
387
|
-
The database combines
|
|
318
|
+
The database combines multiple MikroTik data sources into a single SQLite file with [FTS5](https://www.sqlite.org/fts5.html) full-text search, [porter stemming](https://www.sqlite.org/fts5.html#porter_tokenizer), and [BM25 ranking](https://www.sqlite.org/fts5.html#the_bm25_function):
|
|
388
319
|
|
|
389
|
-
- **HTML Documentation** — Confluence space export from help.mikrotik.com (March 2026).
|
|
320
|
+
- **HTML Documentation** — Confluence space export from help.mikrotik.com (March 2026). 317 pages broken into sections, callouts, and property tables (~515K words) with links back to help.mikrotik.com.
|
|
390
321
|
|
|
391
|
-
- **Command Tree** — `inspect.json`
|
|
322
|
+
- **Command Tree** — `inspect.json` from [tikoci/restraml](https://github.com/tikoci/restraml), generated by running `/console/inspect` against RouterOS CHR under QEMU for every version since 7.9 (46 versions tracked: 7.9–7.23beta2).
|
|
392
323
|
|
|
393
|
-
- **Product Matrix** — CSV export from mikrotik.com/products/matrix (144 products, 34 columns). Hardware specs, license levels, and pricing
|
|
324
|
+
- **Product Matrix** — CSV export from mikrotik.com/products/matrix (144 products, 34 columns). Hardware specs, license levels, and pricing.
|
|
325
|
+
|
|
326
|
+
- **Device Benchmarks** — Ethernet bridging/routing and IPSec throughput test results scraped from individual product pages on mikrotik.com (2,874 measurements across 125 devices; 64/512/1518-byte packets, multiple configurations). Also captures block diagram image URLs for 110 devices.
|
|
394
327
|
|
|
395
328
|
Documentation covers RouterOS **v7 only** and aligns with the long-term release (~7.22) at export time. v6 had different syntax and major subsystems — answers for v6 are unreliable.
|
|
396
329
|
|
|
397
330
|
## Database (Standalone)
|
|
398
331
|
|
|
399
|
-
The SQLite database is
|
|
332
|
+
The SQLite database is downloadable on its own from [GitHub Releases](https://github.com/tikoci/rosetta/releases):
|
|
400
333
|
|
|
401
334
|
```text
|
|
402
335
|
https://github.com/tikoci/rosetta/releases/latest/download/ros-help.db.gz
|
|
403
336
|
```
|
|
404
337
|
|
|
405
|
-
|
|
338
|
+
Use it with any SQLite client:
|
|
406
339
|
|
|
407
340
|
```sh
|
|
408
341
|
sqlite3 ros-help.db "SELECT title, url FROM pages_fts WHERE pages_fts MATCH 'DHCP lease' ORDER BY rank LIMIT 5;"
|
|
@@ -420,8 +353,13 @@ sqlite3 ros-help.db "SELECT title, url FROM pages_fts WHERE pages_fts MATCH 'DHC
|
|
|
420
353
|
| `command_versions` | 1.67M | Junction table: which command paths exist in which RouterOS versions (7.9–7.23beta2) |
|
|
421
354
|
| `ros_versions` | 46 | Tracked RouterOS versions with channel (stable/development) |
|
|
422
355
|
| `devices` | 144 | MikroTik hardware — CPU, RAM, storage, ports, PoE, wireless, license level, MSRP |
|
|
356
|
+
| `device_test_results` | 2,874 | Ethernet and IPSec throughput benchmarks for 125 devices — packet sizes, modes, Mbps/Kpps |
|
|
357
|
+
|
|
358
|
+
Each content table has a corresponding FTS5 index (e.g., `pages_fts`, `properties_fts`, `devices_fts`).
|
|
359
|
+
|
|
360
|
+
## Contributing
|
|
423
361
|
|
|
424
|
-
|
|
362
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for building from source, running tests, development setup, and the release process.
|
|
425
363
|
|
|
426
364
|
## License
|
|
427
365
|
|
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -250,9 +250,20 @@ export function initDb() {
|
|
|
250
250
|
sim_slots INTEGER,
|
|
251
251
|
memory_cards TEXT,
|
|
252
252
|
usb_type TEXT,
|
|
253
|
-
msrp_usd REAL
|
|
253
|
+
msrp_usd REAL,
|
|
254
|
+
product_url TEXT,
|
|
255
|
+
block_diagram_url TEXT
|
|
254
256
|
);`);
|
|
255
257
|
|
|
258
|
+
// Migration: add product_url and block_diagram_url columns if missing
|
|
259
|
+
const devCols = db.prepare("PRAGMA table_info(devices)").all() as Array<{ name: string }>;
|
|
260
|
+
if (!devCols.some((c) => c.name === "product_url")) {
|
|
261
|
+
db.run("ALTER TABLE devices ADD COLUMN product_url TEXT;");
|
|
262
|
+
}
|
|
263
|
+
if (!devCols.some((c) => c.name === "block_diagram_url")) {
|
|
264
|
+
db.run("ALTER TABLE devices ADD COLUMN block_diagram_url TEXT;");
|
|
265
|
+
}
|
|
266
|
+
|
|
256
267
|
db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS devices_fts USING fts5(
|
|
257
268
|
product_name, product_code, architecture, cpu,
|
|
258
269
|
content=devices,
|
|
@@ -275,6 +286,23 @@ export function initDb() {
|
|
|
275
286
|
VALUES (new.id, new.product_name, new.product_code, new.architecture, new.cpu);
|
|
276
287
|
END;`);
|
|
277
288
|
|
|
289
|
+
// -- Device test results (from mikrotik.com product pages) --
|
|
290
|
+
|
|
291
|
+
db.run(`CREATE TABLE IF NOT EXISTS device_test_results (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
device_id INTEGER NOT NULL REFERENCES devices(id),
|
|
294
|
+
test_type TEXT NOT NULL,
|
|
295
|
+
mode TEXT NOT NULL,
|
|
296
|
+
configuration TEXT NOT NULL,
|
|
297
|
+
packet_size INTEGER NOT NULL,
|
|
298
|
+
throughput_kpps REAL,
|
|
299
|
+
throughput_mbps REAL,
|
|
300
|
+
UNIQUE(device_id, test_type, mode, configuration, packet_size)
|
|
301
|
+
);`);
|
|
302
|
+
|
|
303
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_device_tests_device ON device_test_results(device_id);`);
|
|
304
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_device_tests_type ON device_test_results(test_type);`);
|
|
305
|
+
|
|
278
306
|
// -- Changelogs (parsed per-entry from MikroTik download server) --
|
|
279
307
|
|
|
280
308
|
db.run(`CREATE TABLE IF NOT EXISTS changelogs (
|
|
@@ -330,6 +358,8 @@ export function getDbStats() {
|
|
|
330
358
|
commands: count("SELECT COUNT(*) AS c FROM commands"),
|
|
331
359
|
commands_linked: count("SELECT COUNT(*) AS c FROM commands WHERE page_id IS NOT NULL"),
|
|
332
360
|
devices: count("SELECT COUNT(*) AS c FROM devices"),
|
|
361
|
+
device_test_results: count("SELECT COUNT(*) AS c FROM device_test_results"),
|
|
362
|
+
devices_with_tests: count("SELECT COUNT(DISTINCT device_id) AS c FROM device_test_results"),
|
|
333
363
|
changelogs: count("SELECT COUNT(*) AS c FROM changelogs"),
|
|
334
364
|
changelog_versions: count("SELECT COUNT(DISTINCT version) AS c FROM changelogs"),
|
|
335
365
|
ros_versions: count("SELECT COUNT(*) AS c FROM ros_versions"),
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract-test-results.ts — Scrape MikroTik product pages for test results + block diagram URLs.
|
|
3
|
+
*
|
|
4
|
+
* Fetches each product page from mikrotik.com and extracts:
|
|
5
|
+
* - Ethernet test results (bridging/routing throughput at various packet sizes)
|
|
6
|
+
* - IPSec test results (tunnel throughput with various ciphers)
|
|
7
|
+
* - Block diagram PNG URL
|
|
8
|
+
* - Product page URL slug
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: deletes all existing test results, updates device rows.
|
|
11
|
+
* Requires devices table to be populated first (via extract-devices.ts).
|
|
12
|
+
*
|
|
13
|
+
* Usage: bun run src/extract-test-results.ts [--concurrency N] [--delay MS]
|
|
14
|
+
*
|
|
15
|
+
* Product page URL slug discovery: fetches the product matrix page to build
|
|
16
|
+
* a name→slug mapping, then fetches each product page by slug.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseHTML } from "linkedom";
|
|
20
|
+
import { db, initDb } from "./db.ts";
|
|
21
|
+
|
|
22
|
+
// ── CLI flags ──
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
function getFlag(name: string, fallback: number): number {
|
|
26
|
+
const idx = args.indexOf(`--${name}`);
|
|
27
|
+
if (idx !== -1 && args[idx + 1]) return Number(args[idx + 1]);
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CONCURRENCY = getFlag("concurrency", 4);
|
|
32
|
+
const DELAY_MS = getFlag("delay", 500);
|
|
33
|
+
const PRODUCT_BASE = "https://mikrotik.com/product/";
|
|
34
|
+
|
|
35
|
+
// ── Types ──
|
|
36
|
+
|
|
37
|
+
interface TestResultRow {
|
|
38
|
+
mode: string;
|
|
39
|
+
configuration: string;
|
|
40
|
+
packet_size: number;
|
|
41
|
+
throughput_kpps: number | null;
|
|
42
|
+
throughput_mbps: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProductPageData {
|
|
46
|
+
slug: string;
|
|
47
|
+
ethernet_results: TestResultRow[];
|
|
48
|
+
ipsec_results: TestResultRow[];
|
|
49
|
+
block_diagram_url: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── HTML Parsing ──
|
|
53
|
+
|
|
54
|
+
/** Decode HTML entities like none to text. */
|
|
55
|
+
function decodeEntities(html: string): string {
|
|
56
|
+
const { document } = parseHTML("<div></div>");
|
|
57
|
+
const el = document.createElement("div");
|
|
58
|
+
el.innerHTML = html;
|
|
59
|
+
return el.textContent || "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parse a performance-table element into test result rows. */
|
|
63
|
+
function parsePerformanceTable(table: Element): { testType: string; rows: TestResultRow[] } {
|
|
64
|
+
const rows: TestResultRow[] = [];
|
|
65
|
+
|
|
66
|
+
// Header row: first <tr> in <thead> has [product_code, test_description]
|
|
67
|
+
const thead = table.querySelector("thead");
|
|
68
|
+
if (!thead) return { testType: "unknown", rows };
|
|
69
|
+
|
|
70
|
+
const headerRows = thead.querySelectorAll("tr");
|
|
71
|
+
if (headerRows.length < 2) return { testType: "unknown", rows };
|
|
72
|
+
|
|
73
|
+
// Determine test type from header description
|
|
74
|
+
const headerCells = headerRows[0].querySelectorAll("td");
|
|
75
|
+
const testDesc = headerCells.length >= 2 ? (headerCells[1].textContent || "").trim().toLowerCase() : "";
|
|
76
|
+
const testType = testDesc.includes("ipsec") ? "ipsec" : "ethernet";
|
|
77
|
+
|
|
78
|
+
// Determine packet sizes from the second header row
|
|
79
|
+
// Structure: [Mode, Configuration, (1518|1400) byte, 512 byte, 64 byte]
|
|
80
|
+
// The colspan=2 means each size has kpps + Mbps columns
|
|
81
|
+
const sizeRow = headerRows[1];
|
|
82
|
+
const sizeCells = sizeRow.querySelectorAll("td");
|
|
83
|
+
const packetSizes: number[] = [];
|
|
84
|
+
for (const cell of sizeCells) {
|
|
85
|
+
const text = (cell.textContent || "").trim();
|
|
86
|
+
const match = text.match(/^(\d+)\s*byte/i);
|
|
87
|
+
if (match) packetSizes.push(Number.parseInt(match[1], 10));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// If we couldn't find sizes in the header, use defaults
|
|
91
|
+
if (packetSizes.length === 0) {
|
|
92
|
+
if (testType === "ipsec") {
|
|
93
|
+
packetSizes.push(1400, 512, 64);
|
|
94
|
+
} else {
|
|
95
|
+
packetSizes.push(1518, 512, 64);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Parse data rows from <tbody>
|
|
100
|
+
const tbody = table.querySelector("tbody");
|
|
101
|
+
if (!tbody) return { testType, rows };
|
|
102
|
+
|
|
103
|
+
for (const tr of tbody.querySelectorAll("tr")) {
|
|
104
|
+
const cells = tr.querySelectorAll("td");
|
|
105
|
+
if (cells.length < 2) continue;
|
|
106
|
+
|
|
107
|
+
const mode = (cells[0].textContent || "").trim();
|
|
108
|
+
const config = (cells[1].textContent || "").trim();
|
|
109
|
+
|
|
110
|
+
// Each packet size has 2 columns: kpps, Mbps
|
|
111
|
+
for (let i = 0; i < packetSizes.length; i++) {
|
|
112
|
+
const kppsIdx = 2 + i * 2;
|
|
113
|
+
const mbpsIdx = 3 + i * 2;
|
|
114
|
+
if (kppsIdx >= cells.length) break;
|
|
115
|
+
|
|
116
|
+
const kpps = Number.parseFloat((cells[kppsIdx].textContent || "").trim());
|
|
117
|
+
const mbps = mbpsIdx < cells.length
|
|
118
|
+
? Number.parseFloat((cells[mbpsIdx].textContent || "").trim())
|
|
119
|
+
: null;
|
|
120
|
+
|
|
121
|
+
rows.push({
|
|
122
|
+
mode,
|
|
123
|
+
configuration: config,
|
|
124
|
+
packet_size: packetSizes[i],
|
|
125
|
+
throughput_kpps: Number.isNaN(kpps) ? null : kpps,
|
|
126
|
+
throughput_mbps: mbps !== null && Number.isNaN(mbps) ? null : mbps,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { testType, rows };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Generate candidate URL slugs for a product.
|
|
135
|
+
* MikroTik slugs are wildly inconsistent — some use lowercased names with underscores,
|
|
136
|
+
* some use product codes with original casing, and + is sometimes "plus", sometimes dropped.
|
|
137
|
+
* Unicode superscripts (², ³) are transliterated to digits.
|
|
138
|
+
* We try multiple variants and use the first that returns 200. */
|
|
139
|
+
function generateSlugs(name: string, code: string | null): string[] {
|
|
140
|
+
const slugs: string[] = [];
|
|
141
|
+
const seen = new Set<string>();
|
|
142
|
+
const add = (s: string) => {
|
|
143
|
+
if (s && !seen.has(s)) {
|
|
144
|
+
seen.add(s);
|
|
145
|
+
slugs.push(s);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Normalize Unicode superscripts to regular digits
|
|
150
|
+
const norm = (s: string) =>
|
|
151
|
+
s.replace(/²/g, "2").replace(/³/g, "3").replace(/¹/g, "1");
|
|
152
|
+
|
|
153
|
+
const cleanName = norm(name);
|
|
154
|
+
|
|
155
|
+
// 1. Lowercased name: + → plus, non-alphanum → _
|
|
156
|
+
add(cleanName.toLowerCase().replace(/\+/g, "plus").replace(/[^a-z0-9plus]+/g, "_").replace(/^_|_$/g, ""));
|
|
157
|
+
|
|
158
|
+
// 2. Lowercased name: drop + entirely
|
|
159
|
+
add(cleanName.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""));
|
|
160
|
+
|
|
161
|
+
if (code) {
|
|
162
|
+
const cleanCode = norm(code);
|
|
163
|
+
|
|
164
|
+
// 3. Product code as-is (original casing, + → plus, strip other specials)
|
|
165
|
+
add(cleanCode.replace(/\+/g, "plus").replace(/[^a-zA-Z0-9plus\-]+/g, "").replace(/^-|-$/g, ""));
|
|
166
|
+
|
|
167
|
+
// 4. Product code as-is (original casing)
|
|
168
|
+
add(cleanCode.replace(/[^a-zA-Z0-9\-]+/g, "").replace(/^-|-$/g, ""));
|
|
169
|
+
|
|
170
|
+
// 5. Lowercased code: + → plus
|
|
171
|
+
add(cleanCode.toLowerCase().replace(/\+/g, "plus").replace(/[^a-z0-9plus]+/g, "_").replace(/^_|_$/g, ""));
|
|
172
|
+
|
|
173
|
+
// 6. Lowercased code: drop +
|
|
174
|
+
add(cleanCode.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return slugs;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Fetch and parse a single product page, trying multiple slug candidates. */
|
|
181
|
+
async function fetchProductPage(slugs: string[]): Promise<ProductPageData | null> {
|
|
182
|
+
for (const slug of slugs) {
|
|
183
|
+
const url = `${PRODUCT_BASE}${slug}`;
|
|
184
|
+
try {
|
|
185
|
+
const resp = await fetch(url);
|
|
186
|
+
if (resp.ok) {
|
|
187
|
+
const html = await resp.text();
|
|
188
|
+
return parseProductHtml(html, slug);
|
|
189
|
+
}
|
|
190
|
+
// Don't warn for intermediary attempts — only the last slug matters
|
|
191
|
+
} catch {
|
|
192
|
+
// network error, try next slug
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
console.warn(` [404] ${slugs[0]} (tried ${slugs.length} variants)`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Parse product page HTML into structured data. */
|
|
200
|
+
function parseProductHtml(html: string, slug: string): ProductPageData | null {
|
|
201
|
+
|
|
202
|
+
const { document } = parseHTML(html);
|
|
203
|
+
|
|
204
|
+
// Parse performance tables
|
|
205
|
+
const tables = document.querySelectorAll("table.performance-table");
|
|
206
|
+
const ethernet_results: TestResultRow[] = [];
|
|
207
|
+
const ipsec_results: TestResultRow[] = [];
|
|
208
|
+
|
|
209
|
+
for (const table of tables) {
|
|
210
|
+
const { testType, rows } = parsePerformanceTable(table);
|
|
211
|
+
if (testType === "ipsec") {
|
|
212
|
+
ipsec_results.push(...rows);
|
|
213
|
+
} else {
|
|
214
|
+
ethernet_results.push(...rows);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Find block diagram URL
|
|
219
|
+
let block_diagram_url: string | null = null;
|
|
220
|
+
const links = document.querySelectorAll("a");
|
|
221
|
+
for (const a of links) {
|
|
222
|
+
const text = (a.textContent || "").trim();
|
|
223
|
+
if (text === "Block Diagram") {
|
|
224
|
+
const href = a.getAttribute("href");
|
|
225
|
+
if (href) {
|
|
226
|
+
block_diagram_url = href.startsWith("http")
|
|
227
|
+
? href
|
|
228
|
+
: `https://cdn.mikrotik.com${href}`;
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { slug, ethernet_results, ipsec_results, block_diagram_url };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Sleep helper for rate limiting. */
|
|
238
|
+
function sleep(ms: number): Promise<void> {
|
|
239
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Main ──
|
|
243
|
+
|
|
244
|
+
initDb();
|
|
245
|
+
|
|
246
|
+
// Get all devices from DB
|
|
247
|
+
const devices = db.prepare("SELECT id, product_name, product_code FROM devices ORDER BY product_name").all() as Array<{
|
|
248
|
+
id: number;
|
|
249
|
+
product_name: string;
|
|
250
|
+
product_code: string | null;
|
|
251
|
+
}>;
|
|
252
|
+
|
|
253
|
+
if (devices.length === 0) {
|
|
254
|
+
console.error("No devices in database. Run extract-devices.ts first.");
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(`Found ${devices.length} devices in database`);
|
|
259
|
+
|
|
260
|
+
// Build device → candidate slugs mapping
|
|
261
|
+
const deviceSlugs: Array<{ id: number; name: string; slugs: string[] }> = [];
|
|
262
|
+
for (const dev of devices) {
|
|
263
|
+
const slugs = generateSlugs(dev.product_name, dev.product_code);
|
|
264
|
+
deviceSlugs.push({ id: dev.id, name: dev.product_name, slugs });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Idempotent: clear existing test results
|
|
268
|
+
db.run("DELETE FROM device_test_results");
|
|
269
|
+
|
|
270
|
+
// Prepare statements
|
|
271
|
+
const insertTest = db.prepare(`INSERT OR IGNORE INTO device_test_results (
|
|
272
|
+
device_id, test_type, mode, configuration, packet_size,
|
|
273
|
+
throughput_kpps, throughput_mbps
|
|
274
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
275
|
+
|
|
276
|
+
const updateDevice = db.prepare(`UPDATE devices
|
|
277
|
+
SET product_url = ?, block_diagram_url = ?
|
|
278
|
+
WHERE id = ?`);
|
|
279
|
+
|
|
280
|
+
console.log(`Fetching ${deviceSlugs.length} product pages (concurrency=${CONCURRENCY}, delay=${DELAY_MS}ms)...`);
|
|
281
|
+
|
|
282
|
+
let totalTests = 0;
|
|
283
|
+
let devicesWithTests = 0;
|
|
284
|
+
let devicesWithDiagrams = 0;
|
|
285
|
+
let fetchErrors = 0;
|
|
286
|
+
|
|
287
|
+
const insertAll = db.transaction(
|
|
288
|
+
(results: Array<{ deviceId: number; data: ProductPageData | null }>) => {
|
|
289
|
+
for (const { deviceId, data } of results) {
|
|
290
|
+
if (!data) {
|
|
291
|
+
fetchErrors++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Update device with URL and block diagram
|
|
296
|
+
updateDevice.run(
|
|
297
|
+
`https://mikrotik.com/product/${data.slug}`,
|
|
298
|
+
data.block_diagram_url,
|
|
299
|
+
deviceId,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (data.block_diagram_url) devicesWithDiagrams++;
|
|
303
|
+
|
|
304
|
+
// Insert test results
|
|
305
|
+
const allResults = [
|
|
306
|
+
...data.ethernet_results.map((r) => ({ ...r, test_type: "ethernet" as const })),
|
|
307
|
+
...data.ipsec_results.map((r) => ({ ...r, test_type: "ipsec" as const })),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
if (allResults.length > 0) devicesWithTests++;
|
|
311
|
+
|
|
312
|
+
for (const r of allResults) {
|
|
313
|
+
insertTest.run(
|
|
314
|
+
deviceId,
|
|
315
|
+
r.test_type,
|
|
316
|
+
r.mode,
|
|
317
|
+
r.configuration,
|
|
318
|
+
r.packet_size,
|
|
319
|
+
r.throughput_kpps,
|
|
320
|
+
r.throughput_mbps,
|
|
321
|
+
);
|
|
322
|
+
totalTests++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Fetch all products with rate limiting
|
|
329
|
+
const allResults: Array<{ deviceId: number; data: ProductPageData | null }> = [];
|
|
330
|
+
let processed = 0;
|
|
331
|
+
|
|
332
|
+
for (let i = 0; i < deviceSlugs.length; i += CONCURRENCY) {
|
|
333
|
+
const batch = deviceSlugs.slice(i, i + CONCURRENCY);
|
|
334
|
+
const batchResults = await Promise.all(
|
|
335
|
+
batch.map(async (dev) => {
|
|
336
|
+
const data = await fetchProductPage(dev.slugs);
|
|
337
|
+
return { deviceId: dev.id, data };
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
allResults.push(...batchResults);
|
|
341
|
+
processed += batch.length;
|
|
342
|
+
|
|
343
|
+
const pct = Math.round((processed / deviceSlugs.length) * 100);
|
|
344
|
+
process.stdout.write(`\r ${processed}/${deviceSlugs.length} (${pct}%)`);
|
|
345
|
+
|
|
346
|
+
if (i + CONCURRENCY < deviceSlugs.length) {
|
|
347
|
+
await sleep(DELAY_MS);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
console.log(""); // newline after progress
|
|
351
|
+
|
|
352
|
+
// Insert all results in one transaction
|
|
353
|
+
insertAll(allResults);
|
|
354
|
+
|
|
355
|
+
console.log(`Test results: ${totalTests} rows for ${devicesWithTests} devices`);
|
|
356
|
+
console.log(`Block diagrams: ${devicesWithDiagrams} devices`);
|
|
357
|
+
if (fetchErrors > 0) {
|
|
358
|
+
console.warn(`Fetch errors: ${fetchErrors} products`);
|
|
359
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -38,6 +38,12 @@ function getArg(name: string): string | undefined {
|
|
|
38
38
|
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Format a clickable terminal hyperlink using OSC 8 escape sequences.
|
|
42
|
+
* Falls back to plain URL in terminals that don't support OSC 8. */
|
|
43
|
+
function link(url: string, display?: string): string {
|
|
44
|
+
return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
if (args.includes("--version") || args.includes("-v")) {
|
|
42
48
|
console.log(`rosetta ${RESOLVED_VERSION}`);
|
|
43
49
|
process.exit(0);
|
|
@@ -64,8 +70,10 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
64
70
|
console.log(" DB_PATH Absolute path to ros-help.db (optional)");
|
|
65
71
|
console.log(" PORT HTTP listen port (lower precedence than --port)");
|
|
66
72
|
console.log(" HOST HTTP bind address (lower precedence than --host)");
|
|
67
|
-
console.log(
|
|
68
|
-
console.log(
|
|
73
|
+
console.log();
|
|
74
|
+
console.log(`Quick start: bunx @tikoci/rosetta --setup`);
|
|
75
|
+
console.log(`Project: ${link("https://github.com/tikoci/rosetta")}`);
|
|
76
|
+
console.log(`Docs: ${link("https://help.mikrotik.com/docs/spaces/ROS/overview", "help.mikrotik.com")}`);
|
|
69
77
|
process.exit(0);
|
|
70
78
|
}
|
|
71
79
|
|
|
@@ -630,16 +638,26 @@ Examples:
|
|
|
630
638
|
server.registerTool(
|
|
631
639
|
"routeros_device_lookup",
|
|
632
640
|
{
|
|
633
|
-
description: `Look up MikroTik hardware specs or search for devices matching criteria.
|
|
641
|
+
description: `Look up MikroTik hardware specs, performance benchmarks, or search for devices matching criteria.
|
|
634
642
|
|
|
635
|
-
144 products from mikrotik.com
|
|
636
|
-
|
|
643
|
+
144 products from mikrotik.com (March 2026). Returns hardware specs, official test results,
|
|
644
|
+
block diagram URLs, and pricing.
|
|
637
645
|
|
|
638
646
|
**How it works:**
|
|
639
|
-
- If query matches a product name or code exactly → returns full specs
|
|
640
|
-
- Otherwise → FTS search + optional structured filters → returns matching devices
|
|
647
|
+
- If query matches a product name or code exactly → returns full specs + test results + block diagram
|
|
648
|
+
- Otherwise → FTS search + optional structured filters → returns matching devices (compact)
|
|
641
649
|
- Filters can be used alone (no query) to find devices by capability
|
|
642
650
|
|
|
651
|
+
**Test results** (from mikrotik.com per-product pages):
|
|
652
|
+
- Ethernet: bridging/routing throughput at 64/512/1518 byte packets (kpps + Mbps)
|
|
653
|
+
- IPSec: tunnel throughput with various AES/SHA configurations
|
|
654
|
+
- Key metric: "Routing 25 ip filter rules @ 512 byte" is a common routing performance gauge
|
|
655
|
+
- Devices with L3HW offload show additional hardware-accelerated routing rows
|
|
656
|
+
- Included automatically for exact/single-device lookups — no extra call needed
|
|
657
|
+
|
|
658
|
+
**Block diagram**: internal switch/CPU/PHY architecture diagram URL (PNG).
|
|
659
|
+
Shows bus topology and per-port bandwidth limits — useful for understanding SoC bottlenecks.
|
|
660
|
+
|
|
643
661
|
**License levels** determine feature availability:
|
|
644
662
|
- L3: CPE/home (no routing protocols, limited queues)
|
|
645
663
|
- L4: standard (OSPF, BGP, all firewall features)
|
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}
|
package/src/release.test.ts
CHANGED
|
@@ -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");
|