@tikoci/rosetta 0.7.3 → 0.7.4
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 +1 -1
- package/src/db.ts +33 -0
- package/src/mcp-http.test.ts +7 -2
- package/src/mcp.ts +71 -28
- package/src/paths.ts +5 -1
- package/src/query.test.ts +19 -0
- package/src/release.test.ts +58 -0
- package/src/setup.test.ts +107 -0
- package/src/setup.ts +231 -35
package/package.json
CHANGED
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);
|
package/src/mcp-http.test.ts
CHANGED
|
@@ -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
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
const
|
|
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
|
|
83
|
+
return { pages, schemaVersion: ver, releaseTag };
|
|
66
84
|
} catch {
|
|
67
|
-
return
|
|
85
|
+
return null;
|
|
68
86
|
}
|
|
69
|
-
}
|
|
87
|
+
}
|
|
70
88
|
|
|
71
|
-
|
|
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:
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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(
|
|
100
|
-
log(
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 =
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/release.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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();
|