* feat(cli,tui): normalize --catalog / --config with web; update --help (#1347) Bring the CLI and TUI in line with the web catalog vs read-only session model introduced in #1481/#1483, and close the outstanding --help item from #1347. Core (core/mcp/node/config.ts): - Add `catalogPath` to ServerConfigOptions (writable catalog slot). - `resolveServerSource()` picks the active source: `--catalog` (writable, seed-if-missing) wins over `--config` (read-only, error-if-missing). - Writable sources seed an empty `{"mcpServers":{}}` on first run (matches the web backend) instead of erroring; read-only `--config` still errors. - `serverSourceConflict()` shared conflict matrix (mirrors run-web): --catalog/--config mutually exclusive; neither combines with an ad-hoc target. - `readServerListFile()` public loader for runners (TUI) needing the raw map. - Rename `withDefaultConfigPath` → `withDefaultCatalogPath`; the no-flag default now fills the writable catalog slot, not --config. CLI / TUI: - Add `--catalog <path>` (+ MCP_CATALOG_PATH) and re-document `--config` as a read-only session in --help. - Enforce the shared conflict matrix before resolution. Full writable persistence/CRUD threading remains deferred to #1482 / #1432 (CLI/TUI are read consumers today; the split surfaces as seed- vs error-on-missing). Tests: core resolution matrix (source/conflict/seed/read-only), CLI catalog + conflict integration tests, TUI loadTuiServers unit tests. READMEs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(launcher): add smoke:cli + smoke:tui e2e launcher smokes (#1347) The launcher smokes only checked `--help` for --cli/--tui; nothing exercised the launcher → CLI/TUI → core path on the built artifact (only smoke:web did, for web). Add two end-to-end smokes wired into validate:launcher between smoke:launcher and smoke:web: - smoke:cli (scripts/smoke-cli.mjs): runs `--cli` against the bundled stdio test server via a temp --catalog and asserts tools/list returns the tools, plus the #1347 resolution paths — default-catalog seed-on-missing, read-only --config error-without-seed, and the --catalog/--config conflict. - smoke:tui (scripts/smoke-tui.mjs): launches `--tui --catalog <temp>` and asserts the Ink app renders its first frame ("MCP Servers") within a timeout, then SIGTERMs it (shallow boot/render check). Both build test-servers/build on demand if missing. Docs updated (AGENTS.md validate description, launcher README). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: address review on #1499 (comment accuracy, CLI/TUI flag parity) Review feedback from @claude on PR #1499 (all non-blocking): - Align CLI catalogPath resolution with the TUI: `options.catalog?.trim() || process.env.MCP_CATALOG_PATH` so an explicit empty `--catalog ""` falls back to MCP_CATALOG_PATH identically in both front-ends (CLI used `??`, which only guards undefined/null). - Clarify the `serverSourceConflict` comment: it mirrors only the source-selection portion of web's matrix and deliberately omits web's `--header` rejection, since CLI/TUI merge --header into per-server settings. - Note the deliberate --header broadcast semantics in loadTuiServers. - Share an EMPTY_CATALOG_CONTENT constant between seedEmptyCatalog and readServerListContent instead of two separate literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): trim --config to match --catalog and the TUI (#1499 review) Cosmetic uniformity follow-up from the re-review: configPath now uses `options.config?.trim() || undefined`, matching the catalog slot above it and the TUI. Behavior is unchanged (resolveServerSource/serverSourceConflict already trim downstream). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
3.9 KiB
JavaScript
134 lines
3.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Boot smoke test for the prod TUI launcher path (#1347).
|
|
*
|
|
* `npm run smoke:launcher` only checks `--tui --help`; it never actually starts
|
|
* the Ink app, so the launcher → TUI → core path and `--catalog` loading went
|
|
* unverified by the launcher smokes. This script launches the built launcher in
|
|
* `--tui` mode against a temp `--catalog`, waits for the app to render its first
|
|
* frame (the "MCP Servers" panel) within a timeout, then sends SIGTERM and
|
|
* exits.
|
|
*
|
|
* It asserts the TUI *boots and renders* without crashing — not full
|
|
* interaction (driving an Ink UI deterministically in CI is flaky, so this is
|
|
* intentionally a shallow render check). Exits non-zero on a crash-before-render
|
|
* or a render timeout.
|
|
*
|
|
* Expects `clients/launcher/build` and `clients/tui/build` to be built first
|
|
* (the validate / CI ordering guarantees this). The bundled stdio test server
|
|
* (`test-servers/build`) is built on demand here if missing.
|
|
*/
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
const repoRoot = resolve(import.meta.dirname, "..");
|
|
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
|
|
const testServer = join(
|
|
repoRoot,
|
|
"test-servers",
|
|
"build",
|
|
"test-server-stdio.js",
|
|
);
|
|
const RENDER_MARKER = "MCP Servers";
|
|
const TIMEOUT_MS = Number(process.env.SMOKE_TUI_TIMEOUT_MS ?? 15000);
|
|
|
|
function fail(message) {
|
|
console.error(`smoke:tui FAILED — ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function ensureTestServer() {
|
|
if (existsSync(testServer)) return;
|
|
console.log("smoke:tui — building test-servers (missing build output)...");
|
|
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
|
|
cwd: repoRoot,
|
|
stdio: "inherit",
|
|
});
|
|
if (r.status !== 0 || !existsSync(testServer)) {
|
|
fail(
|
|
"could not build the stdio test server (test-servers/build/test-server-stdio.js). " +
|
|
"Run `npm run test-servers:build` from clients/cli.",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!existsSync(launcher)) {
|
|
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
|
|
}
|
|
ensureTestServer();
|
|
|
|
const work = mkdtempSync(join(tmpdir(), "smoke-tui-"));
|
|
const catalogPath = join(work, "catalog.json");
|
|
writeFileSync(
|
|
catalogPath,
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
test: { type: "stdio", command: process.execPath, args: [testServer] },
|
|
},
|
|
}),
|
|
);
|
|
|
|
const child = spawn(
|
|
process.execPath,
|
|
[launcher, "--tui", "--catalog", catalogPath],
|
|
{
|
|
cwd: repoRoot,
|
|
// Redirect HOME so the TUI's storage never touches the real ~/.mcp-inspector.
|
|
env: { ...process.env, HOME: work, USERPROFILE: work },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
|
|
let output = "";
|
|
let settled = false;
|
|
|
|
function cleanup() {
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|
|
|
|
function done(code, message) {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (!child.killed) child.kill("SIGTERM");
|
|
cleanup();
|
|
if (code === 0) {
|
|
console.log(`smoke:tui OK — ${message}`);
|
|
} else {
|
|
console.error(`smoke:tui FAILED — ${message}`);
|
|
}
|
|
process.exit(code);
|
|
}
|
|
|
|
function onData(chunk) {
|
|
output += chunk.toString();
|
|
if (output.includes(RENDER_MARKER)) {
|
|
done(0, `rendered "${RENDER_MARKER}" panel from --catalog within timeout`);
|
|
}
|
|
}
|
|
|
|
child.stdout.on("data", onData);
|
|
child.stderr.on("data", onData);
|
|
|
|
child.on("exit", (code) => {
|
|
if (settled) return;
|
|
// Exiting before the render marker appeared is a failure (crash on boot).
|
|
done(
|
|
1,
|
|
`TUI exited (code ${code}) before rendering "${RENDER_MARKER}"\n${output.slice(0, 800)}`,
|
|
);
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
done(1, `failed to spawn TUI: ${err.message}`);
|
|
});
|
|
|
|
const timer = setTimeout(() => {
|
|
done(
|
|
1,
|
|
`TUI did not render "${RENDER_MARKER}" within ${TIMEOUT_MS}ms\n${output.slice(0, 800)}`,
|
|
);
|
|
}, TIMEOUT_MS);
|