UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

1,334 lines • 61.5 kB
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync, } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { resolveEffectiveComfyUIBase } from "./workspace-env.js";
import { assertSafeRepoName, nonInteractiveGitEnv, } from "./node-management.js";
import { ComfyUIError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
// ---------------------------------------------------------------------------
// Path-jailed live custom-node dev tools.
//
// Port of filliptm/ComfyUI_FL-MCP's coding_tools.py (read/search/write/patch/
// git, hard-jailed to custom_nodes/ with bounded output) onto our stack, plus
// Windows symlink/junction/ADS safety and the seam-injected deps pattern used
// by node-authoring.ts. LOCAL-ONLY: every tool needs config.comfyuiPath.
//
// Design + rationale: design/node-dev-tools.md.
// ---------------------------------------------------------------------------
export class NodeDevError extends ComfyUIError {
    constructor(message, details) {
        super(message, "NODE_DEV_ERROR", details);
        this.name = "NodeDevError";
    }
}
/**
 * Refusal returned when a git write (commit/push) is attempted while the
 * COMFYUI_MCP_ALLOW_GIT_WRITES flag is off. Structured so an agent can
 * self-correct (see design/node-dev-tools.md). The gates framework is
 * deferred to ROADMAP Theme G; this narrow flag is what Theme G will absorb.
 */
export class GitWritesDisabledError extends ComfyUIError {
    constructor(action) {
        super(`node_pack action:"git" with git_action:"${action}" is disabled by configuration. Set the ` +
            `environment variable COMFYUI_MCP_ALLOW_GIT_WRITES=1 (or "true") to ` +
            `allow git commit/push from this server, then retry. Read-only actions ` +
            `(status/diff/log) are always available.`, "DISABLED_BY_CONFIG");
        this.name = "GitWritesDisabledError";
    }
    toToolResult() {
        return {
            isError: true,
            content: [
                {
                    type: "text",
                    text: JSON.stringify({
                        error: "DISABLED_BY_CONFIG",
                        disabled_by_config: true,
                        required_flag: "COMFYUI_MCP_ALLOW_GIT_WRITES=1",
                        message: this.message,
                    }),
                },
            ],
        };
    }
}
// ---------------------------------------------------------------------------
// FL-MCP output-bounding constants (its proven values).
// ---------------------------------------------------------------------------
export const READ_DEFAULT_LINES = 240;
export const READ_MAX_LINES = 800;
export const READ_DEFAULT_CHARS = 12_000;
export const READ_MAX_CHARS = 24_000;
/** Long lines are chunked at this width so a single minified line can't blow the budget. */
export const LONG_LINE_CHUNK = 1_000;
/** Per-match line cap for search results. */
export const SEARCH_LINE_MAX = 600;
/**
 * #2418 — what ripgrep is allowed to PRINT for one line, before we ever see it.
 *
 * `--max-count` is per FILE, so a single minified one-line workflow JSON under
 * custom_nodes can put its whole self on stdout: measured 33 MB of output from one
 * 42 MB file, which overflows spawnSync's 32 MB maxBuffer and fails the whole search
 * with ENOBUFS. SEARCH_LINE_MAX cannot help — it is applied by clipMatchLine AFTER
 * spawnSync has already returned, which is far too late.
 *
 * Set to SEARCH_LINE_MAX so ripgrep prints a preview no larger than what we display.
 * With `--max-columns-preview` it emits that many columns plus its own
 * ` [... omitted end of long line]` marker, so the field still arrives LONGER than
 * SEARCH_LINE_MAX and clipMatchLine still marks it — the #809 disclosure survives.
 */
export const SEARCH_MAX_COLUMNS = SEARCH_LINE_MAX;
/** ripgrep's own elision suffix under `--max-columns-preview` (#2418). */
export const RIPGREP_LONG_LINE_MARKER = " [... omitted end of long line]";
/** Bound on any subprocess (git / patch) stdout+stderr surfaced to the caller. */
export const CMD_OUTPUT_MAX = 12_000;
/**
 * #809 (codex gate): the FLOOR for any `max_chars`, mirroring get_workflow (action:"query")'s
 * own 500.
 * A budget of 1 cannot hold the sentence that explains why the output was cut, so it
 * used to produce either an unexplained empty field or a marker that breached the very
 * bound it described. Neither is honest. This raises a FLOOR, not a cap — the ceiling is
 * untouched — and it is the smallest value at which the tool can still answer "why is
 * this empty?".
 */
export const MIN_OUTPUT_CHARS = 500;
/**
 * #809 (codex gate): "raise `X` up to N" is ITSELF a dead retry when the caller is
 * already at N. Telling someone at max_results=100 to raise it to 100 wastes exactly the
 * round trip this issue exists to prevent, and teaches the same wrong lesson ("the tool
 * can't do this"). At the ceiling the remedy must switch to what is actually left.
 */
export function raiseOrCeiling(param, inForce, ceiling) {
    return inForce >= ceiling
        ? `\`${param}\` is already at its ceiling of ${ceiling}, so raising it is not an option`
        : `raise \`${param}\` up to ${ceiling}`;
}
export const LIST_DEFAULT_ENTRIES = 500;
export const LIST_MAX_ENTRIES = 2_000;
export const SEARCH_DEFAULT_RESULTS = 50;
export const SEARCH_MAX_RESULTS = 100;
/** Skip files larger than this in the builtin search walker. */
const SEARCH_MAX_FILE_BYTES = 1024 * 1024;
/** Hard cap on files the builtin walker will open in one search. */
const SEARCH_MAX_SCANNED_FILES = 20_000;
const GIT_TIMEOUT_MS = 60_000;
const GIT_PUSH_TIMEOUT_MS = 180_000;
const SKIP_DIRS = new Set([".git", "__pycache__", "node_modules"]);
function defaultSpawn(cmd, args, opts) {
    const res = spawnSync(cmd, args, {
        cwd: opts.cwd,
        encoding: "utf-8",
        timeout: opts.timeoutMs,
        input: opts.input,
        env: opts.env,
        maxBuffer: 32 * 1024 * 1024,
        windowsHide: true,
    });
    if (res.error) {
        const e = res.error;
        if (e.code === "ENOENT") {
            throw new NodeDevError(`"${cmd}" was not found on PATH. Install ${cmd} to use this operation.`);
        }
        throw new NodeDevError(`Failed to execute ${cmd}: ${e.message}`);
    }
    return {
        status: res.status,
        stdout: res.stdout ?? "",
        stderr: res.stderr ?? "",
    };
}
export const defaultDeps = {
    existsSync,
    isDirectory: (p) => {
        try {
            return statSync(p).isDirectory();
        }
        catch {
            return false;
        }
    },
    isFile: (p) => {
        try {
            return statSync(p).isFile();
        }
        catch {
            return false;
        }
    },
    fileSize: (p) => {
        try {
            return statSync(p).size;
        }
        catch {
            return 0;
        }
    },
    listDir: (p) => readdirSync(p, { withFileTypes: true }).map((d) => ({
        name: d.name,
        isDir: d.isDirectory(),
    })),
    readFileText: (p) => readFileSync(p, "utf-8"),
    readFileBuffer: (p) => readFileSync(p),
    writeFileText: (p, contents) => writeFileSync(p, contents, "utf-8"),
    mkdirp: (p) => {
        mkdirSync(p, { recursive: true });
    },
    realpath: (p) => realpathSync.native(p),
    hasRipgrep: () => {
        try {
            const res = spawnSync("rg", ["--version"], {
                stdio: "ignore",
                timeout: 5_000,
                windowsHide: true,
            });
            return res.status === 0;
        }
        catch {
            return false;
        }
    },
    runGit: (args, opts) => defaultSpawn("git", args, { ...opts, env: nonInteractiveGitEnv() }),
    runRipgrep: (args, opts) => defaultSpawn("rg", args, opts),
};
// ---------------------------------------------------------------------------
// Path jail
// ---------------------------------------------------------------------------
const WIN_RESERVED = new Set([
    "CON",
    "PRN",
    "AUX",
    "NUL",
    ...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`),
    ...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`),
]);
/**
 * Reject inputs whose SHAPE is dangerous on Windows (NTFS alternate data
 * streams, reserved device names, trailing dots/spaces, UNC paths, and
 * drive-relative paths like "C:x"). Applied to raw input on every platform so
 * behavior is uniform and a repo synced from Windows can't smuggle a hazard.
 */
