@tikoci/rosetta 0.8.11 → 0.8.13
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 +1 -1
- package/matrix/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/browse-parity.test.ts +57 -0
- package/src/cli-help.test.ts +78 -0
- package/src/mcp-contract.test.ts +51 -35
- package/src/mcp-stdio-client.test.ts +170 -0
- package/src/mcp.ts +9 -5
- package/src/release.test.ts +45 -23
- package/src/setup.test.ts +51 -1
- package/src/setup.ts +76 -20
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ This downloads the database and prints config snippets for all supported MCP cli
|
|
|
118
118
|
Need to force a database reload later? Use:
|
|
119
119
|
|
|
120
120
|
```sh
|
|
121
|
-
bunx @tikoci/rosetta --refresh
|
|
121
|
+
bunx @tikoci/rosetta@latest --refresh
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
### Configure your MCP client
|
package/matrix/CLAUDE.md
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Per-device hardware specs from mikrotik.com/products/matrix. Date-stamped snapshots stored in git.
|
|
4
4
|
|
|
5
|
-
See
|
|
5
|
+
See [DESIGN.md](../DESIGN.md) for product-matrix provenance and naming caveats, and [MANUAL.md](../MANUAL.md) for the re-extraction workflow.
|
|
6
6
|
|
|
7
|
-
Extraction: `bun run src/extract-devices.ts` (or `make extract-devices`). Idempotent — deletes and re-inserts all rows.
|
|
7
|
+
Extraction: `bun run src/extract-devices.ts` (or `make extract-devices`). Idempotent — deletes and re-inserts all rows.
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { EXPECTED_TOOLS } from "./mcp-contract.test.ts";
|
|
5
|
+
|
|
6
|
+
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
7
|
+
const configuredDbPath = process.env.TEST_DB_PATH?.trim();
|
|
8
|
+
const DB_PATH = configuredDbPath ? path.resolve(ROOT, configuredDbPath) : path.join(ROOT, "ros-help.db");
|
|
9
|
+
const hasTestDb = existsSync(DB_PATH);
|
|
10
|
+
const dbWasExplicitlyConfigured = Boolean(configuredDbPath);
|
|
11
|
+
const skipReason = `No populated test database at ${DB_PATH}; set TEST_DB_PATH or place ros-help.db at repo root to run this parity check.`;
|
|
12
|
+
|
|
13
|
+
function stripAnsi(s: string): string {
|
|
14
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape matching requires control chars
|
|
15
|
+
return s.replace(/\x1b\][^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runBrowseHelp(): string {
|
|
19
|
+
if (!existsSync(DB_PATH)) {
|
|
20
|
+
throw new Error(`Expected populated test database at ${DB_PATH}.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const proc = Bun.spawnSync(["bun", "src/browse.ts", "--once", ".help"], {
|
|
24
|
+
cwd: ROOT,
|
|
25
|
+
env: { ...process.env, DB_PATH },
|
|
26
|
+
stdout: "pipe",
|
|
27
|
+
stderr: "pipe",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (proc.exitCode !== 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`browse --once .help failed (exit ${proc.exitCode})\nstdout:\n${proc.stdout.toString()}\n\nstderr:\n${proc.stderr.toString()}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return proc.stdout.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractToolDotCommands(helpOutput: string): string[] {
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
for (const line of stripAnsi(helpOutput).split("\n")) {
|
|
42
|
+
const match = line.match(/^\s*\.(routeros_[a-z_]+)\b/);
|
|
43
|
+
if (match) seen.add(match[1]);
|
|
44
|
+
}
|
|
45
|
+
return [...seen].sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe.skipIf(!hasTestDb && !dbWasExplicitlyConfigured)(
|
|
49
|
+
hasTestDb || dbWasExplicitlyConfigured
|
|
50
|
+
? "browse TUI ↔ MCP parity"
|
|
51
|
+
: `browse TUI ↔ MCP parity [skipped: ${skipReason}]`,
|
|
52
|
+
() => {
|
|
53
|
+
test("browse .help lists a dot-command for every MCP tool", () => {
|
|
54
|
+
const actual = extractToolDotCommands(runBrowseHelp());
|
|
55
|
+
expect(actual).toEqual([...EXPECTED_TOOLS].sort());
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
6
|
+
const INTENTIONAL_OMISSIONS = new Set<string>();
|
|
7
|
+
|
|
8
|
+
function readText(relPath: string): string {
|
|
9
|
+
return readFileSync(path.join(ROOT, relPath), "utf-8");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeEntry(value: string): string {
|
|
13
|
+
const trimmed = value.trim().replace(/^`|`$/g, "");
|
|
14
|
+
return trimmed === "*(none)*" ? "(none)" : trimmed;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readManualCliFlags(): string[] {
|
|
18
|
+
const manual = readText("MANUAL.md");
|
|
19
|
+
const sectionMatch = manual.match(/## CLI Flags\n\n([\s\S]*?)\n## /);
|
|
20
|
+
if (!sectionMatch) {
|
|
21
|
+
throw new Error("Could not locate CLI Flags table in MANUAL.md.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const entries = new Set<string>();
|
|
25
|
+
for (const line of sectionMatch[1].split("\n")) {
|
|
26
|
+
if (!line.startsWith("|")) continue;
|
|
27
|
+
const cells = line.split("|").map((cell) => cell.trim()).filter(Boolean);
|
|
28
|
+
if (cells.length < 2) continue;
|
|
29
|
+
if (cells[0] === "Flag" || /^-+$/.test(cells[0])) continue;
|
|
30
|
+
entries.add(normalizeEntry(cells[0]));
|
|
31
|
+
}
|
|
32
|
+
return [...entries].sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runHelp(): string {
|
|
36
|
+
const proc = Bun.spawnSync(["bun", "src/mcp.ts", "--help"], {
|
|
37
|
+
cwd: ROOT,
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (proc.exitCode !== 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`src/mcp.ts --help failed (exit ${proc.exitCode})\nstdout:\n${proc.stdout.toString()}\n\nstderr:\n${proc.stderr.toString()}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return proc.stdout.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readHelpCliEntries(helpText: string): string[] {
|
|
52
|
+
const entries = new Set<string>();
|
|
53
|
+
|
|
54
|
+
for (const line of helpText.split("\n")) {
|
|
55
|
+
const usageMatch = line.match(/^\s*rosetta(?:\s+(.*?))?\s{2,}\S/);
|
|
56
|
+
if (usageMatch) {
|
|
57
|
+
const command = usageMatch[1]?.trim() ?? "";
|
|
58
|
+
entries.add(command === "" ? "(none)" : command);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const optionMatch = line.match(/^\s*(--[a-z-]+(?: <[^>]+>)?)\s{2,}\S/);
|
|
63
|
+
if (optionMatch) {
|
|
64
|
+
entries.add(optionMatch[1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [...entries].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("CLI help ↔ MANUAL flag parity", () => {
|
|
72
|
+
test("MANUAL CLI Flags table matches src/mcp.ts --help", () => {
|
|
73
|
+
const documented = readManualCliFlags().filter((entry) => !INTENTIONAL_OMISSIONS.has(entry));
|
|
74
|
+
const fromHelp = readHelpCliEntries(runHelp()).filter((entry) => !INTENTIONAL_OMISSIONS.has(entry));
|
|
75
|
+
|
|
76
|
+
expect(fromHelp).toEqual(documented);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/mcp-contract.test.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* and titles so DB refreshes don't churn snapshots.
|
|
10
10
|
*
|
|
11
11
|
* These are fast, deterministic, CI-runnable structural tests. No LLM calls,
|
|
12
|
-
* no network.
|
|
12
|
+
* no network. Block A always runs; set ROSETTA_REAL_DB_TESTS=1 to exercise
|
|
13
|
+
* Blocks B/C against a real populated DB for stable FTS results.
|
|
13
14
|
*
|
|
14
15
|
* When adding/removing/renaming a tool: update EXPECTED_TOOLS below AND add
|
|
15
16
|
* a CHANGELOG entry under [Unreleased] → Added/Changed/Removed. The test is
|
|
@@ -20,52 +21,67 @@ import { readFileSync } from "node:fs";
|
|
|
20
21
|
import path from "node:path";
|
|
21
22
|
|
|
22
23
|
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
24
|
+
const runRealDbBlocks = process.env.ROSETTA_REAL_DB_TESTS === "1";
|
|
23
25
|
|
|
24
26
|
// Block A (static file parse) runs unconditionally. Blocks B and C need the
|
|
25
|
-
// real populated DB singleton.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
// real populated DB singleton, but opening db.ts during the main `bun test`
|
|
28
|
+
// suite can poison query.test.ts's DB-wipe guard. Keep the real-DB imports
|
|
29
|
+
// behind an explicit opt-in so the shared suite stays on `:memory:` and the
|
|
30
|
+
// dedicated release/real-DB runs can still exercise Blocks B/C.
|
|
31
|
+
let searchAll: typeof import("./query.ts").searchAll | undefined;
|
|
32
|
+
let realDbPath = ":memory:";
|
|
33
|
+
let dbPages = 0;
|
|
34
|
+
|
|
35
|
+
if (runRealDbBlocks) {
|
|
36
|
+
const queryModule = await import("./query.ts");
|
|
37
|
+
const dbModule = await import("./db.ts");
|
|
38
|
+
searchAll = queryModule.searchAll;
|
|
39
|
+
realDbPath = dbModule.DB_PATH;
|
|
35
40
|
try {
|
|
36
|
-
|
|
41
|
+
dbPages = dbModule.getDbStats().pages;
|
|
37
42
|
} catch {
|
|
38
|
-
|
|
43
|
+
dbPages = 0;
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
function requireSearchAll(): typeof import("./query.ts").searchAll {
|
|
48
|
+
if (!searchAll) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"searchAll unavailable; set ROSETTA_REAL_DB_TESTS=1 to enable real-DB contract blocks.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return searchAll;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const dbIsReal = runRealDbBlocks && realDbPath !== ":memory:" && dbPages > 100;
|
|
44
57
|
const skipReason = dbIsReal
|
|
45
58
|
? ""
|
|
46
|
-
:
|
|
59
|
+
: runRealDbBlocks
|
|
60
|
+
? `DB singleton is "${realDbPath}" (pages=${dbPages}); run \`ROSETTA_REAL_DB_TESTS=1 bun test src/mcp-contract.test.ts\` against a populated DB for Blocks B/C.`
|
|
61
|
+
: "real-DB blocks are opt-in; set ROSETTA_REAL_DB_TESTS=1 and run this file against a populated DB for Blocks B/C.";
|
|
47
62
|
|
|
48
63
|
// ---------------------------------------------------------------------------
|
|
49
64
|
// Block A: Frozen tool registry
|
|
50
65
|
// ---------------------------------------------------------------------------
|
|
51
66
|
|
|
67
|
+
export const EXPECTED_TOOLS = [
|
|
68
|
+
"routeros_search",
|
|
69
|
+
"routeros_get_page",
|
|
70
|
+
"routeros_lookup_property",
|
|
71
|
+
"routeros_explain_command",
|
|
72
|
+
"routeros_command_tree",
|
|
73
|
+
"routeros_stats",
|
|
74
|
+
"routeros_search_changelogs",
|
|
75
|
+
"routeros_dude_search",
|
|
76
|
+
"routeros_dude_get_page",
|
|
77
|
+
"routeros_command_version_check",
|
|
78
|
+
"routeros_command_diff",
|
|
79
|
+
"routeros_device_lookup",
|
|
80
|
+
"routeros_search_tests",
|
|
81
|
+
"routeros_current_versions",
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
52
84
|
describe("Frozen tool registry", () => {
|
|
53
|
-
const EXPECTED_TOOLS = [
|
|
54
|
-
"routeros_search",
|
|
55
|
-
"routeros_get_page",
|
|
56
|
-
"routeros_lookup_property",
|
|
57
|
-
"routeros_explain_command",
|
|
58
|
-
"routeros_command_tree",
|
|
59
|
-
"routeros_stats",
|
|
60
|
-
"routeros_search_changelogs",
|
|
61
|
-
"routeros_dude_search",
|
|
62
|
-
"routeros_dude_get_page",
|
|
63
|
-
"routeros_command_version_check",
|
|
64
|
-
"routeros_command_diff",
|
|
65
|
-
"routeros_device_lookup",
|
|
66
|
-
"routeros_search_tests",
|
|
67
|
-
"routeros_current_versions",
|
|
68
|
-
];
|
|
69
85
|
|
|
70
86
|
test("exactly 14 tools registered", () => {
|
|
71
87
|
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
@@ -74,7 +90,7 @@ describe("Frozen tool registry", () => {
|
|
|
74
90
|
const foundTools = Array.from(toolMatches, (m) => m[1]);
|
|
75
91
|
|
|
76
92
|
expect(foundTools.length).toBe(14);
|
|
77
|
-
expect(foundTools.sort()).toEqual(EXPECTED_TOOLS.sort());
|
|
93
|
+
expect(foundTools.sort()).toEqual([...EXPECTED_TOOLS].sort());
|
|
78
94
|
});
|
|
79
95
|
|
|
80
96
|
test("all tools have workflow arrow (→) in description", () => {
|
|
@@ -156,7 +172,7 @@ describe.skipIf(!dbIsReal)(`Token-budget guardrails${dbIsReal ? "" : ` [skipped:
|
|
|
156
172
|
|
|
157
173
|
for (const { query, limit, budget } of QUERIES) {
|
|
158
174
|
test(`"${query}" (limit=${limit}) ≤ ${budget} tokens`, () => {
|
|
159
|
-
const result =
|
|
175
|
+
const result = requireSearchAll()(query, limit);
|
|
160
176
|
const tokens = estimateTokens(result);
|
|
161
177
|
|
|
162
178
|
if (tokens > budget) {
|
|
@@ -216,7 +232,7 @@ describe.skipIf(!dbIsReal)(`Response-shape invariants${dbIsReal ? "" : ` [skippe
|
|
|
216
232
|
|
|
217
233
|
for (const { query, limit, classifier_expected } of INVARIANTS) {
|
|
218
234
|
test(`shape: "${query}"`, () => {
|
|
219
|
-
const result =
|
|
235
|
+
const result = requireSearchAll()(query, limit);
|
|
220
236
|
|
|
221
237
|
// Top-level keys
|
|
222
238
|
expect(result).toHaveProperty("query", query);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-stdio-client.test.ts — Integration test for the real MCP stdio path.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `bun src/mcp.ts` via the SDK's StdioClientTransport and exercises the
|
|
5
|
+
* real client handshake, tool listing, tool calling, resource listing, and
|
|
6
|
+
* shutdown flow. This catches stdout pollution and JSON-RPC framing bugs that
|
|
7
|
+
* in-process tests cannot.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { Client } from "@modelcontextprotocol/sdk/client";
|
|
13
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
16
|
+
const configuredDbPath = process.env.TEST_DB_PATH?.trim();
|
|
17
|
+
const DB_PATH = configuredDbPath ? path.resolve(ROOT, configuredDbPath) : path.join(ROOT, "ros-help.db");
|
|
18
|
+
const hasTestDb = existsSync(DB_PATH);
|
|
19
|
+
const dbWasExplicitlyConfigured = Boolean(configuredDbPath);
|
|
20
|
+
const skipReason = `No populated test database at ${DB_PATH}; set TEST_DB_PATH or place ros-help.db at repo root to run this integration test.`;
|
|
21
|
+
|
|
22
|
+
const EXPECTED_TOOLS = [
|
|
23
|
+
"routeros_search",
|
|
24
|
+
"routeros_get_page",
|
|
25
|
+
"routeros_lookup_property",
|
|
26
|
+
"routeros_explain_command",
|
|
27
|
+
"routeros_command_tree",
|
|
28
|
+
"routeros_stats",
|
|
29
|
+
"routeros_search_changelogs",
|
|
30
|
+
"routeros_dude_search",
|
|
31
|
+
"routeros_dude_get_page",
|
|
32
|
+
"routeros_command_version_check",
|
|
33
|
+
"routeros_command_diff",
|
|
34
|
+
"routeros_device_lookup",
|
|
35
|
+
"routeros_search_tests",
|
|
36
|
+
"routeros_current_versions",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const FIXED_RESOURCE_URIS = [
|
|
40
|
+
"rosetta://datasets/device-test-results.csv",
|
|
41
|
+
"rosetta://datasets/devices.csv",
|
|
42
|
+
"rosetta://schema-guide.md",
|
|
43
|
+
"rosetta://schema.sql",
|
|
44
|
+
"rosetta://skills",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function asRecord(value: unknown, label: string): Record<string, unknown> {
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
49
|
+
throw new Error(`Expected ${label} to be an object.`);
|
|
50
|
+
}
|
|
51
|
+
return value as Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseJsonToolResult(value: unknown): Record<string, unknown> {
|
|
55
|
+
const result = asRecord(value, "tool result");
|
|
56
|
+
if (!Array.isArray(result.content) || result.content.length === 0) {
|
|
57
|
+
throw new Error("Expected tool result content[0] to exist.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const firstContent = asRecord(result.content[0], "tool result content[0]");
|
|
61
|
+
if (firstContent.type !== "text" || typeof firstContent.text !== "string") {
|
|
62
|
+
throw new Error("Expected tool result content[0] to be text.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return asRecord(JSON.parse(firstContent.text), "parsed tool JSON");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildDiagnostics(errors: Error[], stderr: string[]): string {
|
|
69
|
+
const sections: string[] = [];
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
sections.push(`transport/client errors:\n${errors.map((error) => error.stack ?? error.message).join("\n\n")}`);
|
|
72
|
+
}
|
|
73
|
+
if (stderr.length > 0) {
|
|
74
|
+
sections.push(`server stderr:\n${stderr.join("")}`);
|
|
75
|
+
}
|
|
76
|
+
return sections.length > 0 ? `\n\n${sections.join("\n\n")}` : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe.skipIf(!hasTestDb && !dbWasExplicitlyConfigured)(
|
|
80
|
+
hasTestDb || dbWasExplicitlyConfigured
|
|
81
|
+
? "stdio transport: real MCP client"
|
|
82
|
+
: `stdio transport: real MCP client [skipped: ${skipReason}]`,
|
|
83
|
+
() => {
|
|
84
|
+
let client: Client | undefined;
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
if (client) {
|
|
88
|
+
await client.close().catch(() => undefined);
|
|
89
|
+
client = undefined;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("real stdio client lists tools, calls routeros_search, lists resources, and closes cleanly", async () => {
|
|
94
|
+
if (!hasTestDb) {
|
|
95
|
+
throw new Error(`Expected populated test database at ${DB_PATH}.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const transport = new StdioClientTransport({
|
|
99
|
+
command: "bun",
|
|
100
|
+
args: ["src/mcp.ts"],
|
|
101
|
+
cwd: ROOT,
|
|
102
|
+
env: { ...process.env, DB_PATH },
|
|
103
|
+
stderr: "pipe",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const stderrChunks: string[] = [];
|
|
107
|
+
transport.stderr?.on("data", (chunk) => {
|
|
108
|
+
stderrChunks.push(chunk.toString());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const transportErrors: Error[] = [];
|
|
112
|
+
let resolveClosed!: () => void;
|
|
113
|
+
const closeSeen = new Promise<void>((resolve) => {
|
|
114
|
+
resolveClosed = resolve;
|
|
115
|
+
});
|
|
116
|
+
let closeCount = 0;
|
|
117
|
+
|
|
118
|
+
client = new Client({ name: "stdio-test-client", version: "1.0.0" });
|
|
119
|
+
client.onerror = (error) => {
|
|
120
|
+
transportErrors.push(error);
|
|
121
|
+
};
|
|
122
|
+
client.onclose = () => {
|
|
123
|
+
closeCount += 1;
|
|
124
|
+
resolveClosed();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await client.connect(transport);
|
|
128
|
+
expect(client.getServerVersion()?.name).toBe("rosetta");
|
|
129
|
+
|
|
130
|
+
const tools = await client.listTools();
|
|
131
|
+
const toolNames = tools.tools.map((tool) => tool.name).sort();
|
|
132
|
+
expect(toolNames).toEqual([...EXPECTED_TOOLS].sort());
|
|
133
|
+
|
|
134
|
+
const searchPayload = parseJsonToolResult(await client.callTool({
|
|
135
|
+
name: "routeros_search",
|
|
136
|
+
arguments: { query: "/ip/firewall/filter", limit: 3 },
|
|
137
|
+
}));
|
|
138
|
+
expect(Array.isArray(searchPayload.pages)).toBe(true);
|
|
139
|
+
expect((searchPayload.pages as unknown[]).length).toBeGreaterThan(0);
|
|
140
|
+
expect(searchPayload.related).toBeDefined();
|
|
141
|
+
expect(typeof searchPayload.related).toBe("object");
|
|
142
|
+
expect(Array.isArray(searchPayload.related)).toBe(false);
|
|
143
|
+
expect(Object.keys(asRecord(searchPayload.related, "routeros_search.related")).length).toBeGreaterThan(0);
|
|
144
|
+
|
|
145
|
+
const resources = await client.listResources();
|
|
146
|
+
const fixedResourceUris = resources.resources
|
|
147
|
+
.map((resource) => resource.uri)
|
|
148
|
+
.filter((uri) => !uri.startsWith("rosetta://skills/"))
|
|
149
|
+
.sort();
|
|
150
|
+
expect(fixedResourceUris).toEqual([...FIXED_RESOURCE_URIS].sort());
|
|
151
|
+
|
|
152
|
+
if (transportErrors.length > 0) {
|
|
153
|
+
throw new Error(`Unexpected stdio transport/client error before close.${buildDiagnostics(transportErrors, stderrChunks)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await client.close();
|
|
157
|
+
client = undefined;
|
|
158
|
+
await Promise.race([
|
|
159
|
+
closeSeen,
|
|
160
|
+
Bun.sleep(2_000).then(() => {
|
|
161
|
+
throw new Error(`Timed out waiting for stdio client/server close.${buildDiagnostics(transportErrors, stderrChunks)}`);
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
expect(closeCount).toBe(1);
|
|
166
|
+
if (transportErrors.length > 0) {
|
|
167
|
+
throw new Error(`Unexpected stdio transport/client error during close.${buildDiagnostics(transportErrors, stderrChunks)}`);
|
|
168
|
+
}
|
|
169
|
+
}, 60_000);
|
|
170
|
+
});
|
package/src/mcp.ts
CHANGED
|
@@ -61,14 +61,14 @@ function link(url: string, display?: string): string {
|
|
|
61
61
|
*/
|
|
62
62
|
async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
63
63
|
const { resolveDbPath, SCHEMA_VERSION, resolveVersion } = await import("./paths.ts");
|
|
64
|
-
const { downloadDb, hasMinimumDbContent, probeDb
|
|
64
|
+
const { cleanupAbandonedTempArtifacts, downloadDb, hasMinimumDbContent, probeDb } = await import("./setup.ts");
|
|
65
65
|
|
|
66
66
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
67
67
|
const runningVersion = resolveVersion(import.meta.dirname);
|
|
68
68
|
|
|
69
|
-
// Always clean
|
|
70
|
-
// whether a download is needed
|
|
71
|
-
|
|
69
|
+
// Always clean abandoned .tmp.* artifacts from previous failed runs when no
|
|
70
|
+
// active download lock exists, regardless of whether a download is needed now.
|
|
71
|
+
cleanupAbandonedTempArtifacts(dbPath);
|
|
72
72
|
|
|
73
73
|
let p = probeDb(dbPath);
|
|
74
74
|
|
|
@@ -80,7 +80,7 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
80
80
|
log("Database downloaded successfully.");
|
|
81
81
|
} catch (e) {
|
|
82
82
|
log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
|
|
83
|
-
log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
|
|
83
|
+
log(`Close other rosetta clients and run: bunx @tikoci/rosetta@latest --refresh`);
|
|
84
84
|
throw new Error(`Unable to start rosetta without a usable database at ${dbPath}.`);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -139,6 +139,8 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
139
139
|
console.log(" rosetta Start MCP server (stdio transport)");
|
|
140
140
|
console.log(" rosetta --http Start with Streamable HTTP transport");
|
|
141
141
|
console.log(" rosetta browse Interactive terminal browser");
|
|
142
|
+
console.log(" rosetta browse <cmd> [args] Run any TUI command once, then open REPL");
|
|
143
|
+
console.log(" rosetta browse --once <cmd> Execute any TUI command and exit");
|
|
142
144
|
console.log(" rosetta --setup Download database + print MCP client config");
|
|
143
145
|
console.log(" rosetta --setup --force Re-download database");
|
|
144
146
|
console.log(" rosetta --refresh Shortcut for --setup --force");
|
|
@@ -155,6 +157,8 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
155
157
|
console.log(" DB_PATH Absolute path to ros-help.db (optional)");
|
|
156
158
|
console.log(" PORT HTTP listen port (lower precedence than --port)");
|
|
157
159
|
console.log(" HOST HTTP bind address (lower precedence than --host)");
|
|
160
|
+
console.log(" TLS_CERT_PATH TLS certificate path (lower precedence than --tls-cert)");
|
|
161
|
+
console.log(" TLS_KEY_PATH TLS private key path (lower precedence than --tls-key)");
|
|
158
162
|
console.log();
|
|
159
163
|
console.log(`Quick start: bunx @tikoci/rosetta --setup`);
|
|
160
164
|
console.log(`Project: ${link("https://github.com/tikoci/rosetta")}`);
|
package/src/release.test.ts
CHANGED
|
@@ -225,9 +225,16 @@ describe("setup.ts", () => {
|
|
|
225
225
|
test("setup.ts cleans temp DB artifacts instead of accumulating stale .tmp files", () => {
|
|
226
226
|
const src = readText("src/setup.ts");
|
|
227
227
|
expect(src).toContain("cleanupStaleTempArtifacts");
|
|
228
|
+
expect(src).toContain("cleanupAbandonedTempArtifacts");
|
|
228
229
|
expect(src).toContain("tryUnlinkDbSidecars(tmpPath)");
|
|
229
230
|
});
|
|
230
231
|
|
|
232
|
+
test("setup.ts finalizes probe statements before renaming temp DBs", () => {
|
|
233
|
+
const src = readText("src/setup.ts");
|
|
234
|
+
expect(src).toContain("stmt.finalize()");
|
|
235
|
+
expect(src).toContain("sqliteGet");
|
|
236
|
+
});
|
|
237
|
+
|
|
231
238
|
test("no DB open uses { readonly: true } (WAL-shm init trap on macOS)", () => {
|
|
232
239
|
// Freshly-renamed WAL-mode DBs fail to open readonly on macOS until a
|
|
233
240
|
// read-write connection initialises the .shm file. downloadDb explicitly
|
|
@@ -281,12 +288,8 @@ describe("Makefile", () => {
|
|
|
281
288
|
expect(makefile).toContain("preflight:");
|
|
282
289
|
});
|
|
283
290
|
|
|
284
|
-
test("has
|
|
285
|
-
expect(makefile).toContain("
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
test("has release target", () => {
|
|
289
|
-
expect(makefile).toContain("release:");
|
|
291
|
+
test("has verify target", () => {
|
|
292
|
+
expect(makefile).toContain("verify:");
|
|
290
293
|
});
|
|
291
294
|
|
|
292
295
|
test("has extract-videos target", () => {
|
|
@@ -343,12 +346,8 @@ describe("Makefile", () => {
|
|
|
343
346
|
expect(phonyBlock).toContain("extract-dude");
|
|
344
347
|
});
|
|
345
348
|
|
|
346
|
-
test("
|
|
347
|
-
expect(makefile).toMatch(/^
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
test("release depends on build-release", () => {
|
|
351
|
-
expect(makefile).toMatch(/^release:.*build-release/m);
|
|
349
|
+
test("extract target includes Dude cache import", () => {
|
|
350
|
+
expect(makefile).toMatch(/^extract:.*extract-dude-from-cache/m);
|
|
352
351
|
});
|
|
353
352
|
|
|
354
353
|
test("extract target includes skills", () => {
|
|
@@ -359,10 +358,6 @@ describe("Makefile", () => {
|
|
|
359
358
|
expect(makefile).toMatch(/^extract-full:.*extract-skills/m);
|
|
360
359
|
});
|
|
361
360
|
|
|
362
|
-
test("extract target includes Dude cache import", () => {
|
|
363
|
-
expect(makefile).toMatch(/^extract:.*extract-dude-from-cache/m);
|
|
364
|
-
});
|
|
365
|
-
|
|
366
361
|
test("extract-full target includes Dude cache import", () => {
|
|
367
362
|
expect(makefile).toMatch(/^extract-full:.*extract-dude-from-cache/m);
|
|
368
363
|
});
|
|
@@ -370,12 +365,6 @@ describe("Makefile", () => {
|
|
|
370
365
|
test("preflight checks dirty tree", () => {
|
|
371
366
|
expect(makefile).toContain("git diff --quiet");
|
|
372
367
|
});
|
|
373
|
-
|
|
374
|
-
test("FORCE flag controls tag behavior", () => {
|
|
375
|
-
expect(makefile).toContain("FORCE");
|
|
376
|
-
expect(makefile).toContain("git tag -f");
|
|
377
|
-
expect(makefile).toContain("--clobber");
|
|
378
|
-
});
|
|
379
368
|
});
|
|
380
369
|
|
|
381
370
|
// ---------------------------------------------------------------------------
|
|
@@ -474,13 +463,34 @@ describe("release.yml", () => {
|
|
|
474
463
|
test("runs MCP contract tests against the real built DB before eval/release", () => {
|
|
475
464
|
const src = readText(".github/workflows/release.yml");
|
|
476
465
|
const contractIdx = src.indexOf("bun test src/mcp-contract.test.ts");
|
|
477
|
-
const evalIdx = src.indexOf("MCP retrieval eval (Phase 0
|
|
466
|
+
const evalIdx = src.indexOf("MCP retrieval eval (Phase 0)");
|
|
478
467
|
const buildIdx = src.indexOf("Build release artifacts");
|
|
479
468
|
expect(contractIdx).toBeGreaterThan(0);
|
|
480
469
|
expect(contractIdx).toBeLessThan(evalIdx);
|
|
481
470
|
expect(contractIdx).toBeLessThan(buildIdx);
|
|
482
471
|
});
|
|
483
472
|
|
|
473
|
+
test("runs Phase 1 self-supervised eval against the built DB and appends a summary result", () => {
|
|
474
|
+
const src = readText(".github/workflows/release.yml");
|
|
475
|
+
const phase0Idx = mustIndex(src, "MCP retrieval eval (Phase 0)");
|
|
476
|
+
const phase1Idx = mustIndex(
|
|
477
|
+
src,
|
|
478
|
+
"MCP retrieval eval (Phase 1, self-supervised, non-blocking)",
|
|
479
|
+
);
|
|
480
|
+
const buildIdx = mustIndex(src, "Build release artifacts");
|
|
481
|
+
const phase1Block = src.slice(phase1Idx, buildIdx);
|
|
482
|
+
|
|
483
|
+
expect(phase1Idx).toBeGreaterThan(phase0Idx);
|
|
484
|
+
expect(phase1Idx).toBeLessThan(buildIdx);
|
|
485
|
+
expect(phase1Block).toContain("continue-on-error: true");
|
|
486
|
+
expect(phase1Block).toContain("bun run src/eval/self-supervised.ts");
|
|
487
|
+
expect(phase1Block).toContain(
|
|
488
|
+
"## Phase 1 retrieval eval (self-supervised)",
|
|
489
|
+
);
|
|
490
|
+
expect(phase1Block).toContain("Result: ✅ pass");
|
|
491
|
+
expect(phase1Block).toContain("Result: ⚠️ non-blocking failure");
|
|
492
|
+
});
|
|
493
|
+
|
|
484
494
|
test("creates GitHub Release", () => {
|
|
485
495
|
const src = readText(".github/workflows/release.yml");
|
|
486
496
|
expect(src).toContain("gh release create");
|
|
@@ -536,6 +546,18 @@ describe("release.yml", () => {
|
|
|
536
546
|
expect(src).toContain("NPM_TOKEN");
|
|
537
547
|
});
|
|
538
548
|
|
|
549
|
+
test("bunx-smoke covers windows with bash steps and a runner temp log", () => {
|
|
550
|
+
const src = readText(".github/workflows/release.yml");
|
|
551
|
+
const bunxIdx = mustIndex(src, "bunx-smoke:");
|
|
552
|
+
const bumpIdx = mustIndex(src, "bump-version:");
|
|
553
|
+
const bunxBlock = src.slice(bunxIdx, bumpIdx);
|
|
554
|
+
|
|
555
|
+
expect(bunxBlock).toContain("windows-latest");
|
|
556
|
+
expect(bunxBlock).toContain("shell: bash");
|
|
557
|
+
expect(bunxBlock).toContain("RUNNER_TEMP");
|
|
558
|
+
expect(bunxBlock).not.toContain("mktemp");
|
|
559
|
+
});
|
|
560
|
+
|
|
539
561
|
test("validates DB content before publishing (regression: v0.7.6 shipped 3 pages)", () => {
|
|
540
562
|
const src = readText(".github/workflows/release.yml");
|
|
541
563
|
// Hard guard step that hard-fails the workflow if the built DB is degenerate.
|
package/src/setup.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import sqlite from "bun:sqlite";
|
|
10
10
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
11
|
-
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdtempSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
|
|
@@ -21,6 +21,7 @@ afterAll(() => {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
const {
|
|
24
|
+
cleanupAbandonedTempArtifacts,
|
|
24
25
|
cleanupStaleTempArtifacts,
|
|
25
26
|
dbDownloadUrls,
|
|
26
27
|
probeDb,
|
|
@@ -178,6 +179,19 @@ describe("probeDb", () => {
|
|
|
178
179
|
expect(probe?.releaseTag).toBe("v0.0.0-test");
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
test("closes statements so a probed temp DB can be renamed immediately", () => {
|
|
183
|
+
const dbFile = path.join(tmp, "probe-rename-source.db");
|
|
184
|
+
const renamed = path.join(tmp, "probe-rename-dest.db");
|
|
185
|
+
writeUsableDb(dbFile, "v0.0.0-rename");
|
|
186
|
+
|
|
187
|
+
const probe = probeDb(dbFile);
|
|
188
|
+
expect(probe?.releaseTag).toBe("v0.0.0-rename");
|
|
189
|
+
|
|
190
|
+
renameSync(dbFile, renamed);
|
|
191
|
+
expect(existsSync(dbFile)).toBe(false);
|
|
192
|
+
expect(probeDb(renamed)?.releaseTag).toBe("v0.0.0-rename");
|
|
193
|
+
});
|
|
194
|
+
|
|
181
195
|
test("opens a freshly-renamed WAL-mode DB with no .shm sibling", () => {
|
|
182
196
|
// Reproduces the exact state downloadDb leaves the DB in: journal_mode=WAL
|
|
183
197
|
// on disk, but the .wal/.shm siblings are deleted just before the rename.
|
|
@@ -242,3 +256,39 @@ describe("cleanupStaleTempArtifacts", () => {
|
|
|
242
256
|
}
|
|
243
257
|
});
|
|
244
258
|
});
|
|
259
|
+
|
|
260
|
+
describe("cleanupAbandonedTempArtifacts", () => {
|
|
261
|
+
test("removes fresh temp artifacts when no download lock exists", () => {
|
|
262
|
+
const dbFile = path.join(tmp, "cleanup-abandoned.db");
|
|
263
|
+
const artifacts = [
|
|
264
|
+
`${dbFile}.tmp.222`,
|
|
265
|
+
`${dbFile}.tmp.222-wal`,
|
|
266
|
+
`${dbFile}.tmp.222-shm`,
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
for (const artifact of artifacts) {
|
|
270
|
+
writeFileSync(artifact, "x");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
expect(cleanupAbandonedTempArtifacts(dbFile)).toBe(3);
|
|
274
|
+
for (const artifact of artifacts) {
|
|
275
|
+
expect(existsSync(artifact)).toBe(false);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("preserves fresh temp artifacts while a download lock exists", () => {
|
|
280
|
+
const dbFile = path.join(tmp, "cleanup-active.db");
|
|
281
|
+
const artifact = `${dbFile}.tmp.333`;
|
|
282
|
+
writeFileSync(artifact, "x");
|
|
283
|
+
const lock = tryAcquireDownloadLock(dbFile);
|
|
284
|
+
expect(lock).not.toBeNull();
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
expect(cleanupAbandonedTempArtifacts(dbFile)).toBe(0);
|
|
288
|
+
expect(existsSync(artifact)).toBe(true);
|
|
289
|
+
} finally {
|
|
290
|
+
releaseDownloadLock(lock);
|
|
291
|
+
unlinkSync(artifact);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
package/src/setup.ts
CHANGED
|
@@ -47,6 +47,18 @@ type DbProbe = {
|
|
|
47
47
|
releaseTag: string | null;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
type SQLiteStatement = {
|
|
51
|
+
get: (...params: unknown[]) => unknown;
|
|
52
|
+
finalize: () => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type SQLiteDatabase = {
|
|
56
|
+
prepare: (sql: string) => SQLiteStatement;
|
|
57
|
+
close: () => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type SQLiteConstructor = new (path: string) => SQLiteDatabase;
|
|
61
|
+
|
|
50
62
|
type DownloadLockHandle = {
|
|
51
63
|
fd: number;
|
|
52
64
|
path: string;
|
|
@@ -98,20 +110,20 @@ export function probeDb(dbPath: string): {
|
|
|
98
110
|
} | null {
|
|
99
111
|
if (!looksLikeSqliteFile(dbPath)) return null;
|
|
100
112
|
|
|
113
|
+
let check: SQLiteDatabase | null = null;
|
|
101
114
|
try {
|
|
102
|
-
const { default: sqlite } = require("bun:sqlite");
|
|
103
|
-
|
|
104
|
-
const ver =
|
|
105
|
-
const pages = check
|
|
106
|
-
const cmds = check
|
|
115
|
+
const { default: sqlite } = require("bun:sqlite") as { default: SQLiteConstructor };
|
|
116
|
+
check = new sqlite(dbPath);
|
|
117
|
+
const ver = sqliteGet<{ user_version: number }>(check, "PRAGMA user_version");
|
|
118
|
+
const pages = sqliteGet<{ c: number }>(check, "SELECT COUNT(*) AS c FROM pages");
|
|
119
|
+
const cmds = sqliteGet<{ c: number }>(check, "SELECT COUNT(*) AS c FROM commands");
|
|
107
120
|
let releaseTag: string | null = null;
|
|
108
121
|
try {
|
|
109
|
-
const meta = check
|
|
122
|
+
const meta = sqliteGet<{ value: string } | null>(check, "SELECT value FROM db_meta WHERE key = 'release_tag'");
|
|
110
123
|
releaseTag = meta?.value ?? null;
|
|
111
124
|
} catch {
|
|
112
125
|
// db_meta missing — pre-v5 schema, leave releaseTag null
|
|
113
126
|
}
|
|
114
|
-
check.close();
|
|
115
127
|
return {
|
|
116
128
|
schemaVersion: ver.user_version,
|
|
117
129
|
pages: pages.c,
|
|
@@ -120,6 +132,23 @@ export function probeDb(dbPath: string): {
|
|
|
120
132
|
};
|
|
121
133
|
} catch {
|
|
122
134
|
return null;
|
|
135
|
+
} finally {
|
|
136
|
+
if (check) {
|
|
137
|
+
try {
|
|
138
|
+
check.close();
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort cleanup; a failed probe should never leave a DB handle open
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sqliteGet<T>(db: SQLiteDatabase, sql: string): T {
|
|
147
|
+
const stmt = db.prepare(sql);
|
|
148
|
+
try {
|
|
149
|
+
return stmt.get() as T;
|
|
150
|
+
} finally {
|
|
151
|
+
stmt.finalize();
|
|
123
152
|
}
|
|
124
153
|
}
|
|
125
154
|
|
|
@@ -258,18 +287,45 @@ export function cleanupStaleTempArtifacts(dbPath: string, staleMs = DOWNLOAD_LOC
|
|
|
258
287
|
return removed;
|
|
259
288
|
}
|
|
260
289
|
|
|
261
|
-
function
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return;
|
|
265
|
-
} catch (e) {
|
|
266
|
-
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
267
|
-
// Windows can return EBUSY, EPERM, or EEXIST when the destination is open.
|
|
268
|
-
if (code !== "EBUSY" && code !== "EEXIST" && code !== "EPERM") throw e;
|
|
290
|
+
export function cleanupAbandonedTempArtifacts(dbPath: string): number {
|
|
291
|
+
if (existsSync(lockPathFor(dbPath))) {
|
|
292
|
+
return cleanupStaleTempArtifacts(dbPath);
|
|
269
293
|
}
|
|
294
|
+
return cleanupStaleTempArtifacts(dbPath, -1);
|
|
295
|
+
}
|
|
270
296
|
|
|
271
|
-
|
|
272
|
-
|
|
297
|
+
function isReplaceRaceError(e: unknown): boolean {
|
|
298
|
+
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
299
|
+
// Windows can return EBUSY, EPERM, or EEXIST when the source or destination is open.
|
|
300
|
+
return code === "EBUSY" || code === "EEXIST" || code === "EPERM";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function replaceDbFile(tmpPath: string, dbPath: string): Promise<void> {
|
|
304
|
+
const deadline = Date.now() + 30_000;
|
|
305
|
+
let lastError: unknown = null;
|
|
306
|
+
|
|
307
|
+
while (Date.now() <= deadline) {
|
|
308
|
+
try {
|
|
309
|
+
renameSync(tmpPath, dbPath);
|
|
310
|
+
return;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (!isReplaceRaceError(e)) throw e;
|
|
313
|
+
lastError = e;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
tryUnlink(dbPath);
|
|
317
|
+
try {
|
|
318
|
+
renameSync(tmpPath, dbPath);
|
|
319
|
+
return;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (!isReplaceRaceError(e)) throw e;
|
|
322
|
+
lastError = e;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await Bun.sleep(250);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw lastError;
|
|
273
329
|
}
|
|
274
330
|
|
|
275
331
|
/** Build the version-pinned download URL. Falls back to /latest/ when no version.
|
|
@@ -418,9 +474,9 @@ export async function downloadDb(
|
|
|
418
474
|
if (probe.schemaVersion !== SCHEMA_VERSION) {
|
|
419
475
|
cleanupDbArtifacts(tmpPath);
|
|
420
476
|
lastError = new Error(
|
|
421
|
-
|
|
477
|
+
`Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
|
|
422
478
|
`This usually means the cached package version is older than the published DB. ` +
|
|
423
|
-
`Run
|
|
479
|
+
`Run: bunx @tikoci/rosetta@latest --refresh`,
|
|
424
480
|
);
|
|
425
481
|
if (isLast) throw lastError;
|
|
426
482
|
log(` ${lastError.message}`);
|
|
@@ -440,7 +496,7 @@ export async function downloadDb(
|
|
|
440
496
|
try {
|
|
441
497
|
tryUnlinkDbSidecars(tmpPath);
|
|
442
498
|
tryUnlinkDbSidecars(dbPath);
|
|
443
|
-
replaceDbFile(tmpPath, dbPath);
|
|
499
|
+
await replaceDbFile(tmpPath, dbPath);
|
|
444
500
|
} catch (e) {
|
|
445
501
|
const existingProbe = probeDb(dbPath);
|
|
446
502
|
if (hasMinimumDbContent(existingProbe) && existingProbe.schemaVersion === probe.schemaVersion && existingProbe.releaseTag === probe.releaseTag) {
|