diff --git a/package-lock.json b/package-lock.json index 6b9f0e81c0..d3b97749ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20912,10 +20912,31 @@ "bin": { "loopover-mcp": "bin/loopover-mcp.js" }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" + }, "engines": { "node": ">=22.0.0" } }, + "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" + }, "packages/loopover-miner": { "name": "@loopover/miner", "version": "3.4.0", diff --git a/packages/loopover-mcp/lib/cli-error.js b/packages/loopover-mcp/lib/cli-error.js index 0e8d7032c6..f46d466701 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=")); } - /** 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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xpLWVycm9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2xpLWVycm9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO3FHQUNxRztBQUVyRyxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsU0FBa0IsRUFBRSxPQUFlLEVBQUUsUUFBUSxHQUFHLENBQUM7SUFDaEYsSUFBSSxTQUFTLEVBQUUsQ0FBQztRQUNkLE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxFQUFFLEVBQUUsRUFBRSxLQUFLLEVBQUUsS0FBSyxFQUFFLE9BQU8sRUFBRSxFQUFFLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ3RFLENBQUM7U0FBTSxDQUFDO1FBQ04sT0FBTyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQztJQUN6QixDQUFDO0lBQ0QsT0FBTyxRQUFRLENBQUM7QUFDbEIsQ0FBQztBQUVELGlHQUFpRztBQUNqRyxNQUFNLFVBQVUsWUFBWSxDQUFDLElBQXVCO0lBQ2xELE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsR0FBRyxLQUFLLFFBQVEsSUFBSSxHQUFHLEVBQUUsVUFBVSxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUM7QUFDNUUsQ0FBQztBQUVELHNFQUFzRTtBQUN0RSxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsS0FBYztJQUM3QyxPQUFPLEtBQUssWUFBWSxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztBQUNoRSxDQUFDIn0= \ 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..6ee1ff290e --- /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: readonly string[]): boolean { + return args.some((arg) => arg === "--json" || arg?.startsWith("--json=")); +} + +/** 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.js b/packages/loopover-mcp/lib/format-table.js index da589fc771..d46a4ebf52 100644 --- a/packages/loopover-mcp/lib/format-table.js +++ b/packages/loopover-mcp/lib/format-table.js @@ -1,39 +1,35 @@ // 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) { + 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, 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 +38,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)))); + // Every `cells`/`widths` array here has exactly `headers.length` entries (built via headers.map), so + // indexing by a column index drawn from that same range is always in bounds. + const widths = headers.map((header, column) => Math.max(header.label.length, ...bodyCells.map((cells) => cells[column].length), 0)); + const renderRow = (cells) => // 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"); + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZm9ybWF0LXRhYmxlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZm9ybWF0LXRhYmxlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGtHQUFrRztBQUNsRyxtR0FBbUc7QUFDbkcseUVBQXlFO0FBV3pFLGdHQUFnRztBQUNoRyxvR0FBb0c7QUFDcEcsdURBQXVEO0FBQ3ZELFNBQVMsY0FBYyxDQUFDLEtBQW9DO0lBQzFELElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDO1FBQ3pCLE1BQU0sSUFBSSxHQUFhLEVBQUUsQ0FBQztRQUMxQixLQUFLLE1BQU0sR0FBRyxJQUFJLEtBQUssRUFBRSxDQUFDO1lBQ3hCLEtBQUssTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQyxHQUFHLElBQUksRUFBRSxDQUFDO2dCQUFFLElBQUksQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLEdBQUcsQ0FBQztvQkFBRSxJQUFJLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDO1FBQ3BGLENBQUM7UUFDRCxPQUFPLEVBQUUsT0FBTyxFQUFFLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDLENBQUMsRUFBRSxHQUFHLEVBQUUsS0FBSyxFQUFFLEdBQUcsRUFBRSxDQUFDLENBQUMsRUFBRSxJQUFJLEVBQUUsS0FBSyxFQUFFLENBQUM7SUFDNUUsQ0FBQztJQUNELE1BQU0sT0FBTyxHQUFHLENBQUMsS0FBSyxFQUFFLE9BQU8sSUFBSSxFQUFFLENBQUMsQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUNwRCxPQUFPLE1BQU0sS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLEVBQUUsR0FBRyxFQUFFLE1BQU0sRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsR0FBRyxFQUFFLE1BQU0sQ0FBQyxHQUFHLEVBQUUsS0FBSyxFQUFFLE1BQU0sQ0FBQyxLQUFLLElBQUksTUFBTSxDQUFDLEdBQUcsRUFBRSxLQUFLLEVBQUUsTUFBTSxDQUFDLEtBQUssRUFBRSxDQUMxSSxDQUFDO0lBQ0YsT0FBTyxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsS0FBSyxFQUFFLElBQUksSUFBSSxFQUFFLEVBQUUsQ0FBQztBQUM5QyxDQUFDO0FBRUQsU0FBUyxhQUFhLENBQUMsS0FBYztJQUNuQyxPQUFPLEtBQUssS0FBSyxTQUFTLElBQUksS0FBSyxLQUFLLElBQUksQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDcEUsQ0FBQztBQUVELCtGQUErRjtBQUMvRixTQUFTLFFBQVEsQ0FBQyxHQUFhLEVBQUUsTUFBd0IsRUFBRSxXQUFtQjtJQUM1RSxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDO1FBQUUsT0FBTyxHQUFHLENBQUMsV0FBVyxDQUFDLENBQUM7SUFDaEQsT0FBUSxHQUFrQyxFQUFFLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDO0FBQzNELENBQUM7QUFFRCxTQUFTLFlBQVksQ0FBQyxNQUF3QixFQUFFLElBQXdCO0lBQ3RFLE1BQU0sUUFBUSxHQUFHLElBQUksQ0FBQyxLQUFLLElBQUksQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsSUFBSSxJQUFJLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDO0lBQ3BGLE9BQU8sTUFBTSxDQUFDLEtBQUssSUFBSSxRQUFRLElBQUksTUFBTSxDQUFDO0FBQzVDLENBQUM7QUFFRDs7Ozs7O0dBTUc7QUFDSCxNQUFNLFVBQVUsV0FBVyxDQUFDLEtBQXlCLEVBQUUsT0FBMkIsRUFBRTtJQUNsRixNQUFNLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxHQUFHLGNBQWMsQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUNoRCxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sRUFBRSxDQUFDO0lBQ3BDLE1BQU0sR0FBRyxHQUFHLEdBQUcsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEVBQUUsSUFBSSxDQUFDLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ25ELE1BQU0sTUFBTSxHQUFHLE9BQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUFDLFlBQVksQ0FBQyxNQUFNLEVBQUUsSUFBSSxDQUFDLENBQUMsQ0FBQztJQUNuRSw2RkFBNkY7SUFDN0YsTUFBTSxTQUFTLEdBQUcsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBRSxNQUFNLEVBQUUsRUFBRSxDQUFDLGFBQWEsQ0FBQyxRQUFRLENBQUMsR0FBRyxFQUFFLE1BQU0sRUFBRSxNQUFNLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUNuSCxxR0FBcUc7SUFDckcsNkVBQTZFO0lBQzdFLE1BQU0sTUFBTSxHQUFHLE9BQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLEVBQUUsTUFBTSxFQUFFLEVBQUUsQ0FDNUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLE1BQU0sRUFBRSxHQUFHLFNBQVMsQ0FBQyxHQUFHLENBQUMsQ0FBQyxLQUFLLEVBQUUsRUFBRSxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUUsQ0FBQyxNQUFNLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FDckYsQ0FBQztJQUNGLE1BQU0sU0FBUyxHQUFHLENBQUMsS0FBZSxFQUFFLEVBQUU7SUFDcEMsb0ZBQW9GO0lBQ3BGLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQyxJQUFJLEVBQUUsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsS0FBSyxPQUFPLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDO0lBQzFKLE9BQU8sQ0FBQyxTQUFTLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsR0FBRyxTQUFTLENBQUMsR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQ3BHLENBQUMifQ== \ 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..46a82b0ca9 --- /dev/null +++ b/packages/loopover-mcp/lib/format-table.ts @@ -0,0 +1,69 @@ +// 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 TableHeader = { key: string; label?: string; align?: "left" | "right" }; +export type TableRowObject = Record; +export type TableRow = TableRowObject | unknown[] | null | undefined; +export type TableInput = TableRow[] | { headers?: (string | TableHeader)[]; rows?: TableRow[] }; +export type FormatTableOptions = { align?: Record; gap?: number }; + +type NormalizedHeader = { key: string; label: string; align?: "left" | "right" | undefined }; +type Normalized = { headers: NormalizedHeader[]; rows: TableRow[] }; + +// 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: TableInput | undefined | null): Normalized { + if (Array.isArray(input)) { + const keys: string[] = []; + for (const row of input) { + 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) => + typeof header === "string" ? { key: header, label: header } : { key: header.key, label: header.label ?? header.key, 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: TableRow, header: NormalizedHeader, columnIndex: number): unknown { + if (Array.isArray(row)) return row[columnIndex]; + return (row as TableRowObject | undefined)?.[header.key]; +} + +function resolveAlign(header: NormalizedHeader, opts: FormatTableOptions): "left" | "right" { + 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?: TableInput | null, 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)))); + // Every `cells`/`widths` array here has exactly `headers.length` entries (built via headers.map), so + // indexing by a column index drawn from that same range is always in bounds. + const widths = headers.map((header, column) => + Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]!.length), 0), + ); + const renderRow = (cells: string[]) => + // 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"); +} diff --git a/packages/loopover-mcp/lib/redact-local-path.js b/packages/loopover-mcp/lib/redact-local-path.js index 00379bd8a4..2d1ea4d2fb 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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVkYWN0LWxvY2FsLXBhdGguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJyZWRhY3QtbG9jYWwtcGF0aC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxpR0FBaUc7QUFDakcsK0ZBQStGO0FBQy9GLG1HQUFtRztBQUNuRyx1RkFBdUY7QUFDdkYsRUFBRTtBQUNGLGtHQUFrRztBQUNsRyw4QkFBOEI7QUFDOUIsc0dBQXNHO0FBQ3RHLHVHQUF1RztBQUN2RyxzR0FBc0c7QUFDdEcsc0dBQXNHO0FBQ3RHLHNHQUFzRztBQUN0RyxxR0FBcUc7QUFFckc7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQUMsS0FBYztJQUM1QyxNQUFNLElBQUksR0FBRyxNQUFNLENBQUMsS0FBSyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ2pDLElBQUksQ0FBQyxJQUFJO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDdkIsK0ZBQStGO0lBQy9GLCtGQUErRjtJQUMvRixzR0FBc0c7SUFDdEcsTUFBTSxXQUFXLEdBQUcsa0VBQWtFLENBQUM7SUFDdkYsTUFBTSxRQUFRLEdBQUcsd0JBQXdCLENBQUM7SUFDMUMscUdBQXFHO0lBQ3JHLHdHQUF3RztJQUN4RyxtR0FBbUc7SUFDbkcsTUFBTSxVQUFVLEdBQUcsSUFBSSxNQUFNLENBQUMsMkRBQTJELFdBQVcsWUFBWSxRQUFRLEdBQUcsRUFBRSxHQUFHLENBQUMsQ0FBQztJQUNsSSxPQUFPLElBQUk7U0FDUixPQUFPLENBQUMsVUFBVSxFQUFFLENBQUMsQ0FBQyxFQUFFLE1BQWMsRUFBRSxFQUFFLENBQUMsR0FBRyxNQUFNLGNBQWMsQ0FBQztRQUNwRSxpR0FBaUc7UUFDakcsNEZBQTRGO1NBQzNGLE9BQU8sQ0FBQyxrQ0FBa0MsRUFBRSxjQUFjLENBQUMsQ0FBQztBQUNqRSxDQUFDO0FBSUQ7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxxQkFBcUIsQ0FDbkMsS0FBYyxFQUNkLEVBQUUsTUFBTSxHQUFHLEVBQUUsRUFBRSxLQUFLLEdBQUcsRUFBRSxLQUFtQyxFQUFFO0lBRTlELElBQUksS0FBSyxLQUFLLFNBQVMsSUFBSSxLQUFLLEtBQUssSUFBSTtRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3hELElBQUksSUFBSSxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUN6QixLQUFLLE1BQU0sS0FBSyxJQUFJLE1BQU0sRUFBRSxDQUFDO1FBQzNCLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxNQUFNLEdBQUcsQ0FBQztZQUFFLElBQUksR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQyxZQUFZLENBQUMsQ0FBQztJQUNqRyxDQUFDO0lBQ0QsTUFBTSxVQUFVLEdBQUcsS0FBSyxDQUFDLE1BQU0sQ0FBQyxDQUFDLFNBQVMsRUFBdUIsRUFBRSxDQUFDLE9BQU8sU0FBUyxLQUFLLFFBQVEsSUFBSSxTQUFTLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQyxDQUFDO0lBQzNILEtBQUssTUFBTSxTQUFTLElBQUksVUFBVSxDQUFDLElBQUksQ0FBQyxDQUFDLElBQUksRUFBRSxLQUFLLEVBQUUsRUFBRSxDQUFDLEtBQUssQ0FBQyxNQUFNLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7UUFDckYsSUFBSSxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsU0FBUyxDQUFDLENBQUMsSUFBSSxDQUFDLGNBQWMsQ0FBQyxDQUFDO0lBQ3BELENBQUM7SUFDRCxPQUFPLElBQUksQ0FBQztBQUNkLENBQUMifQ== \ 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..094df96d10 --- /dev/null +++ b/packages/loopover-mcp/lib/redact-local-path.ts @@ -0,0 +1,63 @@ +// #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 = {}, +): string | null | undefined { + 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.js b/packages/loopover-mcp/lib/telemetry.js index 435b1eb395..25152e3695 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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVsZW1ldHJ5LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsidGVsZW1ldHJ5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxPQUFPLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFFdkMsMkdBQTJHO0FBQzNHLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcseUdBQXlHO0FBQ3pHLDJHQUEyRztBQUMzRyxnR0FBZ0c7QUFDaEcsRUFBRTtBQUNGLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcsdUdBQXVHO0FBQ3ZHLDJHQUEyRztBQUUzRywrRkFBK0Y7QUFDL0YsTUFBTSxvQkFBb0IsR0FBRywwQkFBMEIsQ0FBQztBQUV4RCx3R0FBd0c7QUFDeEcsTUFBTSxtQkFBbUIsR0FBRyxlQUFlLENBQUM7QUFFNUM7b0ZBQ29GO0FBQ3BGLE1BQU0seUJBQXlCLEdBQUcsY0FBYyxDQUFDO0FBS2pEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUsaUJBQWlCLENBQUMsT0FBaUMsRUFBRSxLQUF1QjtJQUMxRix3R0FBd0c7SUFDeEcsZ0ZBQWdGO0lBQ2hGLElBQUksT0FBTyxFQUFFLGdCQUFnQixLQUFLLElBQUk7UUFBRSxPQUFPO0lBRS9DLE1BQU0sTUFBTSxHQUFHLGtCQUFrQixDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsNEJBQTRCLENBQUMsQ0FBQztJQUM1RSxnRkFBZ0Y7SUFDaEYsSUFBSSxDQUFDLE1BQU07UUFBRSxPQUFPO0lBRXBCLE1BQU0sSUFBSSxHQUFHLGtCQUFrQixDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMseUJBQXlCLENBQUMsSUFBSSxvQkFBb0IsQ0FBQztJQUMvRixJQUFJLENBQUM7UUFDSCxNQUFNLE1BQU0sR0FBRyxJQUFJLE9BQU8sQ0FBQyxNQUFNLEVBQUUsRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLENBQUMsRUFBRSxhQUFhLEVBQUUsQ0FBQyxFQUFFLENBQUMsQ0FBQztRQUMzRSxNQUFNLENBQUMsT0FBTyxDQUFDO1lBQ2IsVUFBVSxFQUFFLHlCQUF5QjtZQUNyQyxLQUFLLEVBQUUsbUJBQW1CO1lBQzFCLCtDQUErQztZQUMvQyxVQUFVLEVBQUU7Z0JBQ1YsSUFBSSxFQUFFLEtBQUssQ0FBQyxJQUFJO2dCQUNoQixXQUFXLEVBQUUsS0FBSyxDQUFDLFVBQVUsSUFBSSxPQUFPO2dCQUN4QyxFQUFFLEVBQUUsS0FBSyxDQUFDLEVBQUU7Z0JBQ1osV0FBVyxFQUFFLEtBQUssQ0FBQyxVQUFVO2FBQzlCO1lBQ0QsMkZBQTJGO1lBQzNGLFlBQVksRUFBRSxJQUFJO1NBQ25CLENBQUMsQ0FBQztJQUNMLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxtR0FBbUc7UUFDbkcsMkVBQTJFO0lBQzdFLENBQUM7QUFDSCxDQUFDO0FBRUQsaUZBQWlGO0FBQ2pGLFNBQVMsa0JBQWtCLENBQUMsS0FBeUI7SUFDbkQsTUFBTSxPQUFPLEdBQUcsS0FBSyxFQUFFLElBQUksRUFBRSxDQUFDO0lBQzlCLE9BQU8sT0FBTyxDQUFDLENBQUMsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQztBQUN2QyxDQUFDIn0= \ 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..122805dce9 --- /dev/null +++ b/packages/loopover-mcp/lib/telemetry.ts @@ -0,0 +1,68 @@ +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 McpToolCallEvent = { 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, event: McpToolCallEvent): 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..49b58d93c9 100644 --- a/packages/loopover-mcp/package.json +++ b/packages/loopover-mcp/package.json @@ -32,10 +32,14 @@ "bin", "lib", "scripts", - "CHANGELOG.md" + "CHANGELOG.md", + "!lib/**/*.ts", + "!scripts/check-syntax.mjs" ], "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 +47,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..a752c85646 --- /dev/null +++ b/packages/loopover-mcp/scripts/check-syntax.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Syntax-verifies every compiled/hand-written .js file in bin/ and lib/, plus every .mjs script, 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 (#7290's mcp counterpart, #7291). Glob-driven instead: covers +// every file 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"; + +const ROOT = new URL("..", import.meta.url).pathname; + +function listFiles(dir, extension) { + return readdirSync(join(ROOT, dir), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => join(dir, entry.name)); +} + +const files = [...listFiles("bin", ".js"), ...listFiles("lib", ".js"), ...listFiles("scripts", ".mjs")].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/, lib/, and scripts/.`); diff --git a/packages/loopover-mcp/tsconfig.json b/packages/loopover-mcp/tsconfig.json new file mode 100644 index 0000000000..3345a971fd --- /dev/null +++ b/packages/loopover-mcp/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + // No pre-existing hand-written .d.ts files to replace here (unlike loopover-miner's migration) -- + // this phase proves the build pipeline without also introducing a new published artifact type, so + // the compiled output stays exactly the same .js file set the package has always shipped. + "declaration": false, + // 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, hand-maintained .js until its own migration phase 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/test/unit/format-table.test.ts b/test/unit/format-table.test.ts index cfcc1f78b4..31938f7d77 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; } @@ -58,4 +57,36 @@ describe("formatTable", () => { expect(formatTable([])).toBe(""); expect(formatTable({ headers: [], rows: [{ a: 1 }] })).toBe(""); }); + + it("returns an empty string for an omitted or nullish input, without throwing", async () => { + const formatTable = await loadFormatTable(); + expect(formatTable()).toBe(""); + expect(formatTable(null)).toBe(""); + }); + + it("tolerates a nullish row in an array-of-row-objects input", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable([{ a: "one" }, null, { a: "two" }]); + expect(table.split("\n")).toEqual(["a", "one", "", "two"]); + }); + + it("falls back to the key as the label for an object header with no explicit label", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable({ headers: [{ key: "score" }], rows: [{ score: 5 }] }); + expect(table.split("\n")).toEqual(["score", "5"]); + }); + + it("resolves alignment from opts.align by key, falling back to label when the key misses", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable( + { + headers: [{ key: "n", label: "Name" }, { key: "c", label: "Count" }], + rows: [{ n: "a", c: 1 }], + }, + { align: { n: "right", Count: "right" } }, + ); + // Both columns align right: "n" resolves via opts.align's key entry, "c" only matches by its + // label ("Count") since opts.align has no "c" key entry -- exercising the key-miss/label-hit fallback. + expect(table.split("\n")).toEqual(["Name Count", " a 1"]); + }); }); diff --git a/test/unit/mcp-cli-error.test.ts b/test/unit/mcp-cli-error.test.ts new file mode 100644 index 0000000000..9819935419 --- /dev/null +++ b/test/unit/mcp-cli-error.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { argsWantJson, describeCliError, reportCliFailure } from "../../packages/loopover-mcp/lib/cli-error.js"; + +describe("mcp cli-error (#5928)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reportCliFailure logs plain text to stderr when --json is absent", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(reportCliFailure(false, "bad args")).toBe(2); + expect(err).toHaveBeenCalledWith("bad args"); + expect(log).not.toHaveBeenCalled(); + }); + + it("reportCliFailure emits parseable JSON on stdout when --json is set, honoring a custom exit code", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(reportCliFailure(true, "bad args", 1)).toBe(1); + expect(log).toHaveBeenCalledWith(JSON.stringify({ ok: false, error: "bad args" }, null, 2)); + expect(err).not.toHaveBeenCalled(); + }); + + it("defaults the exit code to 2 when omitted", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(reportCliFailure(false, "bad args")).toBe(2); + }); + + it("argsWantJson detects --json and --json=... in argv", () => { + expect(argsWantJson(["discover", "acme/widgets", "--json"])).toBe(true); + expect(argsWantJson(["discover", "--json=pretty"])).toBe(true); + expect(argsWantJson(["discover", "acme/widgets"])).toBe(false); + expect(argsWantJson([])).toBe(false); + }); + + it("describeCliError normalizes thrown values", () => { + expect(describeCliError(new Error("boom"))).toBe("boom"); + expect(describeCliError("plain")).toBe("plain"); + expect(describeCliError(42)).toBe("42"); + }); +}); 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/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)", () => {