function assertNoWindowsHazards(raw) {
    // UNC path (\\server\share or //server/share).
    if (/^[\\/]{2}/.test(raw)) {
        throw new NodeDevError(`Refusing UNC path "${raw}": paths must stay inside custom_nodes/.`);
    }
    // Drive-relative path: a drive letter + colon NOT followed by a separator
    // ("C:x" resolves against the drive's CWD, escaping the jail).
    if (/^[a-zA-Z]:(?![\\/])/.test(raw)) {
        throw new NodeDevError(`Refusing drive-relative path "${raw}": it is ambiguous and can escape ` +
            `custom_nodes/. Use a path relative to custom_nodes/ or a full absolute path.`);
    }
    // Strip a leading absolute drive prefix ("C:") before scanning segments so
    // the drive's own colon isn't flagged as an ADS.
    const afterDrive = /^[a-zA-Z]:[\\/]/.test(raw) ? raw.slice(2) : raw;
    for (const seg of afterDrive.split(/[\\/]/)) {
        if (!seg)
            continue;
        if (seg.includes(":")) {
            throw new NodeDevError(`Refusing NTFS alternate data stream in "${seg}": ':' is not allowed in a path segment.`);
        }
        const stem = seg.split(".")[0].toUpperCase();
        if (WIN_RESERVED.has(seg.toUpperCase()) || WIN_RESERVED.has(stem)) {
            throw new NodeDevError(`Refusing reserved Windows device name "${seg}".`);
        }
        // `.` and `..` are traversal operators, not Windows filenames with a
        // trailing dot. Containment checks below still decide whether they escape.
        if (seg !== "." && seg !== ".." && /[ .]$/.test(seg)) {
            throw new NodeDevError(`Refusing path segment "${seg}": trailing dot or space is unsafe on Windows.`);
        }
    }
}
export function customNodesRoot(resolvedBase) {
    // Resolve the effective LOCAL ComfyUI base the same way every other
    // filesystem-backed tool does: COMFYUI_PATH first, then the saved default
    // workspace (set via workspace action:"set_default") when COMFYUI_PATH is unset. This
    // is what install_comfyui (action:"environment") / workspace action:"get" already report, so custom-node
    // source tools no longer reject a loopback session that has a saved default
    // workspace as if it were remote (#506). Returns undefined only in remote
    // mode or when no local install is known — then we refuse with a clear error.
    //
    // `resolvedBase`, when threaded by the node_pack handler, is the ASYNC
    // live-aware scan-root resolution (resolveCustomNodesScanBaseLive,
    // #1653/#1715/#2031) and is AUTHORITATIVE: it already encodes the full
    // precedence — the live server's --base-directory when set, else the live
    // main.py checkout on a split install that has no --base-directory, ahead
    // of configuration — so configuration is NOT re-preferred over it here.
    const base = resolvedBase ?? resolveEffectiveComfyUIBase();
    if (!base) {
        throw new NodeDevError("This operation requires a local ComfyUI install, but none is configured " +
            "(COMFYUI_PATH is unset, no saved default workspace, or running in remote " +
            "--comfyui-url mode). Set COMFYUI_PATH or a default workspace " +
            "(workspace action:\"set_default\") to read, search, or edit custom-node source.");
    }
    return resolve(base, "custom_nodes");
}
function isEscape(root, candidate) {
    const rel = relative(root, candidate);
    return (rel.startsWith("..") ||
        isAbsolute(rel) ||
        rel.split(/[\\/]/).includes(".."));
}
/** Realpath the deepest existing ancestor, re-appending the not-yet-existing tail. */
function realpathDeepestExisting(abs, deps) {
    let cur = abs;
    const tail = [];
    // Guard against pathological loops.
    for (let i = 0; i < 4096; i++) {
        if (deps.existsSync(cur)) {
            const real = deps.realpath(cur);
            return tail.length ? join(real, ...tail.reverse()) : real;
        }
        const parent = dirname(cur);
        if (parent === cur)
            return abs;
        tail.push(basename(cur));
        cur = parent;
    }
    return abs;
}
/**
 * The single auditable jail resolver. Returns the realpath'd absolute path and
 * its path relative to the realpath'd custom_nodes root. Throws NodeDevError on
 * any lexical- or symlink-based escape. rel === "" denotes the root itself;
 * callers that must not touch the root reject an empty rel.
 */
export function resolveInJail(input, deps = defaultDeps, resolvedBase) {
    const raw = (input ?? "").trim();
    if (!raw)
        throw new NodeDevError("A path is required (received an empty string).");
    assertNoWindowsHazards(raw);
    const root = customNodesRoot(resolvedBase);
    const candidate = isAbsolute(raw) ? resolve(raw) : resolve(root, raw);
    // 1. Lexical containment on the un-resolved candidate.
    if (isEscape(root, candidate)) {
        throw new NodeDevError(`Refusing "${input}": resolves to "${candidate}", which is outside custom_nodes/.`);
    }
    // 2. Symlink/junction safety: re-check containment on realpaths.
    const realRoot = deps.existsSync(root) ? deps.realpath(root) : root;
    const realCandidate = realpathDeepestExisting(candidate, deps);
    if (isEscape(realRoot, realCandidate)) {
        throw new NodeDevError(`Refusing "${input}": its real path "${realCandidate}" escapes custom_nodes/ ` +
            `(via a symlink or junction).`);
    }
    return { abs: realCandidate, rel: relative(realRoot, realCandidate) };
}
/** Resolve a pack folder: name validated + jailed, must be a non-root dir. */
function resolvePackDir(pack, deps, resolvedBase) {
    const name = (pack ?? "").trim();
    assertSafeRepoName(name);
    const { abs, rel } = resolveInJail(name, deps, resolvedBase);
    if (!rel) {
        throw new NodeDevError("Refusing to operate on the custom_nodes root itself.");
    }
    return { abs, name };
}
// ---------------------------------------------------------------------------
// Bounded-text helpers (pure — exported for direct testing)
// ---------------------------------------------------------------------------
/** Break any line longer than `width` into multiple lines of at most `width`. */
export function chunkLongLines(lines, width = LONG_LINE_CHUNK) {
    const out = [];
    for (const line of lines) {
        if (line.length <= width) {
            out.push(line);
            continue;
        }
        for (let i = 0; i < line.length; i += width) {
            out.push(line.slice(i, i + width));
        }
    }
    return out;
}
/**
 * Clip text to maxChars, appending a truncation notice when clipped.
 *
 * #809: the default notice ("request a narrower range") named no parameter at all, so
 * a caller could not tell WHICH argument to change or how far it could go. Callers now
 * pass a notice built by `boundedNotice`, which states how much was dropped, the exact
 * parameter to raise, and that parameter's REAL clamp — not the one in the prose.
 */
