@tikoci/rosetta 0.2.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/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/rosetta.js +34 -0
- package/matrix/2026-03-25/matrix.csv +145 -0
- package/matrix/CLAUDE.md +7 -0
- package/matrix/get-mikrotik-products-csv.sh +20 -0
- package/package.json +34 -0
- package/src/assess-html.ts +267 -0
- package/src/db.ts +360 -0
- package/src/extract-all-versions.ts +147 -0
- package/src/extract-changelogs.ts +266 -0
- package/src/extract-commands.ts +175 -0
- package/src/extract-devices.ts +194 -0
- package/src/extract-html.ts +379 -0
- package/src/extract-properties.ts +234 -0
- package/src/link-commands.ts +208 -0
- package/src/mcp.ts +725 -0
- package/src/query.test.ts +994 -0
- package/src/query.ts +990 -0
- package/src/release.test.ts +280 -0
- package/src/restraml.ts +65 -0
- package/src/search.ts +49 -0
- package/src/setup.ts +224 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* extract-all-versions.ts — Extract command trees from all RouterOS versions.
|
|
4
|
+
*
|
|
5
|
+
* Discovers RouterOS versions from restraml and extracts each inspect.json
|
|
6
|
+
* (preferring extra/ variant) into command_versions.
|
|
7
|
+
*
|
|
8
|
+
* The latest stable version is loaded as the primary commands table.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bun run src/extract-all-versions.ts [restraml-base-url-or-local-docs-dir]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { discoverRemoteVersions as discoverRemoteVersionList, isHttpUrl, RESTRAML_PAGES_URL } from "./restraml.ts";
|
|
17
|
+
|
|
18
|
+
const SOURCE = process.argv[2];
|
|
19
|
+
|
|
20
|
+
interface VersionInfo {
|
|
21
|
+
version: string;
|
|
22
|
+
channel: "stable" | "development";
|
|
23
|
+
inspectPath: string;
|
|
24
|
+
hasExtra: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyChannel(version: string): "stable" | "development" {
|
|
28
|
+
if (version.includes("beta") || version.includes("rc")) return "development";
|
|
29
|
+
return "stable";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseVersionKey(version: string): number[] {
|
|
33
|
+
// "7.22beta1" → [7, 22, 0, -2, 1] (beta=-2, rc=-1, release=0)
|
|
34
|
+
const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:(beta|rc)(\d+))?$/);
|
|
35
|
+
if (!match) return [0];
|
|
36
|
+
const major = Number(match[1]);
|
|
37
|
+
const minor = Number(match[2]);
|
|
38
|
+
const patch = Number(match[3] ?? 0);
|
|
39
|
+
const preType = match[4] === "beta" ? -2 : match[4] === "rc" ? -1 : 0;
|
|
40
|
+
const preNum = Number(match[5] ?? 0);
|
|
41
|
+
return [major, minor, patch, preType, preNum];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compareVersions(a: string, b: string): number {
|
|
45
|
+
const ka = parseVersionKey(a);
|
|
46
|
+
const kb = parseVersionKey(b);
|
|
47
|
+
for (let i = 0; i < Math.max(ka.length, kb.length); i++) {
|
|
48
|
+
const diff = (ka[i] ?? 0) - (kb[i] ?? 0);
|
|
49
|
+
if (diff !== 0) return diff;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function discoverRemoteVersions(): Promise<VersionInfo[]> {
|
|
55
|
+
const versionNames = await discoverRemoteVersionList();
|
|
56
|
+
const baseUrl = RESTRAML_PAGES_URL;
|
|
57
|
+
|
|
58
|
+
// restraml publishes extra/ for every version that has extra-packages,
|
|
59
|
+
// and the GitHub API listing already confirmed these dirs exist.
|
|
60
|
+
// Assume extra/inspect.json is available (restraml always generates it for CHR builds).
|
|
61
|
+
return versionNames
|
|
62
|
+
.filter((name) => /^\d+\.\d+/.test(name))
|
|
63
|
+
.map((name) => ({
|
|
64
|
+
version: name,
|
|
65
|
+
channel: classifyChannel(name),
|
|
66
|
+
inspectPath: `${baseUrl}/${name}/extra/inspect.json`,
|
|
67
|
+
hasExtra: true,
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function discoverLocalVersions(docsDir: string): VersionInfo[] {
|
|
73
|
+
const entries = readdirSync(docsDir).filter((name) => /^\d+\.\d+/.test(name));
|
|
74
|
+
return entries
|
|
75
|
+
.map((name) => {
|
|
76
|
+
const dir = resolve(docsDir, name);
|
|
77
|
+
const extraPath = resolve(dir, "extra/inspect.json");
|
|
78
|
+
const basePath = resolve(dir, "inspect.json");
|
|
79
|
+
const hasExtra = existsSync(extraPath);
|
|
80
|
+
const inspectPath = hasExtra ? extraPath : basePath;
|
|
81
|
+
|
|
82
|
+
if (!existsSync(inspectPath)) return null;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
version: name,
|
|
86
|
+
channel: classifyChannel(name),
|
|
87
|
+
inspectPath,
|
|
88
|
+
hasExtra,
|
|
89
|
+
};
|
|
90
|
+
})
|
|
91
|
+
.filter((v): v is VersionInfo => v !== null)
|
|
92
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const localMode = SOURCE && !isHttpUrl(SOURCE);
|
|
96
|
+
|
|
97
|
+
const versions = localMode
|
|
98
|
+
? discoverLocalVersions(resolve(SOURCE))
|
|
99
|
+
: await discoverRemoteVersions();
|
|
100
|
+
|
|
101
|
+
console.log(
|
|
102
|
+
`Found ${versions.length} RouterOS versions${localMode ? ` in ${resolve(SOURCE)}` : " from restraml GitHub"}`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (versions.length === 0) {
|
|
106
|
+
throw new Error(`No inspect.json files found${localMode ? ` in ${resolve(SOURCE)}` : " from restraml GitHub"}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Determine the latest stable version for primary extraction
|
|
110
|
+
const latestStable = [...versions].filter((v) => v.channel === "stable").pop();
|
|
111
|
+
const latest = latestStable || versions[versions.length - 1];
|
|
112
|
+
console.log(`Latest stable: ${latest?.version ?? "none"}`);
|
|
113
|
+
|
|
114
|
+
// Run extraction for each version
|
|
115
|
+
// First pass: accumulate all non-primary versions
|
|
116
|
+
// Second pass: primary version (replaces commands table)
|
|
117
|
+
|
|
118
|
+
const extractCmd = resolve(import.meta.dir, "extract-commands.ts");
|
|
119
|
+
|
|
120
|
+
let extracted = 0;
|
|
121
|
+
for (const v of versions) {
|
|
122
|
+
const isPrimary = v === latest;
|
|
123
|
+
const flags = [
|
|
124
|
+
`--version=${v.version}`,
|
|
125
|
+
`--channel=${v.channel}`,
|
|
126
|
+
...(v.hasExtra ? ["--extra"] : []),
|
|
127
|
+
...(isPrimary ? [] : ["--accumulate"]),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
131
|
+
console.log(`${isPrimary ? "PRIMARY" : "accumulate"}: ${v.version} (${v.channel})`);
|
|
132
|
+
|
|
133
|
+
const proc = Bun.spawnSync(["bun", "run", extractCmd, v.inspectPath, ...flags], {
|
|
134
|
+
cwd: resolve(import.meta.dir, ".."),
|
|
135
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (proc.exitCode !== 0) {
|
|
139
|
+
console.error(`FAILED: ${v.version} (exit ${proc.exitCode})`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
extracted++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
146
|
+
console.log(`Done. Extracted ${extracted}/${versions.length} versions.`);
|
|
147
|
+
console.log(`Primary version: ${latest?.version ?? "none"}`);
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract-changelogs.ts — Fetch and parse MikroTik changelogs into the changelogs table.
|
|
3
|
+
*
|
|
4
|
+
* Idempotent: deletes all existing changelog rows, then fetches and inserts.
|
|
5
|
+
* FTS5 index auto-populated via triggers defined in db.ts.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bun run src/extract-changelogs.ts # fetch for all ros_versions
|
|
9
|
+
* bun run src/extract-changelogs.ts --versions=7.21,7.22,7.22.1 # explicit versions
|
|
10
|
+
* bun run src/extract-changelogs.ts --probe-patches # discover patch releases
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { db, initDb } from "./db.ts";
|
|
14
|
+
|
|
15
|
+
const CHANGELOG_BASE = "https://download.mikrotik.com/routeros";
|
|
16
|
+
const FETCH_DELAY_MS = 200; // polite delay between requests
|
|
17
|
+
|
|
18
|
+
// ── Types ──
|
|
19
|
+
|
|
20
|
+
type ChangelogEntry = {
|
|
21
|
+
version: string;
|
|
22
|
+
released: string | null;
|
|
23
|
+
category: string;
|
|
24
|
+
is_breaking: number;
|
|
25
|
+
description: string;
|
|
26
|
+
sort_order: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── Parser ──
|
|
30
|
+
|
|
31
|
+
const HEADER_RE = /^What's new in (\S+) \(([^)]+)\):/i;
|
|
32
|
+
const ENTRY_RE = /^([*!])\)/;
|
|
33
|
+
|
|
34
|
+
/** Parse changelog text into structured entries. */
|
|
35
|
+
export function parseChangelog(text: string, expectedVersion?: string): ChangelogEntry[] {
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
const entries: ChangelogEntry[] = [];
|
|
38
|
+
|
|
39
|
+
let version: string | null = null;
|
|
40
|
+
let released: string | null = null;
|
|
41
|
+
let currentCategory: string | null = null;
|
|
42
|
+
let currentDesc = "";
|
|
43
|
+
let currentBreaking = 0;
|
|
44
|
+
let sortOrder = 0;
|
|
45
|
+
|
|
46
|
+
function flush() {
|
|
47
|
+
if (version && currentCategory !== null && currentDesc) {
|
|
48
|
+
entries.push({
|
|
49
|
+
version,
|
|
50
|
+
released,
|
|
51
|
+
category: currentCategory,
|
|
52
|
+
is_breaking: currentBreaking,
|
|
53
|
+
description: currentDesc.trim(),
|
|
54
|
+
sort_order: sortOrder++,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
currentCategory = null;
|
|
58
|
+
currentDesc = "";
|
|
59
|
+
currentBreaking = 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const rawLine of lines) {
|
|
63
|
+
const line = rawLine.trimEnd();
|
|
64
|
+
|
|
65
|
+
// Check for header
|
|
66
|
+
const headerMatch = line.match(HEADER_RE);
|
|
67
|
+
if (headerMatch) {
|
|
68
|
+
flush();
|
|
69
|
+
version = headerMatch[1];
|
|
70
|
+
released = headerMatch[2];
|
|
71
|
+
sortOrder = 0;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for entry start: *) or !)
|
|
76
|
+
const entryMatch = line.match(ENTRY_RE);
|
|
77
|
+
if (entryMatch) {
|
|
78
|
+
flush();
|
|
79
|
+
currentBreaking = entryMatch[1] === "!" ? 1 : 0;
|
|
80
|
+
|
|
81
|
+
// Rest of line after "*) " or "!) "
|
|
82
|
+
const rest = line.slice(2).trim();
|
|
83
|
+
|
|
84
|
+
// Extract category: text before first " - " within first 40 chars
|
|
85
|
+
const dashIdx = rest.indexOf(" - ");
|
|
86
|
+
if (dashIdx > 0 && dashIdx <= 40) {
|
|
87
|
+
currentCategory = rest.slice(0, dashIdx).trim().toLowerCase();
|
|
88
|
+
currentDesc = rest.slice(dashIdx + 3);
|
|
89
|
+
} else {
|
|
90
|
+
// No clear category separator — use "other"
|
|
91
|
+
currentCategory = "other";
|
|
92
|
+
currentDesc = rest;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Continuation line — append to current entry
|
|
98
|
+
if (currentCategory !== null && line.trim()) {
|
|
99
|
+
currentDesc += ` ${line.trim()}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Flush last entry
|
|
104
|
+
flush();
|
|
105
|
+
|
|
106
|
+
// If expectedVersion was given but header didn't match, override
|
|
107
|
+
// (some changelogs have slightly different header formats)
|
|
108
|
+
if (expectedVersion && entries.length > 0 && !entries.some((e) => e.version === expectedVersion)) {
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
entry.version = expectedVersion;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Fetch ──
|
|
118
|
+
|
|
119
|
+
async function fetchChangelog(version: string): Promise<string | null> {
|
|
120
|
+
const url = `${CHANGELOG_BASE}/${encodeURIComponent(version)}/CHANGELOG`;
|
|
121
|
+
try {
|
|
122
|
+
const resp = await fetch(url);
|
|
123
|
+
if (!resp.ok) {
|
|
124
|
+
if (resp.status === 404) return null;
|
|
125
|
+
console.warn(` ${version}: HTTP ${resp.status}`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return await resp.text();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(` ${version}: fetch error — ${err}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sleep(ms: number): Promise<void> {
|
|
136
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Version discovery ──
|
|
140
|
+
|
|
141
|
+
function getKnownVersions(): string[] {
|
|
142
|
+
const rows = db
|
|
143
|
+
.prepare("SELECT version FROM ros_versions ORDER BY version")
|
|
144
|
+
.all() as Array<{ version: string }>;
|
|
145
|
+
return rows.map((r) => r.version);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Probe patch versions: for each minor (7.X), try 7.X.1, 7.X.2, ... up to first 404. */
|
|
149
|
+
async function probePatchVersions(): Promise<string[]> {
|
|
150
|
+
const known = new Set(getKnownVersions());
|
|
151
|
+
const patches: string[] = [];
|
|
152
|
+
|
|
153
|
+
// Find all minor versions: extract unique 7.X prefixes
|
|
154
|
+
const minors = new Set<string>();
|
|
155
|
+
for (const v of known) {
|
|
156
|
+
const match = v.match(/^(\d+\.\d+)/);
|
|
157
|
+
if (match) minors.add(match[1]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const minor of [...minors].sort()) {
|
|
161
|
+
// Probe .1 through .20 per minor — up to ~920 requests total at 200ms each (~3 min)
|
|
162
|
+
for (let p = 1; p <= 20; p++) {
|
|
163
|
+
const patchVersion = `${minor}.${p}`;
|
|
164
|
+
if (known.has(patchVersion)) {
|
|
165
|
+
patches.push(patchVersion);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Probe by fetching
|
|
169
|
+
const text = await fetchChangelog(patchVersion);
|
|
170
|
+
if (text) {
|
|
171
|
+
patches.push(patchVersion);
|
|
172
|
+
console.log(` Discovered patch: ${patchVersion}`);
|
|
173
|
+
} else {
|
|
174
|
+
break; // No more patches for this minor
|
|
175
|
+
}
|
|
176
|
+
await sleep(FETCH_DELAY_MS);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return patches;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Main ──
|
|
184
|
+
|
|
185
|
+
if (import.meta.main) {
|
|
186
|
+
|
|
187
|
+
initDb();
|
|
188
|
+
|
|
189
|
+
// Parse CLI args
|
|
190
|
+
const args = process.argv.slice(2);
|
|
191
|
+
const versionsArg = args.find((a) => a.startsWith("--versions="));
|
|
192
|
+
const probePatches = args.includes("--probe-patches");
|
|
193
|
+
|
|
194
|
+
let versions: string[];
|
|
195
|
+
|
|
196
|
+
if (versionsArg) {
|
|
197
|
+
versions = versionsArg.slice("--versions=".length).split(",").map((v) => v.trim()).filter(Boolean);
|
|
198
|
+
console.log(`Changelog extraction: ${versions.length} explicit versions`);
|
|
199
|
+
} else if (probePatches) {
|
|
200
|
+
console.log("Changelog extraction: probing patch versions...");
|
|
201
|
+
const known = getKnownVersions();
|
|
202
|
+
const patches = await probePatchVersions();
|
|
203
|
+
// Merge: known + discovered patches (deduplicated)
|
|
204
|
+
const all = new Set([...known, ...patches]);
|
|
205
|
+
versions = [...all];
|
|
206
|
+
console.log(` ${versions.length} versions (${patches.length} from patch probing)`);
|
|
207
|
+
} else {
|
|
208
|
+
versions = getKnownVersions();
|
|
209
|
+
console.log(`Changelog extraction: ${versions.length} versions from ros_versions`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (versions.length === 0) {
|
|
213
|
+
console.error("No versions to process. Run extract-commands first, or use --versions=");
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Fetch all changelogs into memory first, then delete+insert in one transaction.
|
|
218
|
+
// This avoids leaving the table partially populated if a network failure occurs mid-run.
|
|
219
|
+
const fetched: Array<{ version: string; entries: ChangelogEntry[] }> = [];
|
|
220
|
+
let versionsFailed = 0;
|
|
221
|
+
|
|
222
|
+
for (const version of versions) {
|
|
223
|
+
const text = await fetchChangelog(version);
|
|
224
|
+
if (!text) {
|
|
225
|
+
versionsFailed++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const entries = parseChangelog(text, version);
|
|
230
|
+
if (entries.length === 0) {
|
|
231
|
+
console.warn(` ${version}: no entries parsed`);
|
|
232
|
+
versionsFailed++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fetched.push({ version, entries });
|
|
237
|
+
console.log(` ${version}: ${entries.length} entries${entries.some((e) => e.is_breaking) ? ` (${entries.filter((e) => e.is_breaking).length} breaking)` : ""}`);
|
|
238
|
+
|
|
239
|
+
await sleep(FETCH_DELAY_MS);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Atomic: delete all + insert all in one transaction
|
|
243
|
+
const insert = db.prepare(`INSERT OR IGNORE INTO changelogs
|
|
244
|
+
(version, released, category, is_breaking, description, sort_order)
|
|
245
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
246
|
+
|
|
247
|
+
const upsertVersion = db.prepare(`INSERT OR IGNORE INTO ros_versions
|
|
248
|
+
(version, channel, extracted_at)
|
|
249
|
+
VALUES (?, ?, datetime('now'))`);
|
|
250
|
+
|
|
251
|
+
const commitAll = db.transaction(() => {
|
|
252
|
+
db.run("DELETE FROM changelogs");
|
|
253
|
+
for (const { version, entries } of fetched) {
|
|
254
|
+
const channel = version.includes("beta") || version.includes("rc") ? "development" : "stable";
|
|
255
|
+
upsertVersion.run(version, channel);
|
|
256
|
+
for (const e of entries) {
|
|
257
|
+
insert.run(e.version, e.released, e.category, e.is_breaking, e.description, e.sort_order);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
commitAll();
|
|
262
|
+
|
|
263
|
+
const totalEntries = fetched.reduce((sum, f) => sum + f.entries.length, 0);
|
|
264
|
+
console.log(`\nChangelogs: ${totalEntries} entries from ${fetched.length} versions (${versionsFailed} failed/skipped)`);
|
|
265
|
+
|
|
266
|
+
} // end if (import.meta.main)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* extract-commands.ts — Load RouterOS command tree from inspect.json into SQLite.
|
|
4
|
+
*
|
|
5
|
+
* Walks the nested JSON tree and flattens it into a `commands` table with:
|
|
6
|
+
* path, name, type (dir|cmd|arg), parent_path, description
|
|
7
|
+
*
|
|
8
|
+
* Also populates command_versions for version tracking.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bun run src/extract-commands.ts [inspect.json-path-or-url] [--version=7.22] [--channel=stable] [--extra]
|
|
12
|
+
* bun run src/extract-commands.ts --accumulate [inspect.json-path] [--version=X]
|
|
13
|
+
*
|
|
14
|
+
* In default mode: replaces commands table and sets as primary version.
|
|
15
|
+
* With --accumulate: only adds to command_versions, does not touch commands table.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { db, initDb } from "./db.ts";
|
|
19
|
+
import { loadJson, RESTRAML_PAGES_URL } from "./restraml.ts";
|
|
20
|
+
|
|
21
|
+
// Parse flags
|
|
22
|
+
const cliArgs = process.argv.slice(2);
|
|
23
|
+
const accumulate = cliArgs.includes("--accumulate");
|
|
24
|
+
const extraPackages = cliArgs.includes("--extra");
|
|
25
|
+
const positional = cliArgs.filter((a) => !a.startsWith("--"));
|
|
26
|
+
const flagArgs = Object.fromEntries(
|
|
27
|
+
cliArgs.filter((a) => a.startsWith("--") && a.includes("=")).map((a) => {
|
|
28
|
+
const [k, ...v] = a.slice(2).split("=");
|
|
29
|
+
return [k, v.join("=")];
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const DEFAULT_INSPECT_URL = `${RESTRAML_PAGES_URL}/7.22.1/extra/inspect.json`;
|
|
34
|
+
const INSPECT_SOURCE = positional[0] || DEFAULT_INSPECT_URL;
|
|
35
|
+
|
|
36
|
+
// Derive version from path if not explicitly set
|
|
37
|
+
function deriveVersion(filepath: string): string {
|
|
38
|
+
const match = filepath.match(/\/(\d+\.\d+(?:\.\d+)?(?:beta\d+|rc\d+)?)\//);
|
|
39
|
+
return match?.[1] ?? "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function deriveChannel(version: string): string {
|
|
43
|
+
if (version.includes("beta") || version.includes("rc")) return "development";
|
|
44
|
+
const parts = version.split(".");
|
|
45
|
+
if (parts.length === 3) return "stable";
|
|
46
|
+
return "stable";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const version = flagArgs.version || deriveVersion(INSPECT_SOURCE);
|
|
50
|
+
const channel = flagArgs.channel || deriveChannel(version);
|
|
51
|
+
|
|
52
|
+
console.log(`Loading inspect.json from ${INSPECT_SOURCE}...`);
|
|
53
|
+
console.log(`Version: ${version}, Channel: ${channel}, Extra: ${extraPackages}, Accumulate: ${accumulate}`);
|
|
54
|
+
const inspectData = await loadJson<Record<string, unknown>>(INSPECT_SOURCE);
|
|
55
|
+
|
|
56
|
+
interface CommandRow {
|
|
57
|
+
path: string;
|
|
58
|
+
name: string;
|
|
59
|
+
type: string;
|
|
60
|
+
parentPath: string | null;
|
|
61
|
+
description: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rows: CommandRow[] = [];
|
|
65
|
+
|
|
66
|
+
function walk(obj: Record<string, unknown>, parentPath: string) {
|
|
67
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
68
|
+
if (key === "_type" || key === "desc") continue;
|
|
69
|
+
if (typeof value !== "object" || value === null) continue;
|
|
70
|
+
|
|
71
|
+
const node = value as Record<string, unknown>;
|
|
72
|
+
const nodeType = node._type as string | undefined;
|
|
73
|
+
if (!nodeType) continue;
|
|
74
|
+
|
|
75
|
+
const currentPath = parentPath ? `${parentPath}/${key}` : `/${key}`;
|
|
76
|
+
const desc = typeof node.desc === "string" ? node.desc : null;
|
|
77
|
+
|
|
78
|
+
// Normalize "path" type to "dir" — inspect.json uses both
|
|
79
|
+
const normalizedType = nodeType === "path" ? "dir" : nodeType;
|
|
80
|
+
|
|
81
|
+
rows.push({
|
|
82
|
+
path: currentPath,
|
|
83
|
+
name: key,
|
|
84
|
+
type: normalizedType,
|
|
85
|
+
parentPath: parentPath || null,
|
|
86
|
+
description: desc,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Recurse into children (dirs, paths, and cmds have children)
|
|
90
|
+
if (normalizedType === "dir" || normalizedType === "cmd") {
|
|
91
|
+
walk(node, currentPath);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
walk(inspectData, "");
|
|
97
|
+
|
|
98
|
+
console.log(`Parsed ${rows.length} command tree entries`);
|
|
99
|
+
const dirs = rows.filter((r) => r.type === "dir").length;
|
|
100
|
+
const cmds = rows.filter((r) => r.type === "cmd").length;
|
|
101
|
+
const args = rows.filter((r) => r.type === "arg").length;
|
|
102
|
+
console.log(` dirs: ${dirs}, cmds: ${cmds}, args: ${args}`);
|
|
103
|
+
|
|
104
|
+
// Initialize DB and insert
|
|
105
|
+
initDb();
|
|
106
|
+
|
|
107
|
+
// Register this version
|
|
108
|
+
db.run(
|
|
109
|
+
`INSERT OR REPLACE INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
110
|
+
VALUES (?, ?, ?, datetime('now'))`,
|
|
111
|
+
[version, channel, extraPackages ? 1 : 0],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!accumulate) {
|
|
115
|
+
// Primary mode: replace commands table entirely, set ros_version
|
|
116
|
+
db.run("DELETE FROM commands;");
|
|
117
|
+
|
|
118
|
+
const insert = db.prepare(`
|
|
119
|
+
INSERT OR IGNORE INTO commands (path, name, type, parent_path, description, ros_version)
|
|
120
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
121
|
+
`);
|
|
122
|
+
|
|
123
|
+
const insertCommands = db.transaction(() => {
|
|
124
|
+
for (const r of rows) {
|
|
125
|
+
insert.run(r.path, r.name, r.type, r.parentPath, r.description, version);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
insertCommands();
|
|
129
|
+
|
|
130
|
+
const total = (db.prepare("SELECT COUNT(*) as c FROM commands").get() as { c: number }).c;
|
|
131
|
+
console.log(`\nInserted ${total} commands into database (primary: ${version})`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Always populate command_versions for this version
|
|
135
|
+
db.run("DELETE FROM command_versions WHERE ros_version = ?", [version]);
|
|
136
|
+
|
|
137
|
+
const insertVersion = db.prepare(`
|
|
138
|
+
INSERT OR IGNORE INTO command_versions (command_path, ros_version)
|
|
139
|
+
VALUES (?, ?)
|
|
140
|
+
`);
|
|
141
|
+
|
|
142
|
+
const insertVersions = db.transaction(() => {
|
|
143
|
+
for (const r of rows) {
|
|
144
|
+
insertVersion.run(r.path, version);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
insertVersions();
|
|
148
|
+
|
|
149
|
+
const versionCount = (
|
|
150
|
+
db.prepare("SELECT COUNT(*) as c FROM command_versions WHERE ros_version = ?").get(version) as { c: number }
|
|
151
|
+
).c;
|
|
152
|
+
console.log(`Recorded ${versionCount} command_versions entries for ${version}`);
|
|
153
|
+
|
|
154
|
+
const totalVersions = (db.prepare("SELECT COUNT(DISTINCT ros_version) as c FROM command_versions").get() as { c: number }).c;
|
|
155
|
+
console.log(`Total versions tracked: ${totalVersions}`);
|
|
156
|
+
|
|
157
|
+
if (!accumulate) {
|
|
158
|
+
// Sample: show the /ip/firewall subtree
|
|
159
|
+
console.log("\nSample: /ip/firewall children:");
|
|
160
|
+
const children = db
|
|
161
|
+
.prepare("SELECT path, type FROM commands WHERE parent_path = '/ip/firewall' ORDER BY path")
|
|
162
|
+
.all() as Array<{ path: string; type: string }>;
|
|
163
|
+
for (const c of children) {
|
|
164
|
+
console.log(` ${c.type.padEnd(4)} ${c.path}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Show top-level dirs
|
|
168
|
+
console.log("\nTop-level directories:");
|
|
169
|
+
const topLevel = db
|
|
170
|
+
.prepare("SELECT path FROM commands WHERE parent_path = '' AND type = 'dir' ORDER BY path")
|
|
171
|
+
.all() as Array<{ path: string }>;
|
|
172
|
+
for (const t of topLevel) {
|
|
173
|
+
console.log(` ${t.path}`);
|
|
174
|
+
}
|
|
175
|
+
}
|