/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { type z } from "zod"; import { AuthError } from "../../lib/errors.js"; import { SchemaRefreshError } from "../../lib/prime-schema.js"; import { sanitizePaths } from "../../schemas/tool-adapter.js"; /** * Shared adapter for the `sf-gql-*` CLI mirror commands. Each command reads a * JSON blob (positional arg or stdin), validates it against the same Zod schema * the matching MCP tool advertises, calls the same `intent/build*()` function * the MCP tool calls, and emits exactly one JSON line on stdout. This keeps the * CLI a sibling adapter to the MCP server — same args, same output, different * transport. * * Failures emit a JSON error envelope and set `process.exitCode = 1`. We use * `process.exitCode` rather than `process.exit()` so the buffered stdout write * has time to flush before the process ends. */ /** Error codes surfaced in the failure envelope. */ export type MirrorErrorCode = "INVALID_ARGS" | "AUTH_FAILED" | "SCHEMA_PRIME_FAILED" | "INTERNAL"; /** * Best-effort classification of a plain `Error` message into a more specific * code. The intent/auth layers throw plain `Error`s with no type signal, so the * only available hint is the message text. This is a NON-CONTRACT heuristic: a * reworded upstream message can fall through to `INTERNAL`. The verbatim message * is always preserved either way, and the typed `AuthError` / `SchemaRefreshError` * signals (checked before this) take precedence. Patterns mirror the MCP-CLI prior art. */ function classifyErrorMessage(message: string): MirrorErrorCode { if ( /\bauth(?:entication|orization)?\b/i.test(message) || /\bno org\b/i.test(message) || message.includes("~/.sf") ) { return "AUTH_FAILED"; } if (/\b(?:introspect(?:ion)?|priming|schema download)\b/i.test(message)) { return "SCHEMA_PRIME_FAILED"; } return "INTERNAL"; } export interface MirrorErrorEnvelope { error: { code: MirrorErrorCode; message: string; details?: unknown; }; } export interface RunMirrorDeps { /** Read the full JSON payload from stdin. Injectable for tests. */ readStdin?: () => Promise; /** Whether stdin is a TTY. When true and no arg is given, we don't block on stdin. */ isTTY?: boolean; } async function readAllStdin(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk as Buffer); } return Buffer.concat(chunks).toString("utf8"); } function emitError(code: MirrorErrorCode, message: string, details?: unknown): void { const envelope: MirrorErrorEnvelope = { error: details === undefined ? { code, message } : { code, message, details }, }; console.log(JSON.stringify(envelope)); // Not process.exit(1): let the buffered stdout write flush first. process.exitCode = 1; } /** * Run one mirror command end-to-end. * * @param jsonArg Positional JSON argument, or undefined to read from stdin. * @param schema The tool's Zod input schema (shared with the MCP tool). * @param build The intent-layer builder; receives the validated input. * @param deps Injectable stdin reader / TTY flag for testing. */ export async function runMirror( jsonArg: string | undefined, // Input type is `unknown` (a raw parsed-JSON blob), not `T`: `z.preprocess`- // wrapped fields (jsonCoercible, enumStripControlChars) have a Zod input type // of `unknown`, so only the parsed OUTPUT is `T`. See defineMirror in commands.ts. schema: z.ZodType, build: (input: T) => Promise, deps: RunMirrorDeps = {}, ): Promise { // 1. Source the raw JSON: a positional arg wins, except a literal "-" which // is the conventional "read from stdin" sentinel. Otherwise read stdin // (unless TTY, where blocking would hang waiting for a human to type). const isTTY = deps.isTTY ?? process.stdin.isTTY; const wantsStdin = jsonArg === undefined || jsonArg === "-"; let raw: string; if (!wantsStdin) { raw = jsonArg as string; } else if (isTTY) { emitError("INVALID_ARGS", "No input provided. Pass a JSON argument or pipe JSON to stdin."); return; } else { const readStdin = deps.readStdin ?? readAllStdin; raw = await readStdin(); if (!raw.trim()) { emitError("INVALID_ARGS", "No input provided. Pass a JSON argument or pipe JSON to stdin."); return; } } // 2. Parse JSON. let json: unknown; try { json = JSON.parse(raw); } catch (err) { emitError("INVALID_ARGS", `Invalid JSON: ${(err as Error).message}`); return; } // 3. Validate against the shared Zod schema. const result = schema.safeParse(json); if (!result.success) { emitError("INVALID_ARGS", "Input failed schema validation.", result.error.issues); return; } // 4. Build and emit. Classify failures: the typed AuthError / SchemaRefreshError // signals first, then a best-effort message regex (AUTH_FAILED / SCHEMA_PRIME_FAILED), // else INTERNAL. The verbatim message is always preserved. try { const output = await build(result.data); console.log(JSON.stringify(output)); } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err); // Classify on the RAW message (the heuristic keys off auth/schema wording; // sanitizePaths would relativize a "~/.sf" hint to a marker and defeat it). // Typed signals win; otherwise fall back to the best-effort message regex. // AuthError is checked first because its message (W-23335328) reads "Schema // priming failed … expired or unauthorized. Re-authenticate …" — that // "priming" makes classifyErrorMessage return SCHEMA_PRIME_FAILED, so an // untyped fallthrough would misclassify a 401/403 introspection failure as a // schema problem (the exact Schema-vs-Auth confusion W-23335328 fixes). const code: MirrorErrorCode = err instanceof AuthError ? "AUTH_FAILED" : err instanceof SchemaRefreshError ? "SCHEMA_PRIME_FAILED" : classifyErrorMessage(rawMessage); // W-23336442 (N2): emit the SANITIZED message, never the raw one. sanitizePaths // redacts absolute filesystem paths (home dir, repo checkout, schema cache — // W-22697673) AND neutralizes Cc/Cf control chars (bidi/zero-width/tag chars // that JSON.stringify leaves raw), so a caller- or filesystem-derived error // string can neither disclose local layout nor smuggle control chars to the host. const message = sanitizePaths(rawMessage); // The stack is omitted by default so the envelope never leaks internals; // GRAPHITI_DEBUG=1 opts into attaching it under `details` — and even then it is // run through the same sanitizer so the opt-in debug stack cannot leak paths. const details = process.env.GRAPHITI_DEBUG === "1" && err instanceof Error && err.stack ? { stack: sanitizePaths(err.stack) } : undefined; emitError(code, message, details); } }