export function boundText(text, maxChars, 
/**
 * REQUIRED (codex gate): there is no safe default. `boundText` is shared by tools with
 * DIFFERENT levers — action:"read" has `max_chars`, action:"patch" has none — so a
 * default naming `max_chars` would ship a dead remedy to whichever caller lacks it.
 * Every call site states its own.
 */
notice) {
    if (text.length <= maxChars)
        return { text, truncated: false };
    // The marker is spent from the SAME budget it describes (codex gate): reserve its
    // worst-case size up front so the returned text still honours `maxChars`. The digit
    // count is bounded by text.length, so the reserve can never be too small.
    //
    // The one case that cannot honour it: a budget SMALLER than the marker itself. There
    // the marker still wins and content is dropped entirely — an empty field with no
    // explanation reads as "the file is empty", which is a worse lie than an over-budget
    // sentence. Every real caller clamps well above the marker, so this is a corner, not
    // the contract.
    const reserve = notice(text.length).length;
    const keep = Math.max(0, maxChars - reserve);
    return { text: text.slice(0, keep) + notice(text.length - keep), truncated: true };
}
// ---------------------------------------------------------------------------
// glob → RegExp (minimal: **, *, ?)
// ---------------------------------------------------------------------------
function globToRegExp(glob) {
    let re = "";
    for (let i = 0; i < glob.length; i++) {
        const c = glob[i];
        if (c === "*") {
            if (glob[i + 1] === "*") {
                re += ".*";
                i++;
                if (glob[i + 1] === "/")
                    i++;
            }
            else {
                re += "[^/\\\\]*";
            }
        }
        else if (c === "?") {
            re += "[^/\\\\]";
        }
        else if ("\\^$.|+()[]{}".includes(c)) {
            re += "\\" + c;
        }
        else if (c === "/") {
            re += "[/\\\\]";
        }
        else {
            re += c;
        }
    }
    return new RegExp(`^${re}$`, "i");
}
export function listNodePackFiles(options, deps = defaultDeps, resolvedBase) {
    const { abs: packDir, name } = resolvePackDir(options.pack, deps, resolvedBase);
    if (!deps.isDirectory(packDir)) {
        throw new NodeDevError(`Pack "${name}" does not exist under custom_nodes/.`);
    }
    const cap = Math.min(Math.max(1, options.maxEntries ?? LIST_DEFAULT_ENTRIES), LIST_MAX_ENTRIES);
    const matcher = options.glob ? globToRegExp(options.glob) : null;
    const entries = [];
    let truncated = false;
    const walk = (dir) => {
        if (truncated)
            return;
        let items;
        try {
            items = deps.listDir(dir);
        }
        catch {
            return;
        }
        for (const item of items) {
            if (truncated)
                return;
            const full = join(dir, item.name);
            const rel = relative(packDir, full).split(/[\\/]/).join("/");
            // #809 (codex gate): stopping AT the cap cannot tell "exactly cap entries" from
            // "capped", so a pack with exactly `max_entries` files was reported as truncated.
            // Take ONE past the cap, then drop it: the extra entry is the proof, and its
            // presence is the only honest truncation signal.
            const take = (entry) => {
                entries.push(entry);
                if (entries.length > cap) {
                    entries.pop();
                    truncated = true;
                    return true;
                }
                return false;
            };
            if (item.isDir) {
                if (SKIP_DIRS.has(item.name))
                    continue;
                if (!matcher || matcher.test(rel)) {
                    if (take({ path: rel, size: 0, dir: true }))
                        return;
                }
                walk(full);
            }
            else {
                if (matcher && !matcher.test(rel))
                    continue;
                if (take({ path: rel, size: deps.fileSize(full), dir: false }))
                    return;
            }
        }
    };
    walk(packDir);
    return {
        pack: name,
        root: packDir,
        entries,
        truncated,
        ...(truncated
            ? {
                truncation_hint: `Stopped at \`max_entries\`=${cap} after ${entries.length} entr(ies); the walk did NOT ` +
                    `finish, so this is not the pack's full file list. ` +
                    `${raiseOrCeiling("max_entries", cap, LIST_MAX_ENTRIES)}, or narrow with \`glob\` (e.g. '**/*.py').`,
            }
            : {}),
        is_git_repo: deps.isDirectory(join(packDir, ".git")),
        has_pyproject: deps.isFile(join(packDir, "pyproject.toml")),
    };
}
/**
 * #809: the read action's truncation notice. Exported (with the other notice builders
 * below) so the schema-driven remedy test can check the parameters they name against
 * the read action's REAL zod shape without standing up a filesystem — an untested hint
 * string is precisely how this issue's defect 1 happened.
 *
 * Two DIFFERENT levers, both named: the char budget and the line window. A caller cut by
 * one and told to raise the other burns a retry and concludes the file is unreadable.
 */
