@tikoci/rosetta 0.7.3 → 0.7.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/db.ts CHANGED
@@ -53,6 +53,14 @@ export function initDb() {
53
53
  applied_at TEXT NOT NULL
54
54
  );`);
55
55
 
56
+ // -- DB metadata (release tag, build date, source commit) --
57
+ // Key/value to avoid schema churn. Written by extractors / release.yml;
58
+ // read by MCP startup banner and the freshness-check path.
59
+ db.run(`CREATE TABLE IF NOT EXISTS db_meta (
60
+ key TEXT PRIMARY KEY,
61
+ value TEXT NOT NULL
62
+ );`);
63
+
56
64
  // -- Pages (from Confluence HTML export) --
57
65
 
58
66
  db.run(`CREATE TABLE IF NOT EXISTS pages (
@@ -767,6 +775,31 @@ export function checkSchemaVersion(): { ok: boolean; actual: number; expected: n
767
775
  return { ok: row.user_version === SCHEMA_VERSION, actual: row.user_version, expected: SCHEMA_VERSION };
768
776
  }
769
777
 
778
+ /** Read a single row from db_meta. Returns null when the key is missing. */
779
+ export function getDbMeta(key: string): string | null {
780
+ try {
781
+ const row = db.prepare("SELECT value FROM db_meta WHERE key = ?").get(key) as { value: string } | null;
782
+ return row?.value ?? null;
783
+ } catch {
784
+ return null;
785
+ }
786
+ }
787
+
788
+ /** Upsert a single row into db_meta. */
789
+ export function setDbMeta(key: string, value: string): void {
790
+ db.run("INSERT INTO db_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;", [key, value]);
791
+ }
792
+
793
+ /** Read all db_meta rows as a flat object. */
794
+ export function getAllDbMeta(): Record<string, string> {
795
+ try {
796
+ const rows = db.prepare("SELECT key, value FROM db_meta").all() as Array<{ key: string; value: string }>;
797
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]));
798
+ } catch {
799
+ return {};
800
+ }
801
+ }
802
+
770
803
  export function getDbStats() {
771
804
  const count = (sql: string) =>
772
805
  Number((db.prepare(sql).get() as { c: number }).c ?? 0);
@@ -19,6 +19,7 @@ import { mkdtempSync, rmSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import type { Subprocess } from "bun";
22
+ import { SCHEMA_VERSION } from "./paths.ts";
22
23
 
23
24
  // ── Helpers ──
24
25
 
@@ -108,8 +109,12 @@ function createFixtureDb(dbPath: string): void {
108
109
  1, '/system', 'system', 'dir', NULL, 1, 'fixture command', '7.22'
109
110
  );`);
110
111
 
111
- // Stamp schema version so mcp.ts doesn't trigger a 50MB auto-download
112
- fixture.run("PRAGMA user_version = 1;");
112
+ // Stamp the current schema version so mcp.ts doesn't try to auto-download
113
+ // a "real" DB. Importing SCHEMA_VERSION here keeps the fixture in sync with
114
+ // any future bumps. Also seed db_meta so the startup banner has a release tag.
115
+ fixture.run(`PRAGMA user_version = ${SCHEMA_VERSION};`);
116
+ fixture.run("CREATE TABLE db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);");
117
+ fixture.run("INSERT INTO db_meta (key, value) VALUES ('release_tag', 'v0.0.0-test');");
113
118
  fixture.close();
114
119
  }
115
120
 
package/src/mcp.ts CHANGED
@@ -50,56 +50,97 @@ function link(url: string, display?: string): string {
50
50
  * Ensure the DB exists, has page data, and matches the current schema version.
51
51
  * This must run before importing db.ts/query.ts to avoid creating an empty DB file
52
52
  * on fresh installs.
53
+ *
54
+ * Failure modes are explicit:
55
+ * - Missing or empty DB → download once. If download fails, return — server
56
+ * will start but tool calls will surface the underlying SQL error.
57
+ * - Schema mismatch → re-download once, then re-probe. If still mismatched,
58
+ * fail hard with an actionable message rather than silently using a DB
59
+ * that the running code can't query correctly.
53
60
  */
54
61
  async function ensureDbReady(log: (msg: string) => void): Promise<void> {
55
- const { resolveDbPath, SCHEMA_VERSION } = await import("./paths.ts");
62
+ const { resolveDbPath, SCHEMA_VERSION, resolveVersion } = await import("./paths.ts");
56
63
  const { downloadDb } = await import("./setup.ts");
64
+ const { default: sqlite } = await import("bun:sqlite");
57
65
 
58
66
  const dbPath = resolveDbPath(import.meta.dirname);
67
+ const runningVersion = resolveVersion(import.meta.dirname);
59
68
 
60
- const pageCount = (() => {
69
+ /** Probe an existing DB. Returns null if the file is missing or unreadable. */
70
+ function probe(): { pages: number; schemaVersion: number; releaseTag: string | null } | null {
61
71
  try {
62
- const check = new (require("bun:sqlite").default)(dbPath, { readonly: true });
63
- const row = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
72
+ const check = new sqlite(dbPath, { readonly: true });
73
+ const pages = (check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number }).c;
74
+ const ver = (check.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
75
+ let releaseTag: string | null = null;
76
+ try {
77
+ const meta = check.prepare("SELECT value FROM db_meta WHERE key = 'release_tag'").get() as { value: string } | null;
78
+ releaseTag = meta?.value ?? null;
79
+ } catch {
80
+ // db_meta missing on pre-v5 DBs — leave null
81
+ }
64
82
  check.close();
65
- return row.c;
83
+ return { pages, schemaVersion: ver, releaseTag };
66
84
  } catch {
67
- return 0;
85
+ return null;
68
86
  }
69
- })();
87
+ }
70
88
 
71
- if (pageCount === 0) {
89
+ let p = probe();
90
+
91
+ // Case 1: DB missing or empty → first-time download.
92
+ if (!p || p.pages === 0) {
93
+ log(`No usable database at ${dbPath} — downloading...`);
72
94
  try {
73
95
  await downloadDb(dbPath, log);
74
96
  log("Database downloaded successfully.");
97
+ p = probe();
75
98
  } catch (e) {
76
- log(`Auto-download failed: ${e}`);
77
- log(`Run: ${process.argv[0]} --setup`);
99
+ log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
100
+ log(`Run: bunx @tikoci/rosetta --refresh`);
78
101
  return;
79
102
  }
80
103
  }
81
104
 
82
- const dbSchemaVersion = (() => {
83
- try {
84
- const check = new (require("bun:sqlite").default)(dbPath, { readonly: true });
85
- const row = check.prepare("PRAGMA user_version").get() as { user_version: number };
86
- check.close();
87
- return row.user_version;
88
- } catch {
89
- return SCHEMA_VERSION;
90
- }
91
- })();
105
+ if (!p) {
106
+ log(`Database probe failed after download.`);
107
+ return;
108
+ }
92
109
 
93
- if (dbSchemaVersion !== SCHEMA_VERSION) {
94
- log(`DB schema version mismatch (DB=${dbSchemaVersion}, expected=${SCHEMA_VERSION}) - re-downloading updated database...`);
110
+ // Case 2: Schema mismatch → re-download, then re-probe and fail hard if
111
+ // still wrong (don't silently boot with an incompatible schema).
112
+ if (p.schemaVersion !== SCHEMA_VERSION) {
113
+ log(
114
+ `DB schema mismatch: DB=${p.schemaVersion}, expected=${SCHEMA_VERSION}. ` +
115
+ `Re-downloading database...`,
116
+ );
95
117
  try {
96
118
  await downloadDb(dbPath, log);
97
- log("Database updated successfully.");
98
119
  } catch (e) {
99
- log(`Auto-download failed: ${e}`);
100
- log(`Run: ${process.argv[0]} --refresh`);
120
+ log(`✗ Auto-recovery download failed: ${e instanceof Error ? e.message : e}`);
121
+ log(
122
+ ` This rosetta build (v${runningVersion}) cannot use the existing DB. ` +
123
+ `Run \`bun pm cache rm\` to clear the bunx cache and relaunch.`,
124
+ );
125
+ process.exit(1);
126
+ }
127
+ const p2 = probe();
128
+ if (!p2 || p2.schemaVersion !== SCHEMA_VERSION) {
129
+ log(
130
+ `✗ Still incompatible after re-download (DB=${p2?.schemaVersion ?? "unreadable"}, expected=${SCHEMA_VERSION}).`,
131
+ );
132
+ log(
133
+ ` The published database does not match this rosetta build (v${runningVersion}). ` +
134
+ `Run \`bun pm cache rm && bunx @tikoci/rosetta --refresh\` to update both the package and the DB.`,
135
+ );
136
+ process.exit(1);
101
137
  }
