diff --git a/package-lock.json b/package-lock.json index 6b9f0e81c0..babe0b105f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20914,6 +20914,10 @@ }, "engines": { "node": ">=22.0.0" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" } }, "packages/loopover-miner": { @@ -21031,6 +21035,23 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "packages/loopover-mcp/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/loopover-mcp/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/loopover-mcp/lib/cli-error.d.ts b/packages/loopover-mcp/lib/cli-error.d.ts new file mode 100644 index 0000000000..0450a2b865 --- /dev/null +++ b/packages/loopover-mcp/lib/cli-error.d.ts @@ -0,0 +1,7 @@ +/** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on + * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ +export declare function reportCliFailure(wantsJson: boolean, message: string, exitCode?: number): number; +/** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */ +export declare function argsWantJson(args: Array): boolean; +/** Normalize a thrown value to a safe error string for CLI output. */ +export declare function describeCliError(error: unknown): string; diff --git a/packages/loopover-mcp/lib/cli-error.js b/packages/loopover-mcp/lib/cli-error.js index 0e8d7032c6..99823c92e2 100644 --- a/packages/loopover-mcp/lib/cli-error.js +++ b/packages/loopover-mcp/lib/cli-error.js @@ -1,27 +1,20 @@ /** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ - -/** - * @param {boolean} wantsJson - * @param {string} message - * @param {number} [exitCode] - * @returns {number} - */ export function reportCliFailure(wantsJson, message, exitCode = 2) { - if (wantsJson) { - console.log(JSON.stringify({ ok: false, error: message }, null, 2)); - } else { - console.error(message); - } - return exitCode; + if (wantsJson) { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + } + else { + console.error(message); + } + return exitCode; } - /** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */ export function argsWantJson(args) { - return args.some((arg) => arg === "--json" || arg?.startsWith("--json=")); + return args.some((arg) => arg === "--json" || arg?.startsWith("--json=") === true); } - /** Normalize a thrown value to a safe error string for CLI output. */ export function describeCliError(error) { - return error instanceof Error ? error.message : String(error); + return error instanceof Error ? error.message : String(error); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xpLWVycm9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2xpLWVycm9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO3FHQUNxRztBQUVyRyxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsU0FBa0IsRUFBRSxPQUFlLEVBQUUsUUFBUSxHQUFHLENBQUM7SUFDaEYsSUFBSSxTQUFTLEVBQUUsQ0FBQztRQUNkLE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxFQUFFLEVBQUUsRUFBRSxLQUFLLEVBQUUsS0FBSyxFQUFFLE9BQU8sRUFBRSxFQUFFLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ3RFLENBQUM7U0FBTSxDQUFDO1FBQ04sT0FBTyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQztJQUN6QixDQUFDO0lBQ0QsT0FBTyxRQUFRLENBQUM7QUFDbEIsQ0FBQztBQUVELGlHQUFpRztBQUNqRyxNQUFNLFVBQVUsWUFBWSxDQUFDLElBQXNDO0lBQ2pFLE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsR0FBRyxLQUFLLFFBQVEsSUFBSSxHQUFHLEVBQUUsVUFBVSxDQUFDLFNBQVMsQ0FBQyxLQUFLLElBQUksQ0FBQyxDQUFDO0FBQ3JGLENBQUM7QUFFRCxzRUFBc0U7QUFDdEUsTUFBTSxVQUFVLGdCQUFnQixDQUFDLEtBQWM7SUFDN0MsT0FBTyxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDaEUsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-mcp/lib/cli-error.ts b/packages/loopover-mcp/lib/cli-error.ts new file mode 100644 index 0000000000..d7e7022ecc --- /dev/null +++ b/packages/loopover-mcp/lib/cli-error.ts @@ -0,0 +1,21 @@ +/** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on + * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ + +export function reportCliFailure(wantsJson: boolean, message: string, exitCode = 2): number { + if (wantsJson) { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + } else { + console.error(message); + } + return exitCode; +} + +/** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */ +export function argsWantJson(args: Array): boolean { + return args.some((arg) => arg === "--json" || arg?.startsWith("--json=") === true); +} + +/** Normalize a thrown value to a safe error string for CLI output. */ +export function describeCliError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/loopover-mcp/lib/format-table.d.ts b/packages/loopover-mcp/lib/format-table.d.ts new file mode 100644 index 0000000000..9931d91aee --- /dev/null +++ b/packages/loopover-mcp/lib/format-table.d.ts @@ -0,0 +1,23 @@ +export type FormatTableAlign = "left" | "right"; +export type FormatTableHeader = string | { + key: string; + label?: string; + align?: FormatTableAlign; +}; +export type FormatTableRow = Record | unknown[]; +export type FormatTableInput = FormatTableRow[] | { + headers?: FormatTableHeader[]; + rows?: FormatTableRow[]; +}; +export type FormatTableOptions = { + align?: Record; + gap?: number; +}; +/** + * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). + * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` + * headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`; + * `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies. + * Returns "" when there are no columns. + */ +export declare function formatTable(input: FormatTableInput, opts?: FormatTableOptions): string; diff --git a/packages/loopover-mcp/lib/format-table.js b/packages/loopover-mcp/lib/format-table.js index da589fc771..eefae4390a 100644 --- a/packages/loopover-mcp/lib/format-table.js +++ b/packages/loopover-mcp/lib/format-table.js @@ -1,39 +1,39 @@ // Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands // (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its // CLI/MCP entrypoint on import, so importable helpers live here instead. - // Normalize either an array of row objects or an explicit { headers, rows } shape into a common // { headers, rows } form. For an array of objects the column set is the union of keys in first-seen // order, and each key doubles as its own header label. function normalizeInput(input) { - if (Array.isArray(input)) { - const keys = []; - for (const row of input) { - for (const key of Object.keys(row ?? {})) if (!keys.includes(key)) keys.push(key); + if (Array.isArray(input)) { + const keys = []; + for (const row of input) { + if (!row || Array.isArray(row) || typeof row !== "object") + continue; + for (const key of Object.keys(row)) + if (!keys.includes(key)) + keys.push(key); + } + return { headers: keys.map((key) => ({ key, label: key })), rows: input }; } - return { headers: keys.map((key) => ({ key, label: key })), rows: input }; - } - const headers = (input?.headers ?? []).map((header) => - typeof header === "string" ? { key: header, label: header } : { key: header.key, label: header.label ?? header.key, align: header.align }, - ); - return { headers, rows: input?.rows ?? [] }; + const headers = (input.headers ?? []).map((header) => typeof header === "string" + ? { key: header, label: header } + : { key: header.key, label: header.label ?? header.key, ...(header.align !== undefined ? { align: header.align } : {}) }); + return { headers, rows: input.rows ?? [] }; } - function stringifyCell(value) { - return value === undefined || value === null ? "" : String(value); + return value === undefined || value === null ? "" : String(value); } - // A row is either an object keyed by column key or a positional array; read the matching cell. function readCell(row, header, columnIndex) { - if (Array.isArray(row)) return row[columnIndex]; - return row?.[header.key]; + if (Array.isArray(row)) + return row[columnIndex]; + return row[header.key]; } - function resolveAlign(header, opts) { - const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); - return header.align ?? fromOpts ?? "left"; + const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); + return header.align ?? fromOpts ?? "left"; } - /** * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` @@ -42,17 +42,19 @@ function resolveAlign(header, opts) { * Returns "" when there are no columns. */ export function formatTable(input, opts = {}) { - const { headers, rows } = normalizeInput(input); - if (headers.length === 0) return ""; - const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); - const aligns = headers.map((header) => resolveAlign(header, opts)); - // Precompute every cell's text so column widths and the rendered rows read the same strings. - const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); - const widths = headers.map((header, column) => - Math.max(header.label.length, ...bodyCells.map((cells) => cells[column].length), 0), - ); - const renderRow = (cells) => + const { headers, rows } = normalizeInput(input); + if (headers.length === 0) + return ""; + const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); + const aligns = headers.map((header) => resolveAlign(header, opts)); + // Precompute every cell's text so column widths and the rendered rows read the same strings. + const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); + const widths = headers.map((header, column) => Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]?.length ?? 0), 0)); // Trim trailing padding so a left-aligned final column never emits dangling spaces. - cells.map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column]) : text.padEnd(widths[column]))).join(gap).replace(/\s+$/, ""); - return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); + const renderRow = (cells) => cells + .map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column] ?? 0) : text.padEnd(widths[column] ?? 0))) + .join(gap) + .replace(/\s+$/, ""); + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZm9ybWF0LXRhYmxlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZm9ybWF0LXRhYmxlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGtHQUFrRztBQUNsRyxtR0FBbUc7QUFDbkcseUVBQXlFO0FBNEJ6RSxnR0FBZ0c7QUFDaEcsb0dBQW9HO0FBQ3BHLHVEQUF1RDtBQUN2RCxTQUFTLGNBQWMsQ0FBQyxLQUF1QjtJQUM3QyxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQztRQUN6QixNQUFNLElBQUksR0FBYSxFQUFFLENBQUM7UUFDMUIsS0FBSyxNQUFNLEdBQUcsSUFBSSxLQUFLLEVBQUUsQ0FBQztZQUN4QixJQUFJLENBQUMsR0FBRyxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDLElBQUksT0FBTyxHQUFHLEtBQUssUUFBUTtnQkFBRSxTQUFTO1lBQ3BFLEtBQUssTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUM7Z0JBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDO29CQUFFLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7UUFDOUUsQ0FBQztRQUNELE9BQU8sRUFBRSxPQUFPLEVBQUUsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsQ0FBQyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsR0FBRyxFQUFFLENBQUMsQ0FBQyxFQUFFLElBQUksRUFBRSxLQUFLLEVBQUUsQ0FBQztJQUM1RSxDQUFDO0lBQ0QsTUFBTSxPQUFPLEdBQUcsQ0FBQyxLQUFLLENBQUMsT0FBTyxJQUFJLEVBQUUsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBb0IsRUFBRSxDQUNyRSxPQUFPLE1BQU0sS0FBSyxRQUFRO1FBQ3hCLENBQUMsQ0FBQyxFQUFFLEdBQUcsRUFBRSxNQUFNLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRTtRQUNoQyxDQUFDLENBQUMsRUFBRSxHQUFHLEVBQUUsTUFBTSxDQUFDLEdBQUcsRUFBRSxLQUFLLEVBQUUsTUFBTSxDQUFDLEtBQUssSUFBSSxNQUFNLENBQUMsR0FBRyxFQUFFLEdBQUcsQ0FBQyxNQUFNLENBQUMsS0FBSyxLQUFLLFNBQVMsQ0FBQyxDQUFDLENBQUMsRUFBRSxLQUFLLEVBQUUsTUFBTSxDQUFDLEtBQUssRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUMzSCxDQUFDO0lBQ0YsT0FBTyxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsS0FBSyxDQUFDLElBQUksSUFBSSxFQUFFLEVBQUUsQ0FBQztBQUM3QyxDQUFDO0FBRUQsU0FBUyxhQUFhLENBQUMsS0FBYztJQUNuQyxPQUFPLEtBQUssS0FBSyxTQUFTLElBQUksS0FBSyxLQUFLLElBQUksQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDcEUsQ0FBQztBQUVELCtGQUErRjtBQUMvRixTQUFTLFFBQVEsQ0FBQyxHQUFtQixFQUFFLE1BQXdCLEVBQUUsV0FBbUI7SUFDbEYsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQztRQUFFLE9BQU8sR0FBRyxDQUFDLFdBQVcsQ0FBQyxDQUFDO0lBQ2hELE9BQU8sR0FBRyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQztBQUN6QixDQUFDO0FBRUQsU0FBUyxZQUFZLENBQUMsTUFBd0IsRUFBRSxJQUF3QjtJQUN0RSxNQUFNLFFBQVEsR0FBRyxJQUFJLENBQUMsS0FBSyxJQUFJLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLElBQUksSUFBSSxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQztJQUNwRixPQUFPLE1BQU0sQ0FBQyxLQUFLLElBQUksUUFBUSxJQUFJLE1BQU0sQ0FBQztBQUM1QyxDQUFDO0FBRUQ7Ozs7OztHQU1HO0FBQ0gsTUFBTSxVQUFVLFdBQVcsQ0FBQyxLQUF1QixFQUFFLE9BQTJCLEVBQUU7SUFDaEYsTUFBTSxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsR0FBRyxjQUFjLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDaEQsSUFBSSxPQUFPLENBQUMsTUFBTSxLQUFLLENBQUM7UUFBRSxPQUFPLEVBQUUsQ0FBQztJQUNwQyxNQUFNLEdBQUcsR0FBRyxHQUFHLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxFQUFFLElBQUksQ0FBQyxHQUFHLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUNuRCxNQUFNLE1BQU0sR0FBRyxPQUFPLENBQUMsR0FBRyxDQUFDLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxZQUFZLENBQUMsTUFBTSxFQUFFLElBQUksQ0FBQyxDQUFDLENBQUM7SUFDbkUsNkZBQTZGO0lBQzdGLE1BQU0sU0FBUyxHQUFHLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLEVBQUUsTUFBTSxFQUFFLEVBQUUsQ0FBQyxhQUFhLENBQUMsUUFBUSxDQUFDLEdBQUcsRUFBRSxNQUFNLEVBQUUsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDbkgsTUFBTSxNQUFNLEdBQUcsT0FBTyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBRSxNQUFNLEVBQUUsRUFBRSxDQUM1QyxJQUFJLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsTUFBTSxFQUFFLEdBQUcsU0FBUyxDQUFDLEdBQUcsQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxFQUFFLE1BQU0sSUFBSSxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FDMUYsQ0FBQztJQUNGLG9GQUFvRjtJQUNwRixNQUFNLFNBQVMsR0FBRyxDQUFDLEtBQWUsRUFBRSxFQUFFLENBQ3BDLEtBQUs7U0FDRixHQUFHLENBQUMsQ0FBQyxJQUFJLEVBQUUsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsS0FBSyxPQUFPLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO1NBQzNILElBQUksQ0FBQyxHQUFHLENBQUM7U0FDVCxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDO0lBQ3pCLE9BQU8sQ0FBQyxTQUFTLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsR0FBRyxTQUFTLENBQUMsR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQ3BHLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/format-table.ts b/packages/loopover-mcp/lib/format-table.ts new file mode 100644 index 0000000000..3662965897 --- /dev/null +++ b/packages/loopover-mcp/lib/format-table.ts @@ -0,0 +1,90 @@ +// Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands +// (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its +// CLI/MCP entrypoint on import, so importable helpers live here instead. + +export type FormatTableAlign = "left" | "right"; + +export type FormatTableHeader = + | string + | { + key: string; + label?: string; + align?: FormatTableAlign; + }; + +export type FormatTableRow = Record | unknown[]; + +export type FormatTableInput = + | FormatTableRow[] + | { + headers?: FormatTableHeader[]; + rows?: FormatTableRow[]; + }; + +export type FormatTableOptions = { + align?: Record; + gap?: number; +}; + +type NormalizedHeader = { key: string; label: string; align?: FormatTableAlign }; + +// Normalize either an array of row objects or an explicit { headers, rows } shape into a common +// { headers, rows } form. For an array of objects the column set is the union of keys in first-seen +// order, and each key doubles as its own header label. +function normalizeInput(input: FormatTableInput): { headers: NormalizedHeader[]; rows: FormatTableRow[] } { + if (Array.isArray(input)) { + const keys: string[] = []; + for (const row of input) { + if (!row || Array.isArray(row) || typeof row !== "object") continue; + for (const key of Object.keys(row)) if (!keys.includes(key)) keys.push(key); + } + return { headers: keys.map((key) => ({ key, label: key })), rows: input }; + } + const headers = (input.headers ?? []).map((header): NormalizedHeader => + typeof header === "string" + ? { key: header, label: header } + : { key: header.key, label: header.label ?? header.key, ...(header.align !== undefined ? { align: header.align } : {}) }, + ); + return { headers, rows: input.rows ?? [] }; +} + +function stringifyCell(value: unknown): string { + return value === undefined || value === null ? "" : String(value); +} + +// A row is either an object keyed by column key or a positional array; read the matching cell. +function readCell(row: FormatTableRow, header: NormalizedHeader, columnIndex: number): unknown { + if (Array.isArray(row)) return row[columnIndex]; + return row[header.key]; +} + +function resolveAlign(header: NormalizedHeader, opts: FormatTableOptions): FormatTableAlign { + const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); + return header.align ?? fromOpts ?? "left"; +} + +/** + * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). + * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` + * headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`; + * `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies. + * Returns "" when there are no columns. + */ +export function formatTable(input: FormatTableInput, opts: FormatTableOptions = {}): string { + const { headers, rows } = normalizeInput(input); + if (headers.length === 0) return ""; + const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); + const aligns = headers.map((header) => resolveAlign(header, opts)); + // Precompute every cell's text so column widths and the rendered rows read the same strings. + const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); + const widths = headers.map((header, column) => + Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]?.length ?? 0), 0), + ); + // Trim trailing padding so a left-aligned final column never emits dangling spaces. + const renderRow = (cells: string[]) => + cells + .map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column] ?? 0) : text.padEnd(widths[column] ?? 0))) + .join(gap) + .replace(/\s+$/, ""); + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); +} diff --git a/packages/loopover-mcp/lib/redact-local-path.d.ts b/packages/loopover-mcp/lib/redact-local-path.d.ts new file mode 100644 index 0000000000..12b04fede9 --- /dev/null +++ b/packages/loopover-mcp/lib/redact-local-path.d.ts @@ -0,0 +1,19 @@ +/** + * Redact any absolute or home-anchored local path found in free text, replacing it with the + * `` placeholder. Heuristic (matches an unknown path by shape), so it never needs the + * concrete path in advance — the counterpart to the exact-match `redactKnownLocalPaths` below. + */ +export declare function redactLocalPath(value: unknown): string; +export type RedactKnownLocalPathsOptions = { + tokens?: unknown[]; + paths?: unknown[]; +}; +/** + * Redact KNOWN sensitive strings from free text by exact substring substitution: every entry of + * `tokens` becomes `[redacted]` and every entry of `paths` becomes `[local-path]`. Non-string / + * empty entries are ignored; a token must be non-empty and a path longer than one character (a bare + * `/` is not a "known path"). Paths are applied longest-first so a nested path (e.g. cwd under home) + * is redacted before a shorter prefix would swallow its tail. `undefined`/`null` pass through + * untouched so callers can hand diagnostics straight in. + */ +export declare function redactKnownLocalPaths(value: unknown, { tokens, paths }?: RedactKnownLocalPathsOptions): unknown; diff --git a/packages/loopover-mcp/lib/redact-local-path.js b/packages/loopover-mcp/lib/redact-local-path.js index 00379bd8a4..3684219900 100644 --- a/packages/loopover-mcp/lib/redact-local-path.js +++ b/packages/loopover-mcp/lib/redact-local-path.js @@ -11,31 +11,30 @@ // supplied by the caller, by exact substring substitution → `[redacted]` / // `[local-path]`. It cannot detect an arbitrary path; the heuristic cannot // redact a token it was never told about. Each solves a distinct problem. - /** * Redact any absolute or home-anchored local path found in free text, replacing it with the * `` placeholder. Heuristic (matches an unknown path by shape), so it never needs the * concrete path in advance — the counterpart to the exact-match `redactKnownLocalPaths` below. */ export function redactLocalPath(value) { - const text = String(value ?? ""); - if (!text) return text; - // Both `/g` patterns are rebuilt per call so no `lastIndex` state carries between invocations. - // Delimiter-anchored roots (`~/`, `~\`, `C:\`, `C:/`, `/`) whose interior segments may contain - // spaces, e.g. `/Users/Alice Smith/project` — the anchoring prefix is preserved, only the path swaps. - const pathSegment = "[^\\\\/\\s\"'`,;)\\]]+(?:\\s+[^\\\\/\\s\"'`,;)\\]]+)*(?=[\\\\/])"; - const pathTail = "[^\\\\/\\s\"'`,;)\\]]+"; - // Prefix delimiters a real path can immediately follow in pasted stack-trace/validation-output text. - // `(` is the Node.js stack-frame shape (`at fn (/abs/path:10:5)`); `[` and `:` cover the same "no space - // before the path" shape in bracketed log lines and colon-joined messages (e.g. `path:/abs/path`). - const rootedPath = new RegExp(`(^|[\\s"'\\\`=(\\[:])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); - return text - .replace(rootedPath, (_, prefix) => `${prefix}`) - // Home/Windows roots that appear mid-token with no leading delimiter (so the anchored pass skips - // them); run second so it only mops up what the anchored, space-aware pass could not claim. - .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, ""); + const text = String(value ?? ""); + if (!text) + return text; + // Both `/g` patterns are rebuilt per call so no `lastIndex` state carries between invocations. + // Delimiter-anchored roots (`~/`, `~\`, `C:\`, `C:/`, `/`) whose interior segments may contain + // spaces, e.g. `/Users/Alice Smith/project` — the anchoring prefix is preserved, only the path swaps. + const pathSegment = "[^\\\\/\\s\"'`,;)\\]]+(?:\\s+[^\\\\/\\s\"'`,;)\\]]+)*(?=[\\\\/])"; + const pathTail = "[^\\\\/\\s\"'`,;)\\]]+"; + // Prefix delimiters a real path can immediately follow in pasted stack-trace/validation-output text. + // `(` is the Node.js stack-frame shape (`at fn (/abs/path:10:5)`); `[` and `:` cover the same "no space + // before the path" shape in bracketed log lines and colon-joined messages (e.g. `path:/abs/path`). + const rootedPath = new RegExp(`(^|[\\s"'\\\`=(\\[:])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); + return text + .replace(rootedPath, (_, prefix) => `${prefix}`) + // Home/Windows roots that appear mid-token with no leading delimiter (so the anchored pass skips + // them); run second so it only mops up what the anchored, space-aware pass could not claim. + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, ""); } - /** * Redact KNOWN sensitive strings from free text by exact substring substitution: every entry of * `tokens` becomes `[redacted]` and every entry of `paths` becomes `[local-path]`. Non-string / @@ -45,14 +44,17 @@ export function redactLocalPath(value) { * untouched so callers can hand diagnostics straight in. */ export function redactKnownLocalPaths(value, { tokens = [], paths = [] } = {}) { - if (value === undefined || value === null) return value; - let text = String(value); - for (const token of tokens) { - if (typeof token === "string" && token.length > 0) text = text.split(token).join("[redacted]"); - } - const knownPaths = paths.filter((candidate) => typeof candidate === "string" && candidate.length > 1); - for (const localPath of knownPaths.sort((left, right) => right.length - left.length)) { - text = text.split(localPath).join("[local-path]"); - } - return text; + if (value === undefined || value === null) + return value; + let text = String(value); + for (const token of tokens) { + if (typeof token === "string" && token.length > 0) + text = text.split(token).join("[redacted]"); + } + const knownPaths = paths.filter((candidate) => typeof candidate === "string" && candidate.length > 1); + for (const localPath of knownPaths.sort((left, right) => right.length - left.length)) { + text = text.split(localPath).join("[local-path]"); + } + return text; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVkYWN0LWxvY2FsLXBhdGguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJyZWRhY3QtbG9jYWwtcGF0aC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxpR0FBaUc7QUFDakcsK0ZBQStGO0FBQy9GLG1HQUFtRztBQUNuRyx1RkFBdUY7QUFDdkYsRUFBRTtBQUNGLGtHQUFrRztBQUNsRyw4QkFBOEI7QUFDOUIsc0dBQXNHO0FBQ3RHLHVHQUF1RztBQUN2RyxzR0FBc0c7QUFDdEcsc0dBQXNHO0FBQ3RHLHNHQUFzRztBQUN0RyxxR0FBcUc7QUFFckc7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQUMsS0FBYztJQUM1QyxNQUFNLElBQUksR0FBRyxNQUFNLENBQUMsS0FBSyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ2pDLElBQUksQ0FBQyxJQUFJO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDdkIsK0ZBQStGO0lBQy9GLCtGQUErRjtJQUMvRixzR0FBc0c7SUFDdEcsTUFBTSxXQUFXLEdBQUcsa0VBQWtFLENBQUM7SUFDdkYsTUFBTSxRQUFRLEdBQUcsd0JBQXdCLENBQUM7SUFDMUMscUdBQXFHO0lBQ3JHLHdHQUF3RztJQUN4RyxtR0FBbUc7SUFDbkcsTUFBTSxVQUFVLEdBQUcsSUFBSSxNQUFNLENBQUMsMkRBQTJELFdBQVcsWUFBWSxRQUFRLEdBQUcsRUFBRSxHQUFHLENBQUMsQ0FBQztJQUNsSSxPQUFPLElBQUk7U0FDUixPQUFPLENBQUMsVUFBVSxFQUFFLENBQUMsQ0FBQyxFQUFFLE1BQWMsRUFBRSxFQUFFLENBQUMsR0FBRyxNQUFNLGNBQWMsQ0FBQztRQUNwRSxpR0FBaUc7UUFDakcsNEZBQTRGO1NBQzNGLE9BQU8sQ0FBQyxrQ0FBa0MsRUFBRSxjQUFjLENBQUMsQ0FBQztBQUNqRSxDQUFDO0FBT0Q7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxxQkFBcUIsQ0FDbkMsS0FBYyxFQUNkLEVBQUUsTUFBTSxHQUFHLEVBQUUsRUFBRSxLQUFLLEdBQUcsRUFBRSxLQUFtQyxFQUFFO0lBRTlELElBQUksS0FBSyxLQUFLLFNBQVMsSUFBSSxLQUFLLEtBQUssSUFBSTtRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3hELElBQUksSUFBSSxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUN6QixLQUFLLE1BQU0sS0FBSyxJQUFJLE1BQU0sRUFBRSxDQUFDO1FBQzNCLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxNQUFNLEdBQUcsQ0FBQztZQUFFLElBQUksR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQyxZQUFZLENBQUMsQ0FBQztJQUNqRyxDQUFDO0lBQ0QsTUFBTSxVQUFVLEdBQUcsS0FBSyxDQUFDLE1BQU0sQ0FBQyxDQUFDLFNBQVMsRUFBdUIsRUFBRSxDQUFDLE9BQU8sU0FBUyxLQUFLLFFBQVEsSUFBSSxTQUFTLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQyxDQUFDO0lBQzNILEtBQUssTUFBTSxTQUFTLElBQUksVUFBVSxDQUFDLElBQUksQ0FBQyxDQUFDLElBQUksRUFBRSxLQUFLLEVBQUUsRUFBRSxDQUFDLEtBQUssQ0FBQyxNQUFNLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7UUFDckYsSUFBSSxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsU0FBUyxDQUFDLENBQUMsSUFBSSxDQUFDLGNBQWMsQ0FBQyxDQUFDO0lBQ3BELENBQUM7SUFDRCxPQUFPLElBQUksQ0FBQztBQUNkLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/redact-local-path.ts b/packages/loopover-mcp/lib/redact-local-path.ts new file mode 100644 index 0000000000..01eccb7e5d --- /dev/null +++ b/packages/loopover-mcp/lib/redact-local-path.ts @@ -0,0 +1,66 @@ +// #6264: the one shared local-filesystem-path redactor for the MCP CLI. Three call sites used to +// carry their own copy of this logic (`redactLocalPath` here, `redactLocalValidationPaths` and +// `sanitizeDiagnosticText` in bin/loopover-mcp.js), so a single redaction fix had to be made — and +// kept in sync — three times. They are consolidated here so a future fix happens once. +// +// Two genuinely different mechanisms are needed, so both stay available as named functions rather +// than being forced into one: +// - `redactLocalPath` DETECTS an unknown absolute/home path in free text via a regex heuristic +// (stack traces, scorer stderr, pasted validation output) → ``. +// - `redactKnownLocalPaths` redacts KNOWN sensitive strings (session tokens, config dirs, cwd/home) +// supplied by the caller, by exact substring substitution → `[redacted]` / +// `[local-path]`. It cannot detect an arbitrary path; the heuristic cannot +// redact a token it was never told about. Each solves a distinct problem. + +/** + * Redact any absolute or home-anchored local path found in free text, replacing it with the + * `` placeholder. Heuristic (matches an unknown path by shape), so it never needs the + * concrete path in advance — the counterpart to the exact-match `redactKnownLocalPaths` below. + */ +export function redactLocalPath(value: unknown): string { + const text = String(value ?? ""); + if (!text) return text; + // Both `/g` patterns are rebuilt per call so no `lastIndex` state carries between invocations. + // Delimiter-anchored roots (`~/`, `~\`, `C:\`, `C:/`, `/`) whose interior segments may contain + // spaces, e.g. `/Users/Alice Smith/project` — the anchoring prefix is preserved, only the path swaps. + const pathSegment = "[^\\\\/\\s\"'`,;)\\]]+(?:\\s+[^\\\\/\\s\"'`,;)\\]]+)*(?=[\\\\/])"; + const pathTail = "[^\\\\/\\s\"'`,;)\\]]+"; + // Prefix delimiters a real path can immediately follow in pasted stack-trace/validation-output text. + // `(` is the Node.js stack-frame shape (`at fn (/abs/path:10:5)`); `[` and `:` cover the same "no space + // before the path" shape in bracketed log lines and colon-joined messages (e.g. `path:/abs/path`). + const rootedPath = new RegExp(`(^|[\\s"'\\\`=(\\[:])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); + return text + .replace(rootedPath, (_, prefix: string) => `${prefix}`) + // Home/Windows roots that appear mid-token with no leading delimiter (so the anchored pass skips + // them); run second so it only mops up what the anchored, space-aware pass could not claim. + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, ""); +} + +export type RedactKnownLocalPathsOptions = { + tokens?: unknown[]; + paths?: unknown[]; +}; + +/** + * Redact KNOWN sensitive strings from free text by exact substring substitution: every entry of + * `tokens` becomes `[redacted]` and every entry of `paths` becomes `[local-path]`. Non-string / + * empty entries are ignored; a token must be non-empty and a path longer than one character (a bare + * `/` is not a "known path"). Paths are applied longest-first so a nested path (e.g. cwd under home) + * is redacted before a shorter prefix would swallow its tail. `undefined`/`null` pass through + * untouched so callers can hand diagnostics straight in. + */ +export function redactKnownLocalPaths( + value: unknown, + { tokens = [], paths = [] }: RedactKnownLocalPathsOptions = {}, +): unknown { + if (value === undefined || value === null) return value; + let text = String(value); + for (const token of tokens) { + if (typeof token === "string" && token.length > 0) text = text.split(token).join("[redacted]"); + } + const knownPaths = paths.filter((candidate): candidate is string => typeof candidate === "string" && candidate.length > 1); + for (const localPath of knownPaths.sort((left, right) => right.length - left.length)) { + text = text.split(localPath).join("[local-path]"); + } + return text; +} diff --git a/packages/loopover-mcp/lib/telemetry.d.ts b/packages/loopover-mcp/lib/telemetry.d.ts new file mode 100644 index 0000000000..0f70e2706f --- /dev/null +++ b/packages/loopover-mcp/lib/telemetry.d.ts @@ -0,0 +1,15 @@ +export type RecordMcpToolCallOptions = { + telemetryEnabled?: boolean; +}; +export type RecordMcpToolCallEvent = { + tool: string; + callerType?: "local"; + ok: boolean; + durationMs: number; +}; +/** + * Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly + * `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND + * LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws. + */ +export declare function recordMcpToolCall(options: RecordMcpToolCallOptions | null | undefined, event: RecordMcpToolCallEvent): void; diff --git a/packages/loopover-mcp/lib/telemetry.js b/packages/loopover-mcp/lib/telemetry.js index 435b1eb395..e89cdca7e4 100644 --- a/packages/loopover-mcp/lib/telemetry.js +++ b/packages/loopover-mcp/lib/telemetry.js @@ -1,5 +1,4 @@ import { PostHog } from "posthog-node"; - // Local MCP telemetry wrapper (#6236, mirrors the remote wrapper from #6235). Same allowlisted event shape // and PostHog vendor as src/mcp/telemetry.ts, so the two servers report consistent data -- the only real // difference is the trust posture: this CLI runs on a user's own machine, so it is gated on an EXPLICIT, @@ -11,58 +10,52 @@ import { PostHog } from "posthog-node"; // this records nothing and behaves byte-identically to before this module existed -- true for every user // who has not run `loopover-mcp telemetry enable` (the default). It also never throws: a PostHog init/ // capture failure degrades to recording nothing, so it can never affect the CLI's actual command behavior. - /** PostHog US-cloud ingestion host -- the default when LOOPOVER_MCP_POSTHOG_HOST isn't set. */ const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; - /** The PostHog event name every MCP tool call is recorded under (matches the remote wrapper, #6235). */ const MCP_TOOL_CALL_EVENT = "mcp_tool_call"; - /** Anonymous, constant distinct id: this fleet telemetry carries NO per-actor identity by design (#6228), * so every event shares one handle and there is no per-user person to build up. */ const MCP_TELEMETRY_DISTINCT_ID = "loopover-mcp"; - /** * Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly * `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND * LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws. - * - * @param {{ telemetryEnabled: boolean }} options - * @param {{ tool: string, callerType?: "local", ok: boolean, durationMs: number }} event */ export function recordMcpToolCall(options, event) { - // Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an - // API key alone is not enough; the user must have explicitly enabled telemetry. - if (options?.telemetryEnabled !== true) return; - - const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY); - // Unconfigured -> record nothing, byte-identical to before this module existed. - if (!apiKey) return; - - const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; - try { - const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); - client.capture({ - distinctId: MCP_TELEMETRY_DISTINCT_ID, - event: MCP_TOOL_CALL_EVENT, - // Exactly the #6228 allowlist -- nothing more. - properties: { - tool: event.tool, - caller_type: event.callerType ?? "local", - ok: event.ok, - duration_ms: event.durationMs, - }, - // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. - disableGeoip: true, - }); - } catch { - // Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure - // degrades to recording nothing, identical to the unconfigured path above. - } + // Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an + // API key alone is not enough; the user must have explicitly enabled telemetry. + if (options?.telemetryEnabled !== true) + return; + const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY); + // Unconfigured -> record nothing, byte-identical to before this module existed. + if (!apiKey) + return; + const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; + try { + const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); + client.capture({ + distinctId: MCP_TELEMETRY_DISTINCT_ID, + event: MCP_TOOL_CALL_EVENT, + // Exactly the #6228 allowlist -- nothing more. + properties: { + tool: event.tool, + caller_type: event.callerType ?? "local", + ok: event.ok, + duration_ms: event.durationMs, + }, + // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. + disableGeoip: true, + }); + } + catch { + // Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure + // degrades to recording nothing, identical to the unconfigured path above. + } } - /** Trim a possibly-undefined env string, treating blank/whitespace as absent. */ function trimmedOrUndefined(value) { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVsZW1ldHJ5LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsidGVsZW1ldHJ5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxPQUFPLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFFdkMsMkdBQTJHO0FBQzNHLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcseUdBQXlHO0FBQ3pHLDJHQUEyRztBQUMzRyxnR0FBZ0c7QUFDaEcsRUFBRTtBQUNGLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcsdUdBQXVHO0FBQ3ZHLDJHQUEyRztBQUUzRywrRkFBK0Y7QUFDL0YsTUFBTSxvQkFBb0IsR0FBRywwQkFBMEIsQ0FBQztBQUV4RCx3R0FBd0c7QUFDeEcsTUFBTSxtQkFBbUIsR0FBRyxlQUFlLENBQUM7QUFFNUM7b0ZBQ29GO0FBQ3BGLE1BQU0seUJBQXlCLEdBQUcsY0FBYyxDQUFDO0FBYWpEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUsaUJBQWlCLENBQy9CLE9BQW9ELEVBQ3BELEtBQTZCO0lBRTdCLHdHQUF3RztJQUN4RyxnRkFBZ0Y7SUFDaEYsSUFBSSxPQUFPLEVBQUUsZ0JBQWdCLEtBQUssSUFBSTtRQUFFLE9BQU87SUFFL0MsTUFBTSxNQUFNLEdBQUcsa0JBQWtCLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyw0QkFBNEIsQ0FBQyxDQUFDO0lBQzVFLGdGQUFnRjtJQUNoRixJQUFJLENBQUMsTUFBTTtRQUFFLE9BQU87SUFFcEIsTUFBTSxJQUFJLEdBQUcsa0JBQWtCLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyx5QkFBeUIsQ0FBQyxJQUFJLG9CQUFvQixDQUFDO0lBQy9GLElBQUksQ0FBQztRQUNILE1BQU0sTUFBTSxHQUFHLElBQUksT0FBTyxDQUFDLE1BQU0sRUFBRSxFQUFFLElBQUksRUFBRSxPQUFPLEVBQUUsQ0FBQyxFQUFFLGFBQWEsRUFBRSxDQUFDLEVBQUUsQ0FBQyxDQUFDO1FBQzNFLE1BQU0sQ0FBQyxPQUFPLENBQUM7WUFDYixVQUFVLEVBQUUseUJBQXlCO1lBQ3JDLEtBQUssRUFBRSxtQkFBbUI7WUFDMUIsK0NBQStDO1lBQy9DLFVBQVUsRUFBRTtnQkFDVixJQUFJLEVBQUUsS0FBSyxDQUFDLElBQUk7Z0JBQ2hCLFdBQVcsRUFBRSxLQUFLLENBQUMsVUFBVSxJQUFJLE9BQU87Z0JBQ3hDLEVBQUUsRUFBRSxLQUFLLENBQUMsRUFBRTtnQkFDWixXQUFXLEVBQUUsS0FBSyxDQUFDLFVBQVU7YUFDOUI7WUFDRCwyRkFBMkY7WUFDM0YsWUFBWSxFQUFFLElBQUk7U0FDbkIsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLG1HQUFtRztRQUNuRywyRUFBMkU7SUFDN0UsQ0FBQztBQUNILENBQUM7QUFFRCxpRkFBaUY7QUFDakYsU0FBUyxrQkFBa0IsQ0FBQyxLQUF5QjtJQUNuRCxNQUFNLE9BQU8sR0FBRyxLQUFLLEVBQUUsSUFBSSxFQUFFLENBQUM7SUFDOUIsT0FBTyxPQUFPLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsU0FBUyxDQUFDO0FBQ3ZDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/telemetry.ts b/packages/loopover-mcp/lib/telemetry.ts new file mode 100644 index 0000000000..6aee758f72 --- /dev/null +++ b/packages/loopover-mcp/lib/telemetry.ts @@ -0,0 +1,79 @@ +import { PostHog } from "posthog-node"; + +// Local MCP telemetry wrapper (#6236, mirrors the remote wrapper from #6235). Same allowlisted event shape +// and PostHog vendor as src/mcp/telemetry.ts, so the two servers report consistent data -- the only real +// difference is the trust posture: this CLI runs on a user's own machine, so it is gated on an EXPLICIT, +// persisted opt-in flag rather than mere env-var presence. This module stays a pure helper like its lib/ +// siblings (cli-error.js, format-table.js, ...) -- it never reads the CLI's config file itself. The caller +// (bin/loopover-mcp.js) resolves `telemetryEnabled` from the persisted config and passes it in. +// +// SAFE NO-OP: unless the caller passes `telemetryEnabled: true` AND LOOPOVER_MCP_POSTHOG_API_KEY is set, +// this records nothing and behaves byte-identically to before this module existed -- true for every user +// who has not run `loopover-mcp telemetry enable` (the default). It also never throws: a PostHog init/ +// capture failure degrades to recording nothing, so it can never affect the CLI's actual command behavior. + +/** PostHog US-cloud ingestion host -- the default when LOOPOVER_MCP_POSTHOG_HOST isn't set. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +/** The PostHog event name every MCP tool call is recorded under (matches the remote wrapper, #6235). */ +const MCP_TOOL_CALL_EVENT = "mcp_tool_call"; + +/** Anonymous, constant distinct id: this fleet telemetry carries NO per-actor identity by design (#6228), + * so every event shares one handle and there is no per-user person to build up. */ +const MCP_TELEMETRY_DISTINCT_ID = "loopover-mcp"; + +export type RecordMcpToolCallOptions = { + telemetryEnabled?: boolean; +}; + +export type RecordMcpToolCallEvent = { + tool: string; + callerType?: "local"; + ok: boolean; + durationMs: number; +}; + +/** + * Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly + * `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND + * LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws. + */ +export function recordMcpToolCall( + options: RecordMcpToolCallOptions | null | undefined, + event: RecordMcpToolCallEvent, +): void { + // Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an + // API key alone is not enough; the user must have explicitly enabled telemetry. + if (options?.telemetryEnabled !== true) return; + + const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY); + // Unconfigured -> record nothing, byte-identical to before this module existed. + if (!apiKey) return; + + const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; + try { + const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); + client.capture({ + distinctId: MCP_TELEMETRY_DISTINCT_ID, + event: MCP_TOOL_CALL_EVENT, + // Exactly the #6228 allowlist -- nothing more. + properties: { + tool: event.tool, + caller_type: event.callerType ?? "local", + ok: event.ok, + duration_ms: event.durationMs, + }, + // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. + disableGeoip: true, + }); + } catch { + // Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure + // degrades to recording nothing, identical to the unconfigured path above. + } +} + +/** Trim a possibly-undefined env string, treating blank/whitespace as absent. */ +function trimmedOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/loopover-mcp/package.json b/packages/loopover-mcp/package.json index 66a8c24db2..9b77ab9d11 100644 --- a/packages/loopover-mcp/package.json +++ b/packages/loopover-mcp/package.json @@ -32,10 +32,17 @@ "bin", "lib", "scripts", - "CHANGELOG.md" + "CHANGELOG.md", + "!bin/**/*.ts", + "!lib/**/*.ts", + "!scripts/check-syntax.mjs", + "bin/**/*.d.ts", + "lib/**/*.d.ts" ], "scripts": { - "build": "node --check bin/loopover-mcp.js && node --check lib/cli-error.js && node --check lib/local-branch.js && node --check lib/format-table.js && node --check lib/redact-local-path.js && node --check lib/telemetry.js && node --check scripts/gittensor-score-preview.mjs" + "build": "npm run build:tsc && npm run build:verify", + "build:tsc": "tsc -p tsconfig.json", + "build:verify": "node scripts/check-syntax.mjs" }, "dependencies": { "@loopover/engine": "^3.4.0", @@ -43,6 +50,10 @@ "posthog-node": "^5.44.0", "zod": "^4.4.3" }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" + }, "engines": { "node": ">=22.0.0" } diff --git a/packages/loopover-mcp/scripts/check-syntax.mjs b/packages/loopover-mcp/scripts/check-syntax.mjs new file mode 100644 index 0000000000..2cd2657d3b --- /dev/null +++ b/packages/loopover-mcp/scripts/check-syntax.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Syntax-verifies every compiled/hand-written .js file in bin/ and lib/ via `node --check`. Replaces a +// previously hand-listed chain of individual `node --check ` commands in package.json's own +// "build" script -- that list had to be kept in sync by hand every time a file was added, removed, or +// migrated to TypeScript (#7291 / #7328). Glob-driven instead: covers every .js file in bin/lib +// automatically, migrated or not, with no list to fall out of date. +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +function listJsFiles(dir) { + return readdirSync(join(ROOT, dir), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) + .map((entry) => join(dir, entry.name)); +} + +const files = [...listJsFiles("bin"), ...listJsFiles("lib")].sort(); + +const failures = []; +for (const file of files) { + try { + execFileSync(process.execPath, ["--check", file], { cwd: ROOT, stdio: "pipe" }); + } catch (error) { + failures.push({ file, message: error.stderr?.toString().trim() || String(error) }); + } +} + +if (failures.length > 0) { + for (const { file, message } of failures) { + console.error(`${file}:\n${message}\n`); + } + console.error(`node --check failed for ${failures.length} of ${files.length} file(s).`); + process.exit(1); +} + +console.log(`node --check passed for all ${files.length} files in bin/ and lib/.`); diff --git a/packages/loopover-mcp/tsconfig.json b/packages/loopover-mcp/tsconfig.json new file mode 100644 index 0000000000..9d795cfa5d --- /dev/null +++ b/packages/loopover-mcp/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "declaration": true, + // Inline (not a separate .js.map) so the compiled output stays exactly the same file set the + // package has always shipped -- no new file type to teach the npm-pack allowlist about. Vitest's + // v8 coverage provider still remaps through it fine, attributing coverage to the .ts source. + "inlineSourceMap": true, + "noEmit": false, + // Overrides the root config's "dist" so each converted file compiles in place next to its .ts + // source (e.g. lib/foo.ts -> lib/foo.js) -- the package's published bin/lib layout never changes + // as more files convert, so no consumer (in this repo or published) ever needs a different import + // path depending on a given file's migration status. + "rootDir": ".", + "outDir": ".", + // Without this, the inherited root value ("./.tsbuildinfo") resolves relative to the ROOT config's + // location, not this one -- both packages would then read/write the exact same cache file at the + // repo root and corrupt each other's incremental state. + "tsBuildInfoFile": "./.tsbuildinfo" + }, + // Only files already converted to real TypeScript are included -- everything else in bin/lib stays + // plain .js until its own migration PR lands (#7291). No edits needed here as later phases convert + // more files: the glob picks them up automatically. + "include": ["bin/**/*.ts", "lib/**/*.ts"], + // Without this, tsc's default exclude list (which always adds outDir) resolves to "." -- the whole + // package root -- and silently excludes every include match, since outDir is "." for in-place emit. + "exclude": [] +} diff --git a/scripts/mcp-package-allowlist.mjs b/scripts/mcp-package-allowlist.mjs index c0bf12162d..30c798e9a2 100644 --- a/scripts/mcp-package-allowlist.mjs +++ b/scripts/mcp-package-allowlist.mjs @@ -4,11 +4,12 @@ export const MCP_PACKAGE_ALLOWED_FILE_PATTERNS = [ /^bin\/loopover-mcp\.js$/, - /^lib\/cli-error\.js$/, - /^lib\/local-branch\.js$/, - /^lib\/format-table\.js$/, - /^lib\/redact-local-path\.js$/, - /^lib\/telemetry\.js$/, + // Compiled in-place TypeScript emit ships sibling .d.ts next to each converted lib/*.js (#7328 / #7329). + /^lib\/cli-error\.(js|d\.ts)$/, + /^lib\/local-branch\.(js|d\.ts)$/, + /^lib\/format-table\.(js|d\.ts)$/, + /^lib\/redact-local-path\.(js|d\.ts)$/, + /^lib\/telemetry\.(js|d\.ts)$/, /^scripts\/gittensor-score-preview\.(mjs|py)$/, /^package\.json$/, /^README\.md$/, diff --git a/test/unit/format-table.test.ts b/test/unit/format-table.test.ts index cfcc1f78b4..b547863b33 100644 --- a/test/unit/format-table.test.ts +++ b/test/unit/format-table.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; // The helper ships in the MCP package's lib/ (the bin auto-runs on import, so it cannot be imported); // mirror the local-branch.test.ts pattern of dynamically importing the packaged .js module. async function loadFormatTable() { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. return (await import("../../packages/loopover-mcp/lib/format-table.js")).formatTable; } diff --git a/test/unit/mcp-cli-error.test.ts b/test/unit/mcp-cli-error.test.ts new file mode 100644 index 0000000000..39448dfc04 --- /dev/null +++ b/test/unit/mcp-cli-error.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadCliError() { + return await import("../../packages/loopover-mcp/lib/cli-error.js"); +} + +describe("mcp cli-error helpers (#7328)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("argsWantJson detects --json and --json= forms", async () => { + const { argsWantJson } = await loadCliError(); + expect(argsWantJson([])).toBe(false); + expect(argsWantJson(["--help"])).toBe(false); + expect(argsWantJson(["--json"])).toBe(true); + expect(argsWantJson(["--json=true"])).toBe(true); + expect(argsWantJson([undefined, "--json"])).toBe(true); + }); + + it("describeCliError prefers Error.message and stringifies other throws", async () => { + const { describeCliError } = await loadCliError(); + expect(describeCliError(new Error("boom"))).toBe("boom"); + expect(describeCliError("plain")).toBe("plain"); + expect(describeCliError(42)).toBe("42"); + }); + + it("reportCliFailure emits JSON on stdout when wantsJson is true", async () => { + const { reportCliFailure } = await loadCliError(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(reportCliFailure(true, "nope", 3)).toBe(3); + expect(log).toHaveBeenCalledWith(JSON.stringify({ ok: false, error: "nope" }, null, 2)); + expect(err).not.toHaveBeenCalled(); + }); + + it("reportCliFailure writes plain text to stderr otherwise (default exit 2)", async () => { + const { reportCliFailure } = await loadCliError(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(reportCliFailure(false, "plain failure")).toBe(2); + expect(err).toHaveBeenCalledWith("plain failure"); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/mcp-local-telemetry.test.ts b/test/unit/mcp-local-telemetry.test.ts index bf3779e164..5eb1149c89 100644 --- a/test/unit/mcp-local-telemetry.test.ts +++ b/test/unit/mcp-local-telemetry.test.ts @@ -22,7 +22,6 @@ vi.mock("posthog-node", () => ({ }, })); -// @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { recordMcpToolCall } = await import("../../packages/loopover-mcp/lib/telemetry.js"); type LocalToolCallEvent = { tool: string; callerType?: "local"; ok: boolean; durationMs: number }; diff --git a/test/unit/mcp-package-skeleton.test.ts b/test/unit/mcp-package-skeleton.test.ts new file mode 100644 index 0000000000..2150b7f987 --- /dev/null +++ b/test/unit/mcp-package-skeleton.test.ts @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const mcpRoot = join(process.cwd(), "packages/loopover-mcp"); + +type McpPackageJson = { + name: string; + scripts: { build: string; "build:tsc": string; "build:verify": string }; + files: string[]; +}; + +describe("loopover-mcp TypeScript build pipeline (#7328)", () => { + it("exposes a real tsc build split like loopover-miner", () => { + const pkg = JSON.parse(readFileSync(join(mcpRoot, "package.json"), "utf8")) as McpPackageJson; + expect(pkg.name).toBe("@loopover/mcp"); + expect(pkg.scripts.build).toBe("npm run build:tsc && npm run build:verify"); + expect(pkg.scripts["build:tsc"]).toBe("tsc -p tsconfig.json"); + expect(pkg.scripts["build:verify"]).toBe("node scripts/check-syntax.mjs"); + expect(pkg.files).toEqual( + expect.arrayContaining(["bin", "lib", "!bin/**/*.ts", "!lib/**/*.ts", "lib/**/*.d.ts"]), + ); + }); + + it("build:verify's syntax check covers bin and lib .js files", () => { + const result = spawnSync("node", ["scripts/check-syntax.mjs"], { cwd: mcpRoot, encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/node --check passed for all \d+ files in bin\/ and lib\//); + }); +}); diff --git a/test/unit/redact-local-path.test.ts b/test/unit/redact-local-path.test.ts index 6b9ef3601c..1d7b6af1fb 100644 --- a/test/unit/redact-local-path.test.ts +++ b/test/unit/redact-local-path.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; // #6264: the three former MCP redaction call sites now share packages/loopover-mcp/lib/redact-local-path.js. // This is the single home for the redaction contract, so it is tested once here; the call-site tests // (local-scorer-adapter.test.ts, mcp-cli-packets.test.ts) still assert the wired-up behavior end to end. -// @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { redactLocalPath, redactKnownLocalPaths } = await import("../../packages/loopover-mcp/lib/redact-local-path.js"); describe("redactLocalPath (heuristic: detect an unknown path in free text)", () => { diff --git a/turbo.json b/turbo.json index f1e2a25e99..19faa8da86 100644 --- a/turbo.json +++ b/turbo.json @@ -159,10 +159,19 @@ "@loopover/miner-extension#build": { "cache": false }, - "@loopover/mcp#build": { + "@loopover/mcp#build:tsc": { "dependsOn": ["^build"], + "cache": false + }, + "@loopover/mcp#build:verify": { + "dependsOn": ["@loopover/mcp#build:tsc"], + "inputs": ["bin/**/*.js", "lib/**/*.js"], "outputs": [] }, + "@loopover/mcp#build": { + "dependsOn": ["@loopover/mcp#build:tsc", "@loopover/mcp#build:verify"], + "cache": false + }, "@loopover/miner#build:tsc": { "dependsOn": ["^build"], "cache": false diff --git a/vitest.config.ts b/vitest.config.ts index dc1f7f12ea..43c3ff6a45 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -49,12 +49,9 @@ export default defineConfig({ "packages/loopover-miner/bin/**/*.js", "packages/loopover-miner/bin/**/*.ts", "packages/discovery-index/src/**/*.ts", - // packages/loopover-mcp/lib/*.js are plain JS today; issue #7291 migrates them to real TypeScript, - // keeping the same .js/.ts/.d.ts triplet shape packages/loopover-miner/lib/** already has (so - // coverage is wired BEFORE that PR lands, not as a follow-up fix). 4 of the 5 files - // (format-table/local-branch/redact-local-path/telemetry) are already imported in-process by - // test/unit/*.test.ts; lib/cli-error.js currently has no in-process test at all, so it will need - // one before a PR touching it can pass codecov/patch -- that's intended enforcement, not a bug. + // packages/loopover-mcp/lib/* migrate to real TypeScript under #7291; coverage is remapped via + // inline sourcemaps from the compiled .js back to .ts. cli-error gained an in-process unit test + // in test/unit/mcp-cli-error.test.ts so codecov/patch can grade its Phase-1 conversion. "packages/loopover-mcp/lib/**/*.js", "packages/loopover-mcp/lib/**/*.ts", // packages/loopover-mcp/bin/loopover-mcp.js (~6,600 of ~7,400 lines in the package) is tested