export const readBoundNotice = (maxChars, startLine, endLine, sliceLineCount = 2) => (dropped) => {
    // Paging by `start_line`/`line_count` indexes SOURCE lines. On a slice that is ONE
    // physical line (a minified bundle, an embedded blob) there is nothing to page to —
    // offering it would be a lever that exists and cannot move (codex gate). Say what is
    // actually true: past the ceiling this tool cannot return the rest of that line.
    const onePhysicalLine = sliceLineCount <= 1;
    // Deliberately NOT quoting a length for that line (codex gate): what was measured is
    // the CHUNKED text, which carries inserted newlines, so any number here would be a
    // small lie inside a marker whose whole job is to be trusted.
    const rest = onePhysicalLine
        ? `this slice is a SINGLE physical line, so \`start_line\`/\`line_count\` cannot reach the rest — ` +
            (maxChars >= READ_MAX_CHARS
                ? `at the ${READ_MAX_CHARS} ceiling this tool cannot return more of it; search within it with node_pack (action:"search") instead`
                : `${raiseOrCeiling("max_chars", maxChars, READ_MAX_CHARS)}, and past that use node_pack (action:"search") to locate what you need inside it`)
        : `${raiseOrCeiling("max_chars", maxChars, READ_MAX_CHARS)}, or page with \`start_line\`/\`line_count\` (max ${READ_MAX_LINES} lines)`;
    return `\n\n[... ${dropped} more char(s) in lines ${startLine}-${endLine} cut by \`max_chars\`=${maxChars}; ${rest} ...]`;
};
export function readNodeFile(options, deps = defaultDeps, resolvedBase) {
    const { abs, rel } = resolveInJail(options.path, deps, resolvedBase);
    if (!rel)
        throw new NodeDevError("Refusing to read the custom_nodes root itself.");
    if (!deps.existsSync(abs) || !deps.isFile(abs)) {
        throw new NodeDevError(`File not found under custom_nodes/: "${options.path}".`);
    }
    const size = deps.fileSize(abs);
    const raw = deps.readFileText(abs);
    // Split on CRLF or LF so total_lines is correct on Windows files.
    const allLines = raw.split(/\r\n|\n/);
    const totalLines = allLines.length;
    const startLine = Math.max(1, Math.floor(options.startLine ?? 1));
    const lineCount = Math.min(Math.max(1, Math.floor(options.lineCount ?? READ_DEFAULT_LINES)), READ_MAX_LINES);
    const maxChars = Math.min(
    // #809: floor at MIN_OUTPUT_CHARS so the truncation notice always fits inside the
    // budget it describes. The CEILING is unchanged.
    Math.max(MIN_OUTPUT_CHARS, Math.floor(options.maxChars ?? READ_DEFAULT_CHARS)), READ_MAX_CHARS);
    const startIdx = startLine - 1;
    const slice = allLines.slice(startIdx, startIdx + lineCount);
    const endLine = Math.min(totalLines, startIdx + slice.length);
    const chunked = chunkLongLines(slice);
    // #809: name BOTH levers this tool actually has — the char budget and the line window
    // — with their real clamps, so a caller who hit the wrong one doesn't retry the wrong
    // parameter and conclude the file is unreadable.
    const bounded = boundText(chunked.join("\n"), maxChars, 
    // slice.length is the count of SOURCE lines — the unit `start_line`/`line_count`
    // address. `chunked` is longer when a line was split, and paging cannot reach those
    // chunks, so the notice must be told the real number (codex gate).
    readBoundNotice(maxChars, startLine, endLine, slice.length));
    // The line window can end short of the file INDEPENDENTLY of the char budget — two
    // different cuts with two different remedies. Suppressing this note whenever the char
    // budget also fired lost the continuation point exactly when the caller needed it most
    // (codex gate): the inline marker only describes the chars cut WITHIN the shown range,
    // and says nothing about the lines beyond it.
    const linesCut = endLine < totalLines;
    const truncationHint = linesCut
        ? `Shown lines ${startLine}-${endLine} of ${totalLines} (${totalLines - endLine} line(s) remain); continue with \`start_line\`:${endLine + 1} (\`line_count\` max ${READ_MAX_LINES}).`
        : undefined;
    return {
        path: rel.split(/[\\/]/).join("/"),
        content: bounded.text,
        start_line: startLine,
        end_line: endLine,
        total_lines: totalLines,
        size,
        truncated: bounded.truncated || linesCut,
        ...(truncationHint ? { truncation_hint: truncationHint } : {}),
    };
}
/** #809: a hard 600-char slice with no marker read as if the line simply ended there.
 *  Mark it, and say the cap is fixed so nobody retries a parameter that can't move it.
 *
 *  #2418 — when ripgrep already truncated the line (`--max-columns`), the text we hold
 *  is a PREVIEW, not the line. Its length says nothing about the real one, so the
 *  `+N chars` count would be a fabricated number: on the reported 42 MB line it would
 *  have claimed roughly +31. State that the line is longer than the cap, which is all
 *  that was actually observed. Detected from ripgrep's own elision suffix rather than
 *  from a flag threaded through the call, so a preview reaching this from any path is
 *  described correctly. */