138
+ p = p2;
102
139
  }
140
+
141
+ // Quietly emit a one-line provenance banner so MCP-client logs show what's loaded.
142
+ const tagInfo = p.releaseTag ? `, release ${p.releaseTag}` : "";
143
+ log(`rosetta v${runningVersion} ready (DB schema v${p.schemaVersion}, ${p.pages} pages${tagInfo}).`);
103
144
  }
104
145
 
105
146
  if (args.includes("--version") || args.includes("-v")) {
@@ -155,9 +196,11 @@ if (args.includes("--setup")) {
155
196
  }
156
197
 
157
198
  if (args.includes("--refresh")) {
158
- const { runSetup } = await import("./setup.ts");
159
- await runSetup(true);
160
- process.exit(0);
199
+ // Quiet refresh: just download + validate. Skips the MCP-config printing
200
+ // that --setup does — users running --refresh already have a configured client.
201
+ const { refreshDb } = await import("./setup.ts");
202
+ const ok = await refreshDb();
203
+ process.exit(ok ? 0 : 1);
161
204
  }
162
205
 
163
206
  // ── MCP Server ──
package/src/paths.ts CHANGED
@@ -85,8 +85,12 @@ export function detectMode(srcDir: string): InvocationMode {
85
85
  * Increment when making destructive schema changes (DROP/RENAME table or column).
86
86
  * Stamped into the DB via `PRAGMA user_version` by initDb() and checked at MCP
87
87
  * startup to detect stale DBs for bunx users who auto-update the package.
88
+ *
89
+ * Bump history:
90
+ * v5 — added `db_meta` key/value table for release-tag provenance and
91
+ * atomic-download / version-pinned-URL update flow (2026-04-21).
88
92
  */
89
- export const SCHEMA_VERSION = 4;
93
+ export const SCHEMA_VERSION = 5;
90
94
 
91
95
  /**
92
96
  * Resolve the version string.
package/src/query.test.ts CHANGED
@@ -1643,6 +1643,25 @@ describe("schema", () => {
1643
1643
  expect(result.actual).toBe(SCHEMA_VERSION);
1644
1644
  expect(result.expected).toBe(SCHEMA_VERSION);
1645
1645
  });
1646
+
1647
+ test("db_meta table exists with key/value shape and read/write helpers work", async () => {
1648
+ const cols = db.prepare("PRAGMA table_info(db_meta)").all() as Array<{ name: string; type: string }>;
1649
+ const colNames = cols.map((c) => c.name);
1650
+ expect(colNames).toContain("key");
1651
+ expect(colNames).toContain("value");
1652
+
1653
+ const { setDbMeta, getDbMeta, getAllDbMeta } = await import("./db.ts");
1654
+ setDbMeta("release_tag", "v0.0.0-test");
1655
+ setDbMeta("built_at", "2026-04-21T00:00:00Z");
1656
+ expect(getDbMeta("release_tag")).toBe("v0.0.0-test");
1657
+ expect(getDbMeta("missing_key")).toBeNull();
1658
+ // Upsert
1659
+ setDbMeta("release_tag", "v0.0.1-test");
1660
+ expect(getDbMeta("release_tag")).toBe("v0.0.1-test");
1661
+ const all = getAllDbMeta();
1662
+ expect(all.release_tag).toBe("v0.0.1-test");
1663
+ expect(all.built_at).toBe("2026-04-21T00:00:00Z");
1664
+ });
1646
1665
  });
1647
1666
 
1648
1667
  // ---------------------------------------------------------------------------
@@ -167,8 +167,66 @@ describe("setup.ts", () => {
167
167
  test("downloads from GitHub Releases URL", () => {
168
168
  const src = readText("src/setup.ts");
169
169
  expect(src).toContain("github.com/");
170
+ // Now uses both a version-pinned URL and a /latest/ fallback (see dbDownloadUrls).
171
+ expect(src).toContain("/releases/download/");
170
172
  expect(src).toContain("/releases/latest/download/ros-help.db.gz");
171
173
  });
174
+
175
+ test("validates SQLite magic bytes before writing the canonical DB path", () => {
176
+ const src = readText("src/setup.ts");
177
+ expect(src).toContain("SQLite format 3");
178
+ });
179
+
180
+ test("writes to a .tmp file and renames atomically", () => {
181
+ const src = readText("src/setup.ts");
182
+ expect(src).toContain(".tmp.");
183
+ expect(src).toContain("renameSync");
184
+ });
185
+
186
+ test("clears stale WAL/SHM siblings on download", () => {
187
+ const src = readText("src/setup.ts");
188
+ expect(src).toContain("-wal");
189
+ expect(src).toContain("-shm");
190
+ });
191
+
192
+ test("exports a quiet refreshDb path used by --refresh", () => {
193
+ const src = readText("src/setup.ts");
194
+ expect(src).toContain("export async function refreshDb");
195
+ const mcp = readText("src/mcp.ts");
196
+ expect(mcp).toContain("refreshDb");
197
+ });
198
+
199
+ test("mcp.ts fails hard on persistent schema mismatch (no silent fall-through)", () => {
200
+ const src = readText("src/mcp.ts");
201
+ // Must call process.exit on the unrecoverable schema-mismatch path
202
+ expect(src).toContain("Still incompatible after re-download");
203
+ expect(src).toMatch(/Still incompatible[\s\S]{0,400}process\.exit\(1\)/);
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // db_meta provenance + stamp script
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe("db_meta provenance", () => {
212
+ test("stamp-db-meta.ts script exists and accepts --release-tag", () => {
213
+ const src = readText("scripts/stamp-db-meta.ts");
214
+ expect(src).toContain("--release-tag");
215
+ expect(src).toContain("CREATE TABLE IF NOT EXISTS db_meta");
216
+ });
217
+
218
+ test("release.yml stamps db_meta after extraction", () => {
219
+ const yml = readText(".github/workflows/release.yml");
220
+ expect(yml).toContain("scripts/stamp-db-meta.ts");
221
+ expect(yml).toContain("--release-tag");
222
+ expect(yml).toContain("--source-commit");
223
+ });
224
+
225
+ test("db.ts exposes setDbMeta / getDbMeta helpers", () => {
226
+ const src = readText("src/db.ts");
227
+ expect(src).toContain("export function setDbMeta");
228
+ expect(src).toContain("export function getDbMeta");
229
+ });
172
230
  });
173
231
 
174
232
  // ---------------------------------------------------------------------------
@@ -0,0 +1,107 @@
1
+ /**
2
+ * setup.test.ts — Tests for DB download helpers.
3
+ *
4
+ * Covers the parts that don't require network: URL construction and DB probing
5
+ * against fixture DBs written to a temp directory. The full download path is
6
+ * validated structurally in release.test.ts.
7
+ */
8
+
9
+ import sqlite from "bun:sqlite";
10
+ import { afterAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import path from "node:path";
14
+
15
+ const tmp = mkdtempSync(path.join(tmpdir(), "rosetta-setup-test-"));
16
+
17
+ afterAll(() => {
18
+ try {
19
+ rmSync(tmp, { recursive: true, force: true });
20
+ } catch {}
21
+ });
22
+
23
+ const { probeDb, dbDownloadUrls } = await import("./setup.ts");
24
+ const { SCHEMA_VERSION } = await import("./paths.ts");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // dbDownloadUrls — version pinning + latest fallback
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe("dbDownloadUrls", () => {
31
+ test("returns pinned + latest for a real version", () => {
32
+ const urls = dbDownloadUrls("0.7.3");
33
+ expect(urls).toHaveLength(2);
34
+ expect(urls[0]).toContain("/releases/download/v0.7.3/ros-help.db.gz");
35
+ expect(urls[1]).toContain("/releases/latest/download/ros-help.db.gz");
36
+ });
37
+
38
+ test("preserves the v prefix when supplied", () => {
39
+ const urls = dbDownloadUrls("v0.8.0");
40
+ expect(urls[0]).toContain("/releases/download/v0.8.0/ros-help.db.gz");
41
+ });
42
+
43
+ test("returns only /latest/ when version is unknown or dev", () => {
44
+ expect(dbDownloadUrls("unknown")).toEqual([
45
+ expect.stringContaining("/releases/latest/download/ros-help.db.gz"),
46
+ ]);
47
+ expect(dbDownloadUrls("dev")).toEqual([
48
+ expect.stringContaining("/releases/latest/download/ros-help.db.gz"),
49
+ ]);
50
+ expect(dbDownloadUrls("")).toEqual([
51
+ expect.stringContaining("/releases/latest/download/ros-help.db.gz"),
52
+ ]);
53
+ });
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // probeDb — schema / pages / commands / release_tag
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe("probeDb", () => {
61
+ test("returns null for a missing file", () => {
62
+ expect(probeDb(path.join(tmp, "does-not-exist.db"))).toBeNull();
63
+ });
64
+
65
+ test("returns null for a non-SQLite file", () => {
66
+ const garbage = path.join(tmp, "garbage.db");
67
+ writeFileSync(garbage, "this is not a SQLite database");
68
+ expect(probeDb(garbage)).toBeNull();
69
+ });
70
+
71
+ test("reads schema_version, page count, command count, and release_tag", () => {
72
+ const dbFile = path.join(tmp, "fixture.db");
73
+ const db = new sqlite(dbFile);
74
+ db.run(`PRAGMA user_version = ${SCHEMA_VERSION};`);
75
+ db.run("CREATE TABLE pages (id INTEGER PRIMARY KEY, title TEXT);");
76
+ db.run("CREATE TABLE commands (id INTEGER PRIMARY KEY, path TEXT);");
77
+ db.run("CREATE TABLE db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);");
78
+
79
+ const insertPage = db.prepare("INSERT INTO pages (title) VALUES (?)");
80
+ for (let i = 0; i < 7; i++) insertPage.run(`page-${i}`);
81
+ const insertCmd = db.prepare("INSERT INTO commands (path) VALUES (?)");
82
+ for (let i = 0; i < 13; i++) insertCmd.run(`/cmd/${i}`);
83
+ db.run("INSERT INTO db_meta (key, value) VALUES ('release_tag', 'v0.0.0-test');");
84
+ db.close();
85
+
86
+ const probe = probeDb(dbFile);
87
+ expect(probe).not.toBeNull();
88
+ expect(probe?.schemaVersion).toBe(SCHEMA_VERSION);
89
+ expect(probe?.pages).toBe(7);
90
+ expect(probe?.commands).toBe(13);
91
+ expect(probe?.releaseTag).toBe("v0.0.0-test");
92
+ });
93
+
94
+ test("releaseTag is null when db_meta is absent (pre-v5 schema)", () => {
95
+ const dbFile = path.join(tmp, "no-meta.db");
96
+ const db = new sqlite(dbFile);
97
+ db.run("PRAGMA user_version = 4;");
98
+ db.run("CREATE TABLE pages (id INTEGER PRIMARY KEY);");
99
+ db.run("CREATE TABLE commands (id INTEGER PRIMARY KEY);");
100
+ db.close();
101
+
102
+ const probe = probeDb(dbFile);
103
+ expect(probe).not.toBeNull();
104
+ expect(probe?.schemaVersion).toBe(4);
105
+ expect(probe?.releaseTag).toBeNull();
106
+ });
107
+ });
package/src/setup.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { execSync } from "node:child_process";
11
- import { existsSync, writeFileSync } from "node:fs";
11
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
12
12
  import { gunzipSync } from "bun";
13
13
  import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
14
14
 
@@ -18,6 +18,14 @@ const GITHUB_REPO =
18
18
  typeof REPO_URL !== "undefined" ? REPO_URL : "tikoci/rosetta";
19
19
  const RELEASE_VERSION = resolveVersion(import.meta.dirname);
20
20
 
21
+ /** Minimum byte counts for a healthy DB. Validation thresholds — keep loose so
22
+ * shrinking the dataset doesn't break startup, but tight enough to catch a
23
+ * redirect-to-login HTML page or a partial transfer. */
24
+ const MIN_PAGES = 100;
25
+ const MIN_COMMANDS = 1000;
26
+ const MIN_DECOMPRESSED_BYTES = 50 * 1024 * 1024; // 50 MB
27
+ const SQLITE_MAGIC = "SQLite format 3\0";
28
+
21
29
  /** Check if a DB file exists and has actual page data */
22
30
  function dbHasData(dbPath: string): boolean {
23
31
  if (!existsSync(dbPath)) return false;
@@ -32,32 +40,215 @@ function dbHasData(dbPath: string): boolean {
32
40
  }
33
41
  }
34
42
 
35
- /** Download ros-help.db.gz from GitHub Releases, decompress, and write to dbPath */
43
+ /** Open a DB read-only and return its key health metrics. Returns null on error.
44
+ * Exported so tests can validate fixture DBs without depending on network. */
45
+ export function probeDb(dbPath: string): {
46
+ schemaVersion: number;
47
+ pages: number;
48
+ commands: number;
49
+ releaseTag: string | null;
50
+ } | null {
51
+ try {
52
+ const { default: sqlite } = require("bun:sqlite");
53
+ const check = new sqlite(dbPath, { readonly: true });
54
+ const ver = check.prepare("PRAGMA user_version").get() as { user_version: number };
55
+ const pages = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
56
+ const cmds = check.prepare("SELECT COUNT(*) AS c FROM commands").get() as { c: number };
57
+ let releaseTag: string | null = null;
58
+ try {
59
+ const meta = check.prepare("SELECT value FROM db_meta WHERE key = 'release_tag'").get() as { value: string } | null;
60
+ releaseTag = meta?.value ?? null;
61
+ } catch {
62
+ // db_meta missing — pre-v5 schema, leave releaseTag null
63
+ }
64
+ check.close();
65
+ return {
66
+ schemaVersion: ver.user_version,
67
+ pages: pages.c,
68
+ commands: cmds.c,
69
+ releaseTag,
70
+ };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Build the version-pinned download URL. Falls back to /latest/ when no version.
77
+ * Exported for test coverage. */
78
+ export function dbDownloadUrls(version: string): string[] {
79
+ const latest = `https://github.com/${GITHUB_REPO}/releases/latest/download/ros-help.db.gz`;
80
+ // version may be "0.7.3" (from package.json) or "v0.7.3" (compiled-in). Normalize.
81
+ const tag = version.startsWith("v") ? version : `v${version}`;
82
+ if (!version || version === "unknown" || version === "dev") {
83
+ return [latest];
84
+ }
85
+ const pinned = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/ros-help.db.gz`;
86
+ return [pinned, latest];
87
+ }
88
+
89
+ /**
90
+ * Download ros-help.db.gz from GitHub Releases atomically:
91
+ * 1. Try version-pinned URL first, fall back to /latest/ on 404.
92
+ * 2. Decompress in memory, verify SQLite magic bytes + minimum size.
93
+ * 3. Write to <dbPath>.tmp.<pid>, open it read-only, verify schema_version
94
+ * matches the running code and pages/commands counts look healthy.
95
+ * 4. Atomically rename .tmp → dbPath, then delete stale .db-wal / .db-shm.
96
+ *
97
+ * On any validation failure the existing DB is left untouched and we throw —
98
+ * the caller decides whether to fail hard or fall back. Never produces a
99
+ * half-written DB file at the canonical path.
100
+ */
36
101
  export async function downloadDb(
37
102
  dbPath: string,
38
103
  log: (msg: string) => void = console.log,
39
104
  ) {
40
- const url = `https://github.com/${GITHUB_REPO}/releases/latest/download/ros-help.db.gz`;
41
- log(`Downloading database from GitHub Releases...`);
42
- log(` ${url}`);
105
+ const urls = dbDownloadUrls(RELEASE_VERSION);
106
+ let lastError: Error | null = null;
107
+
108
+ for (let i = 0; i < urls.length; i++) {
109
+ const url = urls[i];
110
+ const isLast = i === urls.length - 1;
111
+ log(`Downloading database from GitHub Releases...`);
112
+ log(` ${url}`);
113
+
114
+ let response: Response;
115
+ try {
116
+ response = await fetch(url, { redirect: "follow" });
117
+ } catch (e) {
118
+ lastError = e as Error;
119
+ log(` Network error: ${e}`);
120
+ if (isLast) throw lastError;
121
+ continue;
122
+ }
43
123
 
44
- const response = await fetch(url, { redirect: "follow" });
45
- if (!response.ok) {
46
- throw new Error(`Download failed: ${response.status} ${response.statusText}`);
47
- }
124
+ if (response.status === 404 && !isLast) {
125
+ log(` Not found at this URL, trying fallback...`);
126
+ continue;
127
+ }
128
+ if (!response.ok) {
129
+ lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
130
+ if (isLast) throw lastError;
131
+ log(` ${lastError.message} — trying fallback...`);
132
+ continue;
133
+ }
134
+
135
+ const contentLength = response.headers.get("content-length");
136
+ const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
137
+ log(` Downloading ${totalMB} MB (compressed)...`);
138
+
139
+ const compressed = new Uint8Array(await response.arrayBuffer());
140
+ log(` Decompressing...`);
141
+
142
+ let decompressed: Uint8Array;
143
+ try {
144
+ decompressed = gunzipSync(compressed);
145
+ } catch (e) {
146
+ lastError = new Error(`Gunzip failed (corrupt download or HTML error page): ${e}`);
147
+ if (isLast) throw lastError;
148
+ log(` ${lastError.message}`);
149
+ continue;
150
+ }
151
+
152
+ // Validate magic bytes and minimum size before touching the filesystem.
153
+ if (decompressed.byteLength < MIN_DECOMPRESSED_BYTES) {
154
+ lastError = new Error(
155
+ `Decompressed DB too small: ${decompressed.byteLength} bytes (expected ≥ ${MIN_DECOMPRESSED_BYTES})`,
156
+ );
157
+ if (isLast) throw lastError;
158
+ log(` ${lastError.message}`);
159
+ continue;
160
+ }
161
+ const header = new TextDecoder().decode(decompressed.subarray(0, SQLITE_MAGIC.length));
162
+ if (header !== SQLITE_MAGIC) {
163
+ lastError = new Error("Downloaded payload is not a SQLite database (magic bytes mismatch)");
164
+ if (isLast) throw lastError;
165
+ log(` ${lastError.message}`);
166
+ continue;
167
+ }
168
+
169
+ // Write to a temp file next to the canonical DB path, validate, then rename.
170
+ const tmpPath = `${dbPath}.tmp.${process.pid}`;
171
+ try {
172
+ writeFileSync(tmpPath, decompressed);
173
+ } catch (e) {
174
+ lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
175
+ throw lastError;
176
+ }
48
177
 
49
- const contentLength = response.headers.get("content-length");
50
- const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
51
- log(` Downloading ${totalMB} MB (compressed)...`);
178
+ const probe = probeDb(tmpPath);
179
+ if (!probe) {
180
+ tryUnlink(tmpPath);
181
+ lastError = new Error("Downloaded DB failed to open with SQLite");
182
+ if (isLast) throw lastError;
183
+ log(` ${lastError.message} — trying fallback...`);
184
+ continue;
185
+ }
186
+ if (probe.schemaVersion !== SCHEMA_VERSION) {
187
+ tryUnlink(tmpPath);
188
+ lastError = new Error(
189
+ `Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
190
+ `This usually means the cached package version is older than the published DB. ` +
191
+ `Run \`bun pm cache rm\` and relaunch to pick up the latest package.`,
192
+ );
193
+ if (isLast) throw lastError;
194
+ log(` ${lastError.message}`);
195
+ continue;
196
+ }
197
+ if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
198
+ tryUnlink(tmpPath);
199
+ lastError = new Error(
200
+ `Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
201
+ );
202
+ if (isLast) throw lastError;
203
+ log(` ${lastError.message} — trying fallback...`);
204
+ continue;
205
+ }
52
206
 
53
- const compressed = new Uint8Array(await response.arrayBuffer());
54
- log(` Decompressing...`);
207
+ // Validation passed drop stale WAL/SHM and atomically swap.
208
+ tryUnlink(`${dbPath}-wal`);
209
+ tryUnlink(`${dbPath}-shm`);
210
+ renameSync(tmpPath, dbPath);
55
211
 
56
- const decompressed = gunzipSync(compressed);
57
- writeFileSync(dbPath, decompressed);
212
+ const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
213
+ const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
214
+ log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
215
+ log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
216
+ return;
217
+ }
218
+
219
+ throw lastError ?? new Error("Database download failed for unknown reasons");
220
+ }
58
221
 
59
- const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
60
- log(` Wrote ${sizeMB} MB to ${dbPath}`);
222
+ /** Remove a file if it exists, swallowing all errors. */
223
+ function tryUnlink(p: string): void {
224
+ try {
225
+ if (existsSync(p)) unlinkSync(p);
226
+ } catch {
227
+ // best-effort cleanup
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Quiet refresh — download + validate + report stats. No MCP-config printing.
233
+ * Used by `--refresh` (and indirectly by mcp.ts when auto-recovering from a
234
+ * stale DB at startup). Returns true on success, false on failure.
235
+ */
236
+ export async function refreshDb(log: (msg: string) => void = console.log): Promise<boolean> {
237
+ const dbPath = resolveDbPath(import.meta.dirname);
238
+ try {
239
+ await downloadDb(dbPath, log);
240
+ } catch (e) {
241
+ log(`✗ Refresh failed: ${e instanceof Error ? e.message : e}`);
242
+ return false;
243
+ }
244
+ const probe = probeDb(dbPath);
245
+ if (!probe) {
246
+ log(`✗ Post-download probe failed`);
247
+ return false;
248
+ }
249
+ const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
250
+ log(`✓ Database ready${tagInfo}: ${probe.pages} pages, ${probe.commands} commands, schema v${probe.schemaVersion}`);
251
+ return true;
61
252
  }
62
253
 
63
254
  export async function runSetup(force = false) {
@@ -74,31 +265,36 @@ export async function runSetup(force = false) {
74
265
  console.log(`Database already exists: ${dbPath}`);
75
266
  console.log(` (use --refresh or --setup --force to re-download)`);
76
267
  } else {
77
- await downloadDb(dbPath);
268
+ try {
269
+ await downloadDb(dbPath);
270
+ } catch (e) {
271
+ console.error(`✗ Database download failed: ${e instanceof Error ? e.message : e}`);
272
+ process.exit(1);
273
+ }
78
274
  }
79
275
 
80
276
  // ── Validate DB ──
81
277
  console.log();
82
- try {
83
- const { default: sqlite } = await import("bun:sqlite");
84
- // Note: open in read-write mode (not readonly) to allow WAL checkpoint.
85
- // WAL-mode databases can fail to initialize properly in readonly mode.
86
- const db = new sqlite(dbPath);
87
- const row = db.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
88
- const cmdRow = db.prepare("SELECT COUNT(*) AS c FROM commands WHERE type='cmd'").get() as { c: number };
89
- const versionRow = db.prepare("PRAGMA user_version").get() as { user_version: number };
90
- db.close();
91
- if (versionRow.user_version !== SCHEMA_VERSION) {
92
- console.warn(` Warning: DB schema version is ${versionRow.user_version}, expected ${SCHEMA_VERSION}.`);
93
- console.warn(` The downloaded DB may be incompatible with this version of rosetta.`);
94
- }
95
- console.log(`✓ Database ready (${row.c} pages, ${cmdRow.c} commands, schema v${versionRow.user_version})`);
96
- } catch (e) {
97
- console.error(`✗ Database validation failed: ${e}`);
278
+ const probe = probeDb(dbPath);
279
+ if (!probe) {
280
+ console.error(`✗ Database validation failed: cannot open ${dbPath}`);
98
281
  const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";
99
282
  console.error(` Try re-downloading with: ${retryCmd} --refresh`);
100
283
  process.exit(1);
101
284
  }
285
+ if (probe.schemaVersion !== SCHEMA_VERSION) {
286
+ console.error(
287
+ `✗ DB schema version is ${probe.schemaVersion}, expected ${SCHEMA_VERSION}.`,
288
+ );
289
+ console.error(
290
+ ` Cached package may be out of date. Run \`bun pm cache rm\` and relaunch.`,
291
+ );
292
+ process.exit(1);
293
+ }
294
+ const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
295
+ console.log(
296
+ `✓ Database ready${tagInfo}: ${probe.pages} pages, ${probe.commands} commands, schema v${probe.schemaVersion}`,
297
+ );
102
298
 
103
299
  // ── Print config snippets ──
104
300
  console.log();