@tikoci/rosetta 0.8.12 → 0.9.1
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/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/extract-skills.test.ts +39 -0
- package/src/extract-skills.ts +13 -4
- package/src/mcp-contract.test.ts +51 -35
- package/src/mcp-stdio-client.test.ts +170 -0
- package/src/mcp.ts +4 -0
- package/src/release.test.ts +69 -26
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
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
process.env.DB_PATH = ":memory:";
|
|
4
|
+
|
|
5
|
+
const { githubApiHeaders } = await import("./extract-skills.ts");
|
|
6
|
+
|
|
7
|
+
describe("extract-skills GitHub API headers", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
delete process.env.GITHUB_TOKEN;
|
|
10
|
+
delete process.env.GH_TOKEN;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("keeps unauthenticated headers when no token is configured", () => {
|
|
14
|
+
delete process.env.GITHUB_TOKEN;
|
|
15
|
+
delete process.env.GH_TOKEN;
|
|
16
|
+
|
|
17
|
+
expect(githubApiHeaders()).toEqual({
|
|
18
|
+
Accept: "application/vnd.github.v3+json",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("uses GITHUB_TOKEN for authenticated API requests", () => {
|
|
23
|
+
process.env.GITHUB_TOKEN = "test-token";
|
|
24
|
+
|
|
25
|
+
expect(githubApiHeaders()).toEqual({
|
|
26
|
+
Accept: "application/vnd.github.v3+json",
|
|
27
|
+
Authorization: "Bearer test-token",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("falls back to GH_TOKEN for local authenticated runs", () => {
|
|
32
|
+
process.env.GH_TOKEN = "gh-token";
|
|
33
|
+
|
|
34
|
+
expect(githubApiHeaders()).toEqual({
|
|
35
|
+
Accept: "application/vnd.github.v3+json",
|
|
36
|
+
Authorization: "Bearer gh-token",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/extract-skills.ts
CHANGED
|
@@ -70,9 +70,16 @@ function countWords(text: string): number {
|
|
|
70
70
|
|
|
71
71
|
// ── GitHub API fetching ──
|
|
72
72
|
|
|
73
|
+
export function githubApiHeaders(): Record<string, string> {
|
|
74
|
+
const headers: Record<string, string> = { Accept: "application/vnd.github.v3+json" };
|
|
75
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
76
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
77
|
+
return headers;
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
async function getDefaultBranchSha(): Promise<string> {
|
|
74
81
|
const res = await fetch(`${GITHUB_API_BASE}/commits/HEAD`, {
|
|
75
|
-
headers:
|
|
82
|
+
headers: githubApiHeaders(),
|
|
76
83
|
});
|
|
77
84
|
if (!res.ok) throw new Error(`Failed to get HEAD SHA: HTTP ${res.status}`);
|
|
78
85
|
const data = (await res.json()) as { sha: string };
|
|
@@ -81,7 +88,7 @@ async function getDefaultBranchSha(): Promise<string> {
|
|
|
81
88
|
|
|
82
89
|
async function listSkillDirs(sha: string): Promise<string[]> {
|
|
83
90
|
const res = await fetch(`${GITHUB_API_BASE}/contents/?ref=${sha}`, {
|
|
84
|
-
headers:
|
|
91
|
+
headers: githubApiHeaders(),
|
|
85
92
|
});
|
|
86
93
|
if (!res.ok) throw new Error(`Failed to list repo contents: HTTP ${res.status}`);
|
|
87
94
|
const entries = (await res.json()) as Array<{ name: string; type: string }>;
|
|
@@ -102,7 +109,7 @@ async function fetchRawFile(sha: string, path: string): Promise<string | null> {
|
|
|
102
109
|
|
|
103
110
|
async function listReferences(sha: string, skillName: string): Promise<string[]> {
|
|
104
111
|
const res = await fetch(`${GITHUB_API_BASE}/contents/${skillName}/references?ref=${sha}`, {
|
|
105
|
-
headers:
|
|
112
|
+
headers: githubApiHeaders(),
|
|
106
113
|
});
|
|
107
114
|
if (!res.ok) {
|
|
108
115
|
if (res.status === 404) return [];
|
|
@@ -387,4 +394,6 @@ async function main() {
|
|
|
387
394
|
populateDb(skills, sha);
|
|
388
395
|
}
|
|
389
396
|
|
|
390
|
-
|
|
397
|
+
if (import.meta.main) {
|
|
398
|
+
await main();
|
|
399
|
+
}
|
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
|
@@ -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
|
@@ -288,12 +288,8 @@ describe("Makefile", () => {
|
|
|
288
288
|
expect(makefile).toContain("preflight:");
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
test("has
|
|
292
|
-
expect(makefile).toContain("
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test("has release target", () => {
|
|
296
|
-
expect(makefile).toContain("release:");
|
|
291
|
+
test("has verify target", () => {
|
|
292
|
+
expect(makefile).toContain("verify:");
|
|
297
293
|
});
|
|
298
294
|
|
|
299
295
|
test("has extract-videos target", () => {
|
|
@@ -350,12 +346,8 @@ describe("Makefile", () => {
|
|
|
350
346
|
expect(phonyBlock).toContain("extract-dude");
|
|
351
347
|
});
|
|
352
348
|
|
|
353
|
-
test("
|
|
354
|
-
expect(makefile).toMatch(/^
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
test("release depends on build-release", () => {
|
|
358
|
-
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);
|
|
359
351
|
});
|
|
360
352
|
|
|
361
353
|
test("extract target includes skills", () => {
|
|
@@ -366,10 +358,6 @@ describe("Makefile", () => {
|
|
|
366
358
|
expect(makefile).toMatch(/^extract-full:.*extract-skills/m);
|
|
367
359
|
});
|
|
368
360
|
|
|
369
|
-
test("extract target includes Dude cache import", () => {
|
|
370
|
-
expect(makefile).toMatch(/^extract:.*extract-dude-from-cache/m);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
361
|
test("extract-full target includes Dude cache import", () => {
|
|
374
362
|
expect(makefile).toMatch(/^extract-full:.*extract-dude-from-cache/m);
|
|
375
363
|
});
|
|
@@ -377,12 +365,6 @@ describe("Makefile", () => {
|
|
|
377
365
|
test("preflight checks dirty tree", () => {
|
|
378
366
|
expect(makefile).toContain("git diff --quiet");
|
|
379
367
|
});
|
|
380
|
-
|
|
381
|
-
test("FORCE flag controls tag behavior", () => {
|
|
382
|
-
expect(makefile).toContain("FORCE");
|
|
383
|
-
expect(makefile).toContain("git tag -f");
|
|
384
|
-
expect(makefile).toContain("--clobber");
|
|
385
|
-
});
|
|
386
368
|
});
|
|
387
369
|
|
|
388
370
|
// ---------------------------------------------------------------------------
|
|
@@ -429,7 +411,13 @@ describe("release.yml", () => {
|
|
|
429
411
|
|
|
430
412
|
test("extracts agent skills in CI", () => {
|
|
431
413
|
const src = readText(".github/workflows/release.yml");
|
|
432
|
-
|
|
414
|
+
const skillsIdx = mustIndex(src, "Extract agent skills from GitHub");
|
|
415
|
+
const linkIdx = mustIndex(src, "Link commands to pages");
|
|
416
|
+
const skillsBlock = src.slice(skillsIdx, linkIdx);
|
|
417
|
+
|
|
418
|
+
expect(skillsBlock).toContain("extract-skills.ts");
|
|
419
|
+
expect(skillsBlock).toContain("GITHUB_TOKEN");
|
|
420
|
+
expect(skillsBlock).toContain("$" + "{{ github.token }}");
|
|
433
421
|
});
|
|
434
422
|
|
|
435
423
|
test("runs quality gate before release", () => {
|
|
@@ -454,6 +442,26 @@ describe("release.yml", () => {
|
|
|
454
442
|
expect(earlyTestIdx).toBeLessThan(downloadIdx);
|
|
455
443
|
});
|
|
456
444
|
|
|
445
|
+
test("preflights npm publish access before release side effects", () => {
|
|
446
|
+
const src = readText(".github/workflows/release.yml");
|
|
447
|
+
const preflightIdx = mustIndex(src, "Verify npm publish access");
|
|
448
|
+
const downloadIdx = mustIndex(src, "Download HTML export");
|
|
449
|
+
const ociIdx = mustIndex(src, "Build and push OCI images");
|
|
450
|
+
const releaseIdx = mustIndex(src, "Create or update GitHub Release");
|
|
451
|
+
const publishIdx = mustIndex(src, "Publish to npm");
|
|
452
|
+
const preflightBlock = src.slice(preflightIdx, downloadIdx);
|
|
453
|
+
|
|
454
|
+
expect(preflightIdx).toBeLessThan(downloadIdx);
|
|
455
|
+
expect(preflightIdx).toBeLessThan(ociIdx);
|
|
456
|
+
expect(preflightIdx).toBeLessThan(releaseIdx);
|
|
457
|
+
expect(preflightIdx).toBeLessThan(publishIdx);
|
|
458
|
+
expect(preflightBlock).toContain("NODE_AUTH_TOKEN");
|
|
459
|
+
expect(preflightBlock).toContain("npm whoami");
|
|
460
|
+
expect(preflightBlock).toContain("npm access list collaborators");
|
|
461
|
+
expect(preflightBlock).toContain("read-write access");
|
|
462
|
+
expect(preflightBlock).toContain("npm view");
|
|
463
|
+
});
|
|
464
|
+
|
|
457
465
|
test("keeps post-extraction DB-wipe guard after extraction", () => {
|
|
458
466
|
const src = readText(".github/workflows/release.yml");
|
|
459
467
|
const linkIdx = mustIndex(src, "Link commands to pages");
|
|
@@ -481,13 +489,34 @@ describe("release.yml", () => {
|
|
|
481
489
|
test("runs MCP contract tests against the real built DB before eval/release", () => {
|
|
482
490
|
const src = readText(".github/workflows/release.yml");
|
|
483
491
|
const contractIdx = src.indexOf("bun test src/mcp-contract.test.ts");
|
|
484
|
-
const evalIdx = src.indexOf("MCP retrieval eval (Phase 0
|
|
492
|
+
const evalIdx = src.indexOf("MCP retrieval eval (Phase 0)");
|
|
485
493
|
const buildIdx = src.indexOf("Build release artifacts");
|
|
486
494
|
expect(contractIdx).toBeGreaterThan(0);
|
|
487
495
|
expect(contractIdx).toBeLessThan(evalIdx);
|
|
488
496
|
expect(contractIdx).toBeLessThan(buildIdx);
|
|
489
497
|
});
|
|
490
498
|
|
|
499
|
+
test("runs Phase 1 self-supervised eval against the built DB and appends a summary result", () => {
|
|
500
|
+
const src = readText(".github/workflows/release.yml");
|
|
501
|
+
const phase0Idx = mustIndex(src, "MCP retrieval eval (Phase 0)");
|
|
502
|
+
const phase1Idx = mustIndex(
|
|
503
|
+
src,
|
|
504
|
+
"MCP retrieval eval (Phase 1, self-supervised, non-blocking)",
|
|
505
|
+
);
|
|
506
|
+
const buildIdx = mustIndex(src, "Build release artifacts");
|
|
507
|
+
const phase1Block = src.slice(phase1Idx, buildIdx);
|
|
508
|
+
|
|
509
|
+
expect(phase1Idx).toBeGreaterThan(phase0Idx);
|
|
510
|
+
expect(phase1Idx).toBeLessThan(buildIdx);
|
|
511
|
+
expect(phase1Block).toContain("continue-on-error: true");
|
|
512
|
+
expect(phase1Block).toContain("bun run src/eval/self-supervised.ts");
|
|
513
|
+
expect(phase1Block).toContain(
|
|
514
|
+
"## Phase 1 retrieval eval (self-supervised)",
|
|
515
|
+
);
|
|
516
|
+
expect(phase1Block).toContain("Result: ✅ pass");
|
|
517
|
+
expect(phase1Block).toContain("Result: ⚠️ non-blocking failure");
|
|
518
|
+
});
|
|
519
|
+
|
|
491
520
|
test("creates GitHub Release", () => {
|
|
492
521
|
const src = readText(".github/workflows/release.yml");
|
|
493
522
|
expect(src).toContain("gh release create");
|
|
@@ -507,6 +536,8 @@ describe("release.yml", () => {
|
|
|
507
536
|
const clobberIdx = mustIndex(src, "gh release upload");
|
|
508
537
|
expect(src.slice(clobberIdx, clobberIdx + 120)).toContain("--clobber");
|
|
509
538
|
expect(republishBranchIdx).toBeLessThan(clobberIdx);
|
|
539
|
+
expect(src).toContain('elif gh release view "$VERSION"');
|
|
540
|
+
expect(src).toContain("updated before npm publish retry");
|
|
510
541
|
|
|
511
542
|
expect(src).toContain("if: inputs.republish_assets != true");
|
|
512
543
|
expect(src).toContain("if: inputs.republish_assets == true");
|
|
@@ -539,10 +570,22 @@ describe("release.yml", () => {
|
|
|
539
570
|
|
|
540
571
|
test("publishes to npm", () => {
|
|
541
572
|
const src = readText(".github/workflows/release.yml");
|
|
542
|
-
expect(src).toContain("npm publish");
|
|
573
|
+
expect(src).toContain("npm publish --access public --registry https://registry.npmjs.org/");
|
|
543
574
|
expect(src).toContain("NPM_TOKEN");
|
|
544
575
|
});
|
|
545
576
|
|
|
577
|
+
test("bunx-smoke covers windows with bash steps and a runner temp log", () => {
|
|
578
|
+
const src = readText(".github/workflows/release.yml");
|
|
579
|
+
const bunxIdx = mustIndex(src, "bunx-smoke:");
|
|
580
|
+
const bumpIdx = mustIndex(src, "bump-version:");
|
|
581
|
+
const bunxBlock = src.slice(bunxIdx, bumpIdx);
|
|
582
|
+
|
|
583
|
+
expect(bunxBlock).toContain("windows-latest");
|
|
584
|
+
expect(bunxBlock).toContain("shell: bash");
|
|
585
|
+
expect(bunxBlock).toContain("RUNNER_TEMP");
|
|
586
|
+
expect(bunxBlock).not.toContain("mktemp");
|
|
587
|
+
});
|
|
588
|
+
|
|
546
589
|
test("validates DB content before publishing (regression: v0.7.6 shipped 3 pages)", () => {
|
|
547
590
|
const src = readText(".github/workflows/release.yml");
|
|
548
591
|
// Hard guard step that hard-fails the workflow if the built DB is degenerate.
|
|
@@ -551,7 +594,7 @@ describe("release.yml", () => {
|
|
|
551
594
|
const validateIdx = src.indexOf("Validate DB has expected content");
|
|
552
595
|
const buildIdx = src.indexOf("Build release artifacts");
|
|
553
596
|
const releaseIdx = src.indexOf("gh release create");
|
|
554
|
-
const npmIdx = src.indexOf("npm
|
|
597
|
+
const npmIdx = src.indexOf("Publish to npm");
|
|
555
598
|
expect(validateIdx).toBeGreaterThan(0);
|
|
556
599
|
expect(validateIdx).toBeLessThan(buildIdx);
|
|
557
600
|
expect(validateIdx).toBeLessThan(releaseIdx);
|