export function clipMatchLine(text) {
    const sourceTruncated = text.endsWith(RIPGREP_LONG_LINE_MARKER);
    if (sourceTruncated) {
        const preview = text.slice(0, text.length - RIPGREP_LONG_LINE_MARKER.length);
        const note = `…(line continues past the fixed ${SEARCH_LINE_MAX}-char per-line cap; its full ` +
            `length was not measured — read more of it with node_pack (action:"read"), itself ` +
            `bounded by its own max_chars, max ${READ_MAX_CHARS})`;
        return preview.slice(0, Math.max(0, SEARCH_LINE_MAX - note.length)) + note;
    }
    if (text.length <= SEARCH_LINE_MAX)
        return text;
    // The marker is spent from the SAME cap it describes (codex gate), so reserve its
    // worst-case size — the emitted field still honours SEARCH_LINE_MAX.
    const marker = (dropped) => 
    // "read the FULL line" would over-promise (codex gate): action:"read" has its own
    // 24000-char budget, so it returns MORE of the line, not necessarily all of it.
    `…(+${dropped} chars; fixed ${SEARCH_LINE_MAX}-char per-line cap — read more of it with node_pack (action:"read"), itself bounded by its own max_chars, max ${READ_MAX_CHARS})`;
    const keep = Math.max(0, SEARCH_LINE_MAX - marker(text.length).length);
    return text.slice(0, keep) + marker(text.length - keep);
}
/** #809: the whole-result remedy, naming the lever that matches the cause. */
export function searchTruncationHint(reason, shown, cap) {
    return reason === "max_results"
        ? `Stopped at \`max_results\`=${cap} after ${shown} match(es); there may be more. ${raiseOrCeiling("max_results", cap, SEARCH_MAX_RESULTS)}, or narrow with \`path\`/\`glob\`.`
        : `Stopped after scanning ${SEARCH_MAX_SCANNED_FILES} files (a FIXED walker bound — no parameter raises it) with ${shown} match(es) found. Narrow with \`path\`/\`glob\`, or install ripgrep on PATH to remove this bound.`;
}
/** Resolve the directory a search runs over (default "." = the whole jail root). */
function resolveSearchDir(path, deps, resolvedBase) {
    const p = (path ?? ".").trim();
    if (p === "." || p === "")
        return { abs: customNodesRoot(resolvedBase), rel: "" };
    return resolveInJail(p, deps, resolvedBase);
}
export function searchNodePacks(options, deps = defaultDeps, resolvedBase) {
    const query = options.query ?? "";
    if (!query)
        throw new NodeDevError("A non-empty search query is required.");
    const cap = Math.min(Math.max(1, options.maxResults ?? SEARCH_DEFAULT_RESULTS), SEARCH_MAX_RESULTS);
    const { abs: searchDir, rel: searchPrefix } = resolveSearchDir(options.path, deps, resolvedBase);
    if (!deps.isDirectory(searchDir)) {
        throw new NodeDevError(`Search path does not exist under custom_nodes/.`);
    }
    // #2921: match paths are reported relative to custom_nodes/ (the base root that
    // read/write/list_files resolve against) so a search result can be fed straight to
    // action:"read". searchDir is only the WALK root; when it is a subdirectory such as a
    // pack folder, prefix every reported path with that subdirectory.
    // The jail resolver compares canonical paths. Recomputing this offset from
    // the configured (possibly symlinked) root would invent an escaping path.
    const relPrefix = searchPrefix.split(/[\\/]/).join("/");
    if (deps.hasRipgrep()) {
        return searchWithRipgrep(query, searchDir, cap, options, deps, relPrefix);
    }
    return searchBuiltin(query, searchDir, cap, options, deps, relPrefix);
}
function searchWithRipgrep(query, searchDir, cap, options, deps, relPrefix) {
    const args = [
        "--line-number",
        "--no-heading",
        "--color",
        "never",
        "--path-separator",
        "/",
        // #809 (codex gate): ask for ONE past the cap. `--max-count` is PER FILE, so with
        // `cap` exactly, a file holding more matches had them dropped BY RIPGREP before we
        // saw a line — and the loop below reported truncated:false. With `cap + 1`, any
        // truncation anywhere necessarily produces a (cap+1)-th line, which the loop sees;
        // and a search with exactly `cap` real matches produces no extra line, so it is no
        // longer mislabelled as truncated. Both directions of the lie are closed by the
        // same +1.
        "--max-count",
        String(cap + 1),
        // #2418 — bound each printed line AT THE SOURCE. Without this a minified
        // one-line JSON overflows spawnSync's maxBuffer and the whole search dies
        // ENOBUFS before a single match is returned. `--max-columns-preview` keeps a
        // usable prefix instead of ripgrep's bare `[Omitted long matching line]`.
        "--max-columns",
        String(SEARCH_MAX_COLUMNS),
        "--max-columns-preview",
    ];
    if (!options.caseSensitive)
        args.push("-i");
    if (options.glob)
        args.push("-g", options.glob);
    for (const d of SKIP_DIRS)
        args.push("-g", `!${d}/`);
    args.push("--regexp", query, "--", ".");
    const res = deps.runRipgrep(args, { cwd: searchDir, timeoutMs: GIT_TIMEOUT_MS });
    // rg exits 1 when there are no matches — not an error for us.
    if (res.status !== 0 && res.status !== 1) {
        throw new NodeDevError(`ripgrep failed (exit ${res.status}): ${res.stderr.slice(0, 500)}`);
    }
    const matches = [];
    let truncated = false;
    // Paired with the `cap + 1` above: seeing a (cap+1)-th line is the PROOF that content
    // was dropped, and its absence is proof that nothing was. No per-file bookkeeping is
    // needed — rg cannot hide a match without also pushing the global count past `cap`.
    for (const raw of res.stdout.split(/\r?\n/)) {
        if (!raw)
            continue;
        const m = /^(.*?):(\d+):(.*)$/.exec(raw);
        if (!m)
            continue;
        if (matches.length >= cap) {
            truncated = true;
            break;
        }
        matches.push({
            file: relPrefix ? `${relPrefix}/${m[1]}` : m[1],
            line: Number(m[2]),
            text: clipMatchLine(m[3]),
        });
    }
    return {
        engine: "ripgrep",
        matches,
        truncated,
        // ripgrep walks everything, so the only cut here is the result cap.
        ...(truncated
            ? {
                truncated_by: "max_results",
                truncation_hint: searchTruncationHint("max_results", matches.length, cap),
            }
            : {}),
    };
}
function searchBuiltin(query, searchDir, cap, options, deps, relPrefix) {
    const re = new RegExp(query, options.caseSensitive ? "" : "i");
    const globMatcher = options.glob ? globToRegExp(options.glob) : null;
    const matches = [];
    let truncated = false;
    // #809: the builtin walker has TWO independent cuts with OPPOSITE remedies — the
    // result cap (raise `max_results`) and the fixed scanned-file bound (no lever;
    // narrow the search). Remember which one fired.
    //
    // WRITE-ONCE (codex gate): the FIRST cut is the one the caller has to act on. A later
    // write would flip an actionable "raise `max_results`" into "no parameter raises it",
    // which is the exact wrong-lever defect this issue exists to remove. The recursive walk
    // already unwinds on `truncated`, so no overwrite path is known today — this makes the
    // invariant explicit rather than depending on every future early-return staying correct.
    let reason = null;
    const setReason = (r) => {
        if (reason === null)
            reason = r;
    };
    let scanned = 0;
    const walk = (dir) => {
        if (truncated)
            return;
        let items;
        try {
            items = deps.listDir(dir);
        }
        catch {
            return;
        }
        for (const item of items) {
            if (truncated)
                return;
            const full = join(dir, item.name);
            if (item.isDir) {
                if (SKIP_DIRS.has(item.name) || item.name.startsWith("."))
                    continue;
                walk(full);
                continue;
            }
            const rel = relative(searchDir, full).split(/[\\/]/).join("/");
            if (globMatcher && !globMatcher.test(rel))
                continue;
            if (deps.fileSize(full) > SEARCH_MAX_FILE_BYTES)
                continue;
            if (++scanned > SEARCH_MAX_SCANNED_FILES) {
                truncated = true;
                setReason("scanned_files");
                return;
            }
            let buf;
            try {
                buf = deps.readFileBuffer(full);
            }
            catch {
                continue;
            }
            if (buf.includes(0))
                continue; // binary — skip
            const lines = buf.toString("utf-8").split(/\r\n|\n/);
            for (let i = 0; i < lines.length; i++) {
                if (re.test(lines[i])) {
                    // #809 (codex gate): stopping AT the cap cannot distinguish "exactly cap
                    // matches" from "capped". Reaching a (cap+1)-th match is the proof.
                    if (matches.length >= cap) {
                        truncated = true;
                        setReason("max_results");
                        return;
                    }
                    matches.push({
                        file: relPrefix ? `${relPrefix}/${rel}` : rel,
                        line: i + 1,
                        text: clipMatchLine(lines[i]),
                    });
                }
            }
        }
    };
    walk(searchDir);
    return {
        engine: "builtin",
        matches,
        truncated,
        ...(truncated && reason
            ? {
                truncated_by: reason,
                truncation_hint: searchTruncationHint(reason, matches.length, cap),
            }
            : {}),
    };
}
export function writeNodeFile(options, deps = defaultDeps, resolvedBase) {
    const { abs, rel } = resolveInJail(options.path, deps, resolvedBase);
    if (!rel)
        throw new NodeDevError("Refusing to write the custom_nodes root itself.");
    const exists = deps.existsSync(abs);
    if (exists && !options.overwrite) {
        throw new NodeDevError(`File already exists: "${options.path}". Pass overwrite:true to replace it.`);
    }
    if (exists && !deps.isFile(abs)) {
        throw new NodeDevError(`Refusing to overwrite non-file path "${options.path}".`);
    }
    const parent = dirname(abs);
    if (!deps.existsSync(parent)) {
        if (options.createDirs === false) {
            throw new NodeDevError(`Parent directory does not exist for "${options.path}" and create_dirs is false.`);
        }
        deps.mkdirp(parent);
    }
    const content = options.content ?? "";
    deps.writeFileText(abs, content);
    logger.info("node_pack:write", { path: rel, bytes: Buffer.byteLength(content) });
    return {
        path: rel.split(/[\\/]/).join("/"),
        bytes: Buffer.byteLength(content, "utf-8"),
        created: !exists,
    };
}
const APPLY_PATCH_FILE = /^\*{3} (Update|Add|Delete) File:\s*(.+)$/;
const APPLY_PATCH_MOVE = /^\*{3} Move to:\s*(.+)$/;
/**
 * Apply-patch markers sit at column 0 (optional indent). A unified-diff body
 * line is prefixed with ' ', '+', or '-' — never treat those as headers.
 */
function applyPatchMarkerText(line) {
    if (line.startsWith("+") || line.startsWith("-"))
        return undefined;
    const trimmedStart = line.trimStart();
    if (line.startsWith(" ") && line.length - trimmedStart.length === 1) {
        return undefined;
    }
    const t = trimmedStart.trimEnd();
    return t.startsWith("***") ? t : undefined;
}
/** Strip timestamps and a/ b/ prefixes from a diff path header. */
function normalizeDiffPath(raw) {
    let p = raw.trim();
    if (!p || p === "/dev/null")
        return undefined;
    p = p.replace(/\t.*$/, "");
    p = p.replace(/^[ab]\//, "");
    p = p.replace(/\\/g, "/");
    return p || undefined;
}
function recordPatchPath(paths, raw) {
    const p = normalizeDiffPath(raw);
    if (p)
        paths.add(p);
}
/**
 * Extract every file path a patch touches. Accepts unified-diff ---/+++ headers
 * and the apply-patch / simplified-diff markers (`*** Update/Add/Delete File`,
 * `*** Move to`) so a documented `*** Begin Patch` body is not rejected before
 * git apply ever runs (#2496).
 */
export function parsePatchPaths(patch) {
    const paths = new Set();
    for (const line of patch.split(/\r?\n/)) {
        const unified = /^(?:---|\+\+\+) (.+)$/.exec(line);
        if (unified?.[1]) {
            recordPatchPath(paths, unified[1]);
            continue;
        }
        const marker = applyPatchMarkerText(line);
        if (!marker)
            continue;
        const apply = APPLY_PATCH_FILE.exec(marker);
        if (apply?.[2]) {
            recordPatchPath(paths, apply[2]);
            continue;
        }
        const move = APPLY_PATCH_MOVE.exec(marker);
        if (move?.[1])
            recordPatchPath(paths, move[1]);
    }
    return [...paths];
}
function looksLikeApplyPatch(patch) {
    let hasApply = false;
    let hasUnified = false;
    for (const line of patch.split(/\r?\n/)) {
        if (/^(?:---|\+\+\+) /.test(line))
            hasUnified = true;
        const marker = applyPatchMarkerText(line);
        if (marker === "*** Begin Patch" || (marker !== undefined && APPLY_PATCH_FILE.test(marker))) {
            hasApply = true;
        }
    }
    return hasApply && !hasUnified;
}
/** Prefix an unprefixed context line; keep already-valid unified body lines. */
function asDiffBodyLine(raw) {
    if (raw.startsWith("+") ||
        raw.startsWith("-") ||
        raw.startsWith(" ") ||
        raw.startsWith("\\")) {
        return raw;
    }
    return ` ${raw}`;
}
function countHunkSides(lines) {
    let oldCount = 0;
    let newCount = 0;
    for (const line of lines) {
        if (line.startsWith("\\"))
            continue;
        if (line.startsWith("-"))
            oldCount += 1;
        else if (line.startsWith("+"))
            newCount += 1;
        else {
            oldCount += 1;
            newCount += 1;
        }
    }
    return { oldCount, newCount };
}
function emitCountedHunk(header, body) {
    if (/^@@\s+-\d+/.test(header))
        return [header, ...body];
    const hint = header.replace(/^@@\s?/, "").trim();
    const firstText = body[0]?.replace(/^[-+ ]/, "") ?? "";
    const lines = hint && hint !== firstText ? [` ${hint}`, ...body] : body;
    const { oldCount, newCount } = countHunkSides(lines);
    const oldStart = oldCount === 0 ? 0 : 1;
    const newStart = newCount === 0 ? 0 : 1;
    return [`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, ...lines];
}
function updateBodyToHunks(body) {
    const hunks = [];
    let current;
    const start = (header) => {
        if (current)
            hunks.push(current);
        current = { header, lines: [] };
    };
    for (const raw of body) {
        if (raw.startsWith("@@")) {
            start(raw);
            continue;
        }
        if (!current)
            start("@@");
        if (current)
            current.lines.push(asDiffBodyLine(raw));
    }
    if (current)
        hunks.push(current);
    return hunks.flatMap((h) => emitCountedHunk(h.header, h.lines));
}
function deleteHunkFromDisk(relPath, deps, resolvedBase) {
    const { abs } = resolveInJail(relPath, deps, resolvedBase);
    if (!deps.existsSync(abs) || !deps.isFile(abs))
        return [];
    const text = deps.readFileText(abs);
    const hasNl = text.endsWith("\n");
    const lines = text.split(/\n/).map((l) => l.replace(/\r$/, ""));
    const fileLines = hasNl ? lines.slice(0, -1) : lines;
    if (fileLines.length === 0)
        return [];
    const hunk = [
        `@@ -1,${fileLines.length} +0,0 @@`,
        ...fileLines.map((l) => `-${l}`),
    ];
    if (!hasNl)
        hunk.push("\\ No newline at end of file");
    return hunk;
}
/**
 * Convert apply-patch / V4A (`*** Begin Patch` / `*** Update File`) into a
 * git-applyable unified diff. Unified input is returned unchanged.
 */
function toUnifiedDiff(patch, deps, resolvedBase) {
    if (!looksLikeApplyPatch(patch))
        return patch;
    const lines = patch.split(/\r?\n/);
    const files = [];
    let i = 0;
    while (i < lines.length) {
        const line = lines[i] ?? "";
        const marker = applyPatchMarkerText(line);
        const file = marker ? APPLY_PATCH_FILE.exec(marker) : undefined;
        if (!file) {
            i += 1;
            continue;
        }
        i += 1;
        const kind = file[1] === "Add" ? "add" : file[1] === "Delete" ? "delete" : "update";
        const rawPath = file[2] ?? "";
        const path = normalizeDiffPath(rawPath) ?? rawPath.trim();
        let moveTo;
        if (i < lines.length) {
            const nextMarker = applyPatchMarkerText(lines[i] ?? "");
            const move = nextMarker ? APPLY_PATCH_MOVE.exec(nextMarker) : undefined;
            if (move?.[1]) {
                moveTo = normalizeDiffPath(move[1]) ?? move[1].trim();
                i += 1;
            }
        }
        const body = [];
        while (i < lines.length) {
            const raw = lines[i] ?? "";
            const bodyMarker = applyPatchMarkerText(raw);
            if (bodyMarker === "*** End Patch" || bodyMarker === "***")
                break;
            if (bodyMarker === "*** End of File") {
                i += 1;
                break;
            }
            if (bodyMarker)
                break;
            body.push(raw);
            i += 1;
        }
        if (kind === "add") {
            const plus = body
                .filter((l) => l.length > 0 && l.trim() !== "*** End of File")
                .map((l) => (l.startsWith("+") ? l : `+${l}`));
            files.push([
                "--- /dev/null",
                `+++ b/${path}`,
                `@@ -0,0 +${plus.length === 0 ? 0 : 1},${plus.length} @@`,
                ...plus,
            ].join("\n"));
            continue;
        }
        if (kind === "delete") {
            const minus = body
                .filter((l) => l.trim() !== "" && l.trim() !== "*** End of File")
                .map((l) => (l.startsWith("-") ? l : `-${l}`));
            const hunk = minus.length > 0
                ? [
                    `@@ -1,${minus.length} +0,0 @@`,
                    ...minus,
                ]
                : deleteHunkFromDisk(path, deps, resolvedBase);
            files.push([`--- a/${path}`, "+++ /dev/null", ...hunk].join("\n"));
            continue;
        }
        const dest = moveTo ?? path;
        files.push([`--- a/${path}`, `+++ b/${dest}`, ...updateBodyToHunks(body)].join("\n"));
    }
    return files.length > 0 ? files.join("\n") + "\n" : patch;
}
/**
 * #809 (codex gate): the patch action's ONLY parameter is `patch` — it has no
 * `max_chars`. Its git output cap is therefore fixed, and a remedy naming a lever this
 * tool does not have would be the very defect this issue is about, pointing the other
 * way. Say the cap is fixed, and name the tool that CAN page the same text.
 */
export const patchBoundNotice = (dropped) => `\n\n[... ${dropped} more char(s) cut at the fixed ${CMD_OUTPUT_MAX}-char git-output cap — node_pack (action:"patch") has no parameter to raise it. Split the patch into smaller per-file hunks and re-apply: the output shrinks with the patch ...]`;
export function applyNodePatch(patch, deps = defaultDeps, resolvedBase) {
    if (!patch || !patch.trim()) {
        throw new NodeDevError("An empty patch was provided.");
    }
    const root = customNodesRoot(resolvedBase);
    // Phase 1: jail-check EVERY touched path BEFORE any git call.
    const touched = parsePatchPaths(patch);
    if (touched.length === 0) {
        throw new NodeDevError("Could not find any file headers (---/+++ or *** Update/Add/Delete File) in the patch. " +
            "Provide a unified diff or an apply-patch diff (*** Begin Patch / *** Update File).");
    }
    for (const p of touched) {
        const { rel } = resolveInJail(p, deps, resolvedBase);
        if (!rel) {
            throw new NodeDevError(`Patch would touch the custom_nodes root itself ("${p}").`);
        }
    }
    // Apply-patch / V4A (`*** Begin Patch`) is not git-applyable as-is. Convert
    // after the jail-check so a Delete File with no body can read the jailed
    // target, and so git apply still sees a ---/+++ unified diff (#2496).
    const unified = toUnifiedDiff(patch, deps, resolvedBase);
    // #2422 — what the touched files hold BEFORE the apply. `git apply`'s exit code is
    // the ONLY thing this used to report, and a 0 from it was taken to mean the content
    // changed. The report is two one-line hunks that came back {success:true,
    // stage:"apply"} while an immediate readback still returned the original lines.
    //
    // Compared by CONTENT rather than by re-running the patch in reverse: `git apply
    // --check -R` looks like the natural verifier and is wrong here, because
    // `apply.whitespace=fix` (a git CONFIG, so ambient and per-machine) rewrites the
    // added line, after which the reverse check fails on a patch that legitimately
    // landed. Measured both ways before choosing. A byte comparison cannot be fooled by
    // that: whitespace-fixed content still differs from what was there before.
    const before = new Map();
    for (const p of touched) {
        const { abs } = resolveInJail(p, deps, resolvedBase);
        before.set(p, readIfPresent(abs, deps));
    }
    const input = unified.endsWith("\n") ? unified : unified + "\n";
    // Phase 2a: git apply --check (dry run).
    const check = deps.runGit(["apply", "--check"], {
        cwd: root,
        timeoutMs: GIT_TIMEOUT_MS,
        input,
    });
    if (check.status !== 0) {
        return {
            success: false,
            stage: "check",
            touched,
            stdout: boundText(check.stdout, CMD_OUTPUT_MAX, patchBoundNotice).text,
            stderr: boundText(check.stderr, CMD_OUTPUT_MAX, patchBoundNotice).text,
        };
    }
    // Phase 2b: git apply (real).
    const apply = deps.runGit(["apply"], {
        cwd: root,
        timeoutMs: GIT_TIMEOUT_MS,
        input,
    });
    const applyStdout = boundText(apply.stdout, CMD_OUTPUT_MAX, patchBoundNotice).text;
    const applyStderr = boundText(apply.stderr, CMD_OUTPUT_MAX, patchBoundNotice).text;
    if (apply.status !== 0) {
        return { success: false, stage: "apply", touched, stdout: applyStdout, stderr: applyStderr };
    }
    // #2422 — a 0 exit is not evidence the file moved. Say what was OBSERVED rather than
    // what git returned: if every touched file is byte-identical to its pre-apply state,
    // nothing was applied, and reporting success sends the caller on to build on an edit
    // that is not there. Only an ENTIRELY unchanged set is refused: a partial apply is
    // not possible here (git apply is all-or-nothing without --reject), and a file that
    // changed to something other than the caller's exact intent is a different claim than
    // this check can make.
    const unchanged = touched.filter((p) => {
        const { abs } = resolveInJail(p, deps, resolvedBase);
        const was = before.get(p);
        const now = readIfPresent(abs, deps);
        // undefined = unreadable. Never counts as evidence that nothing happened.
        return was !== undefined && now !== undefined && was === now;
    });
    if (unchanged.length === touched.length) {
        return {
            success: false,
            stage: "apply",
            touched,
            stdout: applyStdout,
            stderr: (applyStderr ? applyStderr + "\n\n" : "") +
                `git apply exited 0 but every file the patch names is byte-identical to before ` +
                `it ran (${unchanged.join(", ")}), so NOTHING was applied. Reporting success here ` +
                `would have you build on an edit that is not on disk (#2422). Re-read the file ` +
                `with node_pack (action:"read") and rebuild the patch against what it ` +
                `actually contains — a hunk whose context has drifted is the usual cause.`,
        };
    }
    return { success: true, stage: "apply", touched, stdout: applyStdout, stderr: applyStderr };
}
/** #2422 — file contents, or null when absent. A patch that CREATES a file has no
 *  before-state, and null vs a string is exactly the change that proves it landed. */
function readIfPresent(abs, deps) {
    try {
        return deps.existsSync(abs) ? deps.readFileBuffer(abs).toString("base64") : null;
    }
    catch {
        // UNREADABLE is not UNCHANGED. undefined is incomparable by the filter above, so a
        // file we cannot read never becomes evidence that the apply did nothing — this
        // check only ever refuses what it positively observed.
        return undefined;
    }
}
/** Whether git writes (commit/push) are permitted by env flag. Default OFF. */
export function gitWritesEnabled() {
    const v = (process.env.COMFYUI_MCP_ALLOW_GIT_WRITES ?? "").trim().toLowerCase();
    return v === "1" || v === "true";
}
/**
 * #2716: before this fix, the pack-name-prefixed spelling was the ONLY one that reached
 * git, so callers learned it. Anchored at the pack it now names `MyPack/MyPack/nodes.py`
 * — and git answers a pathspec that matches nothing with an EMPTY result, not an error,
 * so the workaround would fail silently. Refuse it instead, naming the correction.
 *
 * The refusal rests on ONE disk fact: whether the pack contains a child named like the
 * pack. If it does not, the prefixed reading names a directory that is not there — which
 * decides a wildcard (`MyPack/*.py`) as well as a literal path, and needs no probe of the
 * de-prefixed path. That last part matters: `git diff`/`git add` are exactly how a DELETED
 * file is inspected and staged, so a path absent from the worktree is a legitimate
 * pathspec, and probing it would refuse the deletion.
 *
 * Where the fact is not conclusive, the refusal must stay escapable, so it fires only on
 * the AMBIGUOUS spelling:
 *   • Only a RELATIVE entry can be the prefix mistake. An absolute entry spells the whole
 *     path out, so it is honoured as written — and it is therefore the escape hatch for
 *     the one shape the disk cannot settle: a same-named child that is TRACKED but deleted
 *     from the worktree, where git can still match `MyPack/gone.py`. The message says so.
 *   • A head carrying a wildcard is not a literal reference to the pack, even when it
 *     matches the pack's own name — `assertSafeRepoName` allows `*` in a folder name, and
 *     on a POSIX filesystem a pack really can be called `Pack*`.
 *
 * `pack` reaches here two ways — the caller's spelling and the real directory name — and
 * they differ when custom_nodes/<name> is a symlink or junction to a differently-named
 * directory, since `packDir` is the REALPATH. Match either, or an aliased pack silently
 * loses the correction.
 */
function assertNotPackPrefixed(packDir, packName, original, rel, deps) {
    const segs = rel.split("/");
    const head = segs[0];
    if (/[*?]/.test(head))
        return;
    const lower = head.toLowerCase();
    if (lower !== basename(packDir).toLowerCase() && lower !== packName.toLowerCase())
        return;
    // A deeper entry needs the head to be a traversable DIRECTORY; a bare "<pack>" only
    // needs something of that name to exist, since it names the match itself.
    const headPath = join(packDir, head);
    if (segs.length > 1 ? deps.isDirectory(headPath) : deps.existsSync(headPath))
        return;
    const stripped = segs.slice(1).join("/");
    throw new NodeDevError(`Path "${original}" is resolved relative to the pack, so it names ` +
        `custom_nodes/${basename(packDir)}/${rel} — and ` +
        `custom_nodes/${basename(packDir)}/${head} is not in the working tree. ` +
        `\`paths\` entries are pack-relative: ` +
        (stripped
            ? `drop the "${head}/" prefix and pass "${stripped}"`
            : `omit \`paths\` to scope the whole pack, or pass "."`) +
        `. If you did mean that path — a tracked file whose directory was deleted, say — ` +
        `pass it as an absolute path, which is taken as written.`);
}
/**
 * Jail-check one caller-supplied `paths` entry and return it relative to the pack dir.
 *
 * #2716: `paths` is documented as "pack-relative paths to stage/scope", and the pack is
 * already chosen by `pack` — but a relative entry went to `resolveInJail()` as-is, and
 * that anchors a relative input at the custom_nodes/ ROOT. So the documented form
 * ("preset_core.py") landed BESIDE the pack and every such call was refused as "outside
 * the target pack", leaving an undocumented pack-name prefix as the only spelling that
 * worked. Anchor a relative entry at the pack instead.
 *
 * The anchor is applied to the jail-relative STRING (`<pack>/<entry>`) rather than by
 * joining onto the resolved `packDir`, for two reasons: the resolver's Windows-hazard
 * scan then still sees only caller-controlled segments (joining onto an absolute base
 * would drag the install path's own segments through it), and an install whose
 * custom_nodes/ is a junction keeps working — `packDir` is the REALPATH'd directory, so
 * an absolute join would fail the resolver's lexical containment check against the
 * un-resolved root. Containment itself is unchanged: the single auditable resolver still
 * decides it (lexical + realpath, so the custom_nodes/ jail and its symlink/junction
 * check are intact), and the result must still land inside the selected pack.
 *
 * One deliberate consequence: `join()` collapses `..` BEFORE the resolver's hazard scan
 * sees the string, so an INTERIOR climb that stays in the pack ("sub/../nodes.py") is now
 * accepted as the file it names, where the old anchor refused it. Nothing escapes on that
 * path — a climb that leaves the pack still fails the containment check below, and one
 * that leaves custom_nodes/ still fails the resolver's — and git receives the same
 * normalised pathspec we decided on, so there is no second interpretation.
 */
function packRelativePath(packDir, packName, p, deps, resolvedBase) {
    const raw = (p ?? "").trim();
    // An empty entry would resolve to the pack itself and silently widen the command's
    // scope to every file in it, which is what omitting `paths` already means.
    if (!raw)
        throw new NodeDevError("A path is required (received an empty string).");
    const spelledOut = isAbsolute(raw);
    // Check the caller's spelling before joining it to the pack name. In particular,
    // path.join("Pack", "\\\\server\\share\\file.py") can erase the leading UNC marker
    // on Windows (and POSIX does not consider it absolute), turning a Windows hazard
    // into an apparently harmless pack-relative path.
    assertNoWindowsHazards(raw);
    const { abs } = resolveInJail(spelledOut ? raw : join(packName, raw), deps, resolvedBase);
    const rel = relative(packDir, abs);
    if (rel.startsWith("..") || isAbsolute(rel)) {
        throw new NodeDevError(`Path "${p}" is outside the target pack.`);
    }
    const posix = rel.split(/[\\/]/).join("/") || ".";
    // Only a relative entry can be the pack-name-prefix mistake; an absolute one already
    // says exactly which file it means, so it is honoured as written.
    if (!spelledOut)
        assertNotPackPrefixed(packDir, packName, p, posix, deps);
    return posix;
}
/** #809: the git action's real ceiling is READ_MAX_CHARS (24000) — a CODE clamp the
 *  parameter description never mentioned. State it here so a caller who raises
 *  `max_chars` past it learns why the extra was silently dropped, and narrow-the-scope
 *  is offered because a whole-pack `diff` is often better answered by `paths`. */
export const gitBoundNotice = (maxChars) => (dropped) => `\n\n[... ${dropped} more char(s) cut by \`max_chars\`=${maxChars}; ${raiseOrCeiling("max_chars", maxChars, READ_MAX_CHARS)} (a hard clamp), or scope the command with \`paths\` ...]`;
export function nodePackGit(options, deps = defaultDeps, resolvedBase) {
    const { abs: packDir, name } = resolvePackDir(options.pack, deps, resolvedBase);
    if (!deps.isDirectory(packDir)) {
        throw new NodeDevError(`Pack "${name}" does not exist under custom_nodes/.`);
    }
    const action = options.action;
    const maxChars = Math.min(
    // #809: same floor as action:"read" — a budget too small to explain itself is not a
    // usable budget. Ceiling untouched.
    Math.max(MIN_OUTPUT_CHARS, options.maxChars ?? CMD_OUTPUT_MAX), READ_MAX_CHARS);
    const relPaths = (options.paths ?? []).map((p) => packRelativePath(packDir, name, p, deps, resolvedBase));
    let argv;
    let timeoutMs = GIT_TIMEOUT_MS;
    switch (action) {
        case "status":
            argv = ["status", "--short", "--branch"];
            if (relPaths.length)
                argv.push("--", ...relPaths);
            break;
        case "diff":
            argv = ["diff"];
            if (relPaths.length)
                argv.push("--", ...relPaths);
            break;
        case "log":
            argv = ["log", "--max-count=20", "--pretty=format:%h %an %ad %s", "--date=short"];
            if (relPaths.length)
                argv.push("--", ...relPaths);
            break;
        case "commit": {
            if (!gitWritesEnabled())
                throw new GitWritesDisabledError("commit");
            const message = (options.message ?? "").trim();
            if (!message) {
                throw new NodeDevError("commit requires a non-empty message.");
            }
            // Stage first (selective or all), then commit.
            const addArgs = relPaths.length
                ? ["add", "--end-of-options", "--", ...relPaths]
                : ["add", "-A"];
            const add = deps.runGit(addArgs, { cwd: packDir, timeoutMs });
            if (add.status !== 0) {
                return {
                    pack: name,
                    action,
                    argv: addArgs,
                    status: add.status,
                    stdout: boundText(add.stdout, maxChars, gitBoundNotice(maxChars)).text,
                    stderr: boundText(add.stderr, maxChars, gitBoundNotice(maxChars)).text,
                    success: false,
                };
            }
            argv = ["commit", "-m", message];
            break;
        }
        case "push":
            if (!gitWritesEnabled())
                throw new GitWritesDisabledError("push");
            argv = ["push"];
            timeoutMs = GIT_PUSH_TIMEOUT_MS;
            break;
        default:
            throw new NodeDevError(`Unknown git action "${String(action)}".`);
    }
    const res = deps.runGit(argv, { cwd: packDir, timeoutMs });
    return {
        pack: name,
        action,
        argv,
        status: res.status,
        stdout: boundText(res.stdout, maxChars, gitBoundNotice(maxChars)).text,
        stderr: boundText(res.stderr, maxChars, gitBoundNotice(maxChars)).text,
        success: res.status === 0,
    };
}
//# sourceMappingURL=node-dev.js.map