"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
    if (value !== null && value !== void 0) {
        if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
        var dispose, inner;
        if (async) {
            if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
            dispose = value[Symbol.asyncDispose];
        }
        if (dispose === void 0) {
            if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
            dispose = value[Symbol.dispose];
            if (async) inner = dispose;
        }
        if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
        if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
        env.stack.push({ value: value, dispose: dispose, async: async });
    }
    else if (async) {
        env.stack.push({ async: true });
    }
    return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
    return function (env) {
        function fail(e) {
            env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
            env.hasError = true;
        }
        var r, s = 0;
        function next() {
            while (r = env.stack.pop()) {
                try {
                    if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
                    if (r.dispose) {
                        var result = r.dispose.call(r.value);
                        if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
                    }
                    else s |= 1;
                }
                catch (e) {
                    fail(e);
                }
            }
            if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
            if (env.hasError) throw env.error;
        }
        return next();
    };
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
    var e = new Error(message);
    return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
Object.defineProperty(exports, "__esModule", { value: true });
exports.SSHRuntime = void 0;
exports.streamToString = streamToString;
const child_process_1 = require("child_process");
const stream_1 = require("stream");
const path = __importStar(require("path"));
const shescape_1 = require("shescape");
const Runtime_1 = require("./Runtime");
const exitCodes_1 = require("../constants/exitCodes");
const log_1 = require("../services/log");
const initHook_1 = require("./initHook");
const streamProcess_1 = require("./streamProcess");
const tildeExpansion_1 = require("./tildeExpansion");
const helpers_1 = require("../utils/runtime/helpers");
const errors_1 = require("../utils/errors");
const disposableExec_1 = require("../utils/disposableExec");
const sshConnectionPool_1 = require("./sshConnectionPool");
/**
 * Shescape instance for bash shell escaping.
 * Reused across all SSH runtime operations for performance.
 */
const shescape = new shescape_1.Shescape({ shell: "bash" });
/**
 * SSH runtime implementation that executes commands and file operations
 * over SSH using the ssh command-line tool.
 *
 * Features:
 * - Uses system ssh command (respects ~/.ssh/config)
 * - Supports SSH config aliases, ProxyJump, ControlMaster, etc.
 * - No password prompts (assumes key-based auth or ssh-agent)
 * - Atomic file writes via temp + rename
 *
 * IMPORTANT: All SSH operations MUST include a timeout to prevent hangs from network issues.
 * Timeouts should be either set literally for internal operations or forwarded from upstream
 * for user-initiated operations.
 */
class SSHRuntime {
    config;
    controlPath;
    constructor(config) {
        // Note: srcBaseDir may contain tildes - they will be resolved via resolvePath() before use
        // The WORKSPACE_CREATE IPC handler resolves paths before storing in config
        this.config = config;
        // Get deterministic controlPath from connection pool
        // Multiple SSHRuntime instances with same config share the same controlPath,
        // enabling ControlMaster to multiplex SSH connections across operations
        this.controlPath = (0, sshConnectionPool_1.getControlPath)(config);
    }
    /**
     * Execute command over SSH with streaming I/O
     */
    // eslint-disable-next-line @typescript-eslint/require-await
    async exec(command, options) {
        const startTime = performance.now();
        // Short-circuit if already aborted
        if (options.abortSignal?.aborted) {
            throw new Runtime_1.RuntimeError("Operation aborted before execution", "exec");
        }
        // Build command parts
        const parts = [];
        // Add cd command if cwd is specified
        parts.push((0, tildeExpansion_1.cdCommandForSSH)(options.cwd));
        // Add environment variable exports
        if (options.env) {
            for (const [key, value] of Object.entries(options.env)) {
                parts.push(`export ${key}=${shescape.quote(value)}`);
            }
        }
        // Add the actual command
        parts.push(command);
        // Join all parts with && to ensure each step succeeds before continuing
        let fullCommand = parts.join(" && ");
        // Wrap remote command with timeout to ensure the command is killed on the remote side
        // even if the local SSH client is killed but the ControlMaster connection persists
        // Use timeout command with KILL signal
        // Set remote timeout slightly longer (+1s) than local timeout to ensure
        // the local timeout fires first in normal cases (for cleaner error handling)
        // Note: Using BusyBox-compatible syntax (-s KILL) which also works with GNU timeout
        const remoteTimeout = Math.ceil(options.timeout) + 1;
        fullCommand = `timeout -s KILL ${remoteTimeout} bash -c ${shescape.quote(fullCommand)}`;
        // Build SSH args
        const sshArgs = ["-T"];
        // Add port if specified
        if (this.config.port) {
            sshArgs.push("-p", this.config.port.toString());
        }
        // Add identity file if specified
        if (this.config.identityFile) {
            sshArgs.push("-i", this.config.identityFile);
            // Disable strict host key checking for test environments
            sshArgs.push("-o", "StrictHostKeyChecking=no");
            sshArgs.push("-o", "UserKnownHostsFile=/dev/null");
            sshArgs.push("-o", "LogLevel=ERROR"); // Suppress SSH warnings
        }
        // Enable SSH connection multiplexing for better performance and to avoid
        // exhausting connection limits when running many concurrent operations
        // ControlMaster=auto: Create master connection if none exists, otherwise reuse
        // ControlPath: Unix socket path for multiplexing
        // ControlPersist=60: Keep master connection alive for 60s after last session
        //
        // Socket reuse is safe even with timeouts because:
        // - Each SSH command gets its own channel within the multiplexed connection
        // - SIGKILL on the client immediately closes that channel
        // - Remote sshd terminates the command when the channel closes
        // - Multiplexing only shares the TCP connection, not command lifetime
        sshArgs.push("-o", "ControlMaster=auto");
        sshArgs.push("-o", `ControlPath=${this.controlPath}`);
        sshArgs.push("-o", "ControlPersist=60");
        // Set comprehensive timeout options to ensure SSH respects the timeout
        // ConnectTimeout: Maximum time to wait for connection establishment (DNS, TCP handshake, SSH auth)
        // Cap at 15 seconds - users wanting long timeouts for builds shouldn't wait that long for connection
        // ServerAliveInterval: Send keepalive every 5 seconds to detect dead connections
        // ServerAliveCountMax: Consider connection dead after 2 missed keepalives (10 seconds total)
        // Together these ensure that:
        // 1. Connection establishment can't hang indefinitely (max 15s)
        // 2. Established connections that die are detected quickly
        // 3. The overall command timeout is respected from the moment ssh command starts
        sshArgs.push("-o", `ConnectTimeout=${Math.min(Math.ceil(options.timeout), 15)}`);
        // Set aggressive keepalives to detect dead connections
        sshArgs.push("-o", "ServerAliveInterval=5");
        sshArgs.push("-o", "ServerAliveCountMax=2");
        sshArgs.push(this.config.host, fullCommand);
        // Debug: log the actual SSH command being executed
        log_1.log.debug(`SSH command: ssh ${sshArgs.join(" ")}`);
        log_1.log.debug(`Remote command: ${fullCommand}`);
        // Spawn ssh command
        const sshProcess = (0, child_process_1.spawn)("ssh", sshArgs, {
            stdio: ["pipe", "pipe", "pipe"],
        });
        // Convert Node.js streams to Web Streams
        const stdout = stream_1.Readable.toWeb(sshProcess.stdout);
        const stderr = stream_1.Readable.toWeb(sshProcess.stderr);
        const stdin = stream_1.Writable.toWeb(sshProcess.stdin);
        // Track if we killed the process due to timeout
        let timedOut = false;
        // Create promises for exit code and duration
        // Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions
        const exitCode = new Promise((resolve, reject) => {
            sshProcess.on("close", (code, signal) => {
                // Check abort first (highest priority)
                if (options.abortSignal?.aborted) {
                    resolve(exitCodes_1.EXIT_CODE_ABORTED);
                    return;
                }
                // Check if we killed the process due to timeout
                // Don't check signal - if we set timedOut, we timed out regardless of how process died
                if (timedOut) {
                    resolve(exitCodes_1.EXIT_CODE_TIMEOUT);
                    return;
                }
                resolve(code ?? (signal ? -1 : 0));
            });
            sshProcess.on("error", (err) => {
                reject(new Runtime_1.RuntimeError(`Failed to execute SSH command: ${err.message}`, "exec", err));
            });
        });
        const duration = exitCode.then(() => performance.now() - startTime);
        // Handle abort signal
        if (options.abortSignal) {
            options.abortSignal.addEventListener("abort", () => sshProcess.kill("SIGKILL"));
        }
        // Handle timeout
        setTimeout(() => {
            timedOut = true;
            sshProcess.kill("SIGKILL");
        }, options.timeout * 1000);
        return { stdout, stderr, stdin, exitCode, duration };
    }
    /**
     * Read file contents over SSH as a stream
     */
    readFile(path, abortSignal) {
        // Return stdout, but wrap to handle errors from exec() and exit code
        return new ReadableStream({
            start: async (controller) => {
                try {
                    const stream = await this.exec(`cat ${shescape.quote(path)}`, {
                        cwd: this.config.srcBaseDir,
                        timeout: 300, // 5 minutes - reasonable for large files
                        abortSignal,
                    });
                    const reader = stream.stdout.getReader();
                    const exitCode = stream.exitCode;
                    // Read all chunks
                    while (true) {
                        const { done, value } = await reader.read();
                        if (done)
                            break;
                        controller.enqueue(value);
                    }
                    // Check exit code after reading completes
                    const code = await exitCode;
                    if (code !== 0) {
                        const stderr = await streamToString(stream.stderr);
                        throw new Runtime_1.RuntimeError(`Failed to read file ${path}: ${stderr}`, "file_io");
                    }
                    controller.close();
                }
                catch (err) {
                    if (err instanceof Runtime_1.RuntimeError) {
                        controller.error(err);
                    }
                    else {
                        controller.error(new Runtime_1.RuntimeError(`Failed to read file ${path}: ${err instanceof Error ? err.message : String(err)}`, "file_io", err instanceof Error ? err : undefined));
                    }
                }
            },
        });
    }
    /**
     * Write file contents over SSH atomically from a stream
     * Preserves symlinks and file permissions by resolving and copying metadata
     */
    writeFile(path, abortSignal) {
        const tempPath = `${path}.tmp.${Date.now()}`;
        // Resolve symlinks to get the actual target path, preserving the symlink itself
        // If target exists, save its permissions to restore after write
        // If path doesn't exist, use 600 as default
        // Then write atomically using mv (all-or-nothing for readers)
        // Use shescape.quote for safe path escaping
        const writeCommand = `RESOLVED=$(readlink -f ${shescape.quote(path)} 2>/dev/null || echo ${shescape.quote(path)}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${shescape.quote(tempPath)} && chmod "$PERMS" ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} "$RESOLVED"`;
        // Need to get the exec stream in async callbacks
        let execPromise = null;
        const getExecStream = () => {
            execPromise ?? (execPromise = this.exec(writeCommand, {
                cwd: this.config.srcBaseDir,
                timeout: 300, // 5 minutes - reasonable for large files
                abortSignal,
            }));
            return execPromise;
        };
        // Wrap stdin to handle errors from exit code
        return new WritableStream({
            write: async (chunk) => {
                const stream = await getExecStream();
                const writer = stream.stdin.getWriter();
                try {
                    await writer.write(chunk);
                }
                finally {
                    writer.releaseLock();
                }
            },
            close: async () => {
                const stream = await getExecStream();
                // Close stdin and wait for command to complete
                await stream.stdin.close();
                const exitCode = await stream.exitCode;
                if (exitCode !== 0) {
                    const stderr = await streamToString(stream.stderr);
                    throw new Runtime_1.RuntimeError(`Failed to write file ${path}: ${stderr}`, "file_io");
                }
            },
            abort: async (reason) => {
                const stream = await getExecStream();
                await stream.stdin.abort();
                throw new Runtime_1.RuntimeError(`Failed to write file ${path}: ${String(reason)}`, "file_io");
            },
        });
    }
    /**
     * Get file statistics over SSH
     */
    async stat(path, abortSignal) {
        // Use stat with format string to get: size, mtime, type
        // %s = size, %Y = mtime (seconds since epoch), %F = file type
        const stream = await this.exec(`stat -c '%s %Y %F' ${shescape.quote(path)}`, {
            cwd: this.config.srcBaseDir,
            timeout: 10, // 10 seconds - stat should be fast
            abortSignal,
        });
        const [stdout, stderr, exitCode] = await Promise.all([
            streamToString(stream.stdout),
            streamToString(stream.stderr),
            stream.exitCode,
        ]);
        if (exitCode !== 0) {
            throw new Runtime_1.RuntimeError(`Failed to stat ${path}: ${stderr}`, "file_io");
        }
        const parts = stdout.trim().split(" ");
        if (parts.length < 3) {
            throw new Runtime_1.RuntimeError(`Failed to parse stat output for ${path}: ${stdout}`, "file_io");
        }
        const size = parseInt(parts[0], 10);
        const mtime = parseInt(parts[1], 10);
        const fileType = parts.slice(2).join(" ");
        return {
            size,
            modifiedTime: new Date(mtime * 1000),
            isDirectory: fileType === "directory",
        };
    }
    async resolvePath(filePath) {
        // Use shell to expand tildes on remote system
        // Bash will expand ~ automatically when we echo the unquoted variable
        // This works with BusyBox (doesn't require GNU coreutils)
        const command = `bash -c 'p=${shescape.quote(filePath)}; echo $p'`;
        // Use 5 second timeout for path resolution (should be near-instant)
        return this.execSSHCommand(command, 5000);
    }
    /**
     * Execute a simple SSH command and return stdout
     * @param command - The command to execute on the remote host
     * @param timeoutMs - Timeout in milliseconds (required to prevent network hangs)
     * @private
     */
    async execSSHCommand(command, timeoutMs) {
        const sshArgs = this.buildSSHArgs();
        sshArgs.push(this.config.host, command);
        return new Promise((resolve, reject) => {
            const proc = (0, child_process_1.spawn)("ssh", sshArgs);
            let stdout = "";
            let stderr = "";
            let timedOut = false;
            // Set timeout to prevent hanging on network issues
            const timer = setTimeout(() => {
                timedOut = true;
                proc.kill();
                reject(new Runtime_1.RuntimeError(`SSH command timed out after ${timeoutMs}ms: ${command}`, "network"));
            }, timeoutMs);
            proc.stdout?.on("data", (data) => {
                stdout += data.toString();
            });
            proc.stderr?.on("data", (data) => {
                stderr += data.toString();
            });
            proc.on("close", (code) => {
                clearTimeout(timer);
                if (timedOut)
                    return; // Already rejected
                if (code !== 0) {
                    reject(new Runtime_1.RuntimeError(`SSH command failed: ${stderr.trim()}`, "network"));
                    return;
                }
                const output = stdout.trim();
                resolve(output);
            });
            proc.on("error", (err) => {
                clearTimeout(timer);
                if (timedOut)
                    return; // Already rejected
                reject(new Runtime_1.RuntimeError(`Cannot execute SSH command: ${(0, errors_1.getErrorMessage)(err)}`, "network", err instanceof Error ? err : undefined));
            });
        });
    }
    normalizePath(targetPath, basePath) {
        // For SSH, handle paths in a POSIX-like manner without accessing the remote filesystem
        const target = targetPath.trim();
        let base = basePath.trim();
        // Normalize base path - remove trailing slash (except for root "/")
        if (base.length > 1 && base.endsWith("/")) {
            base = base.slice(0, -1);
        }
        // Handle special case: current directory
        if (target === ".") {
            return base;
        }
        // Handle tilde expansion - keep as-is for comparison
        let normalizedTarget = target;
        if (target === "~" || target.startsWith("~/")) {
            normalizedTarget = target;
        }
        else if (target.startsWith("/")) {
            // Absolute path - use as-is
            normalizedTarget = target;
        }
        else {
            // Relative path - resolve against base using POSIX path joining
            normalizedTarget = base.endsWith("/") ? base + target : base + "/" + target;
        }
        // Remove trailing slash for comparison (except for root "/")
        if (normalizedTarget.length > 1 && normalizedTarget.endsWith("/")) {
            normalizedTarget = normalizedTarget.slice(0, -1);
        }
        return normalizedTarget;
    }
    /**
     * Build common SSH arguments based on runtime config
     * @param includeHost - Whether to include the host in the args (for direct ssh commands)
     */
    buildSSHArgs(includeHost = false) {
        const args = [];
        // Add port if specified
        if (this.config.port) {
            args.push("-p", this.config.port.toString());
        }
        // Add identity file if specified
        if (this.config.identityFile) {
            args.push("-i", this.config.identityFile);
            // Disable strict host key checking for test environments
            args.push("-o", "StrictHostKeyChecking=no");
            args.push("-o", "UserKnownHostsFile=/dev/null");
            args.push("-o", "LogLevel=ERROR");
        }
        // Add ControlMaster options for connection multiplexing
        // This ensures git bundle transfers also reuse the master connection
        args.push("-o", "ControlMaster=auto");
        args.push("-o", `ControlPath=${this.controlPath}`);
        args.push("-o", "ControlPersist=60");
        if (includeHost) {
            args.push(this.config.host);
        }
        return args;
    }
    /**
     * Sync project to remote using git bundle
     *
     * Uses `git bundle` to create a packfile and clones it on the remote.
     *
     * Benefits over git archive:
     * - Creates a real git repository on remote (can run git commands)
     * - Better parity with git worktrees (full .git directory with metadata)
     * - Enables remote git operations (commit, branch, status, diff, etc.)
     * - Only tracked files in checkout (no node_modules, build artifacts)
     * - Includes full history for flexibility
     *
     * Benefits over rsync/scp:
     * - Much faster (only tracked files)
     * - No external dependencies (git is always available)
     * - Simpler implementation
     */
    async syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal) {
        // Short-circuit if already aborted
        if (abortSignal?.aborted) {
            throw new Error("Sync operation aborted before starting");
        }
        // Use timestamp-based bundle path to avoid conflicts (simpler than $$)
        const timestamp = Date.now();
        const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`;
        try {
            // Step 1: Get origin URL from local repository (if it exists)
            let originUrl = null;
            try {
                const env_1 = { stack: [], error: void 0, hasError: false };
                try {
                    const proc = __addDisposableResource(env_1, (0, disposableExec_1.execAsync)(`cd ${shescape.quote(projectPath)} && git remote get-url origin 2>/dev/null || true`), false);
                    const { stdout } = await proc.result;
                    const url = stdout.trim();
                    // Only use URL if it's not a bundle path (avoids propagating bundle paths)
                    if (url && !url.includes(".bundle") && !url.includes(".cmux-bundle")) {
                        originUrl = url;
                    }
                }
                catch (e_1) {
                    env_1.error = e_1;
                    env_1.hasError = true;
                }
                finally {
                    __disposeResources(env_1);
                }
            }
            catch (error) {
                // If we can't get origin, continue without it
                initLogger.logStderr(`Could not get origin URL: ${(0, errors_1.getErrorMessage)(error)}`);
            }
            // Step 2: Create bundle locally and pipe to remote file via SSH
            initLogger.logStep(`Creating git bundle...`);
            await new Promise((resolve, reject) => {
                // Check if aborted before spawning
                if (abortSignal?.aborted) {
                    reject(new Error("Bundle creation aborted"));
                    return;
                }
                const sshArgs = this.buildSSHArgs(true);
                const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`;
                log_1.log.debug(`Creating bundle: ${command}`);
                const proc = (0, child_process_1.spawn)("bash", ["-c", command]);
                const cleanup = (0, streamProcess_1.streamProcessToLogger)(proc, initLogger, {
                    logStdout: false,
                    logStderr: true,
                    abortSignal,
                });
                let stderr = "";
                proc.stderr.on("data", (data) => {
                    stderr += data.toString();
                });
                proc.on("close", (code) => {
                    cleanup();
                    if (abortSignal?.aborted) {
                        reject(new Error("Bundle creation aborted"));
                    }
                    else if (code === 0) {
                        resolve();
                    }
                    else {
                        reject(new Error(`Failed to create bundle: ${stderr}`));
                    }
                });
                proc.on("error", (err) => {
                    cleanup();
                    reject(err);
                });
            });
            // Step 3: Clone from bundle on remote using this.exec
            initLogger.logStep(`Cloning repository on remote...`);
            // Expand tilde in destination path for git clone
            // git doesn't expand tilde when it's quoted, so we need to expand it ourselves
            const cloneDestPath = (0, tildeExpansion_1.expandTildeForSSH)(workspacePath);
            const cloneStream = await this.exec(`git clone --quiet ${bundleTempPath} ${cloneDestPath}`, {
                cwd: "~",
                timeout: 300, // 5 minutes for clone
                abortSignal,
            });
            const [cloneStdout, cloneStderr, cloneExitCode] = await Promise.all([
                streamToString(cloneStream.stdout),
                streamToString(cloneStream.stderr),
                cloneStream.exitCode,
            ]);
            if (cloneExitCode !== 0) {
                throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`);
            }
            // Step 4: Create local tracking branches for all remote branches
            // This ensures that branch names like "custom-trunk" can be used directly
            // in git checkout commands, rather than needing "origin/custom-trunk"
            initLogger.logStep(`Creating local tracking branches...`);
            const createTrackingBranchesStream = await this.exec(`cd ${cloneDestPath} && for branch in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin/ | grep -v 'origin/HEAD'); do localname=\${branch#origin/}; git show-ref --verify --quiet refs/heads/$localname || git branch $localname $branch; done`, {
                cwd: "~",
                timeout: 30,
                abortSignal,
            });
            await createTrackingBranchesStream.exitCode;
            // Don't fail if this fails - some branches may already exist
            // Step 5: Update origin remote if we have an origin URL
            if (originUrl) {
                initLogger.logStep(`Setting origin remote to ${originUrl}...`);
                const setOriginStream = await this.exec(`git -C ${cloneDestPath} remote set-url origin ${shescape.quote(originUrl)}`, {
                    cwd: "~",
                    timeout: 10,
                    abortSignal,
                });
                const setOriginExitCode = await setOriginStream.exitCode;
                if (setOriginExitCode !== 0) {
                    const stderr = await streamToString(setOriginStream.stderr);
                    log_1.log.info(`Failed to set origin remote: ${stderr}`);
                    // Continue anyway - this is not fatal
                }
            }
            else {
                // No origin in local repo, remove the origin that points to bundle
                initLogger.logStep(`Removing bundle origin remote...`);
                const removeOriginStream = await this.exec(`git -C ${cloneDestPath} remote remove origin 2>/dev/null || true`, {
                    cwd: "~",
                    timeout: 10,
                    abortSignal,
                });
                await removeOriginStream.exitCode;
            }
            // Step 5: Remove bundle file
            initLogger.logStep(`Cleaning up bundle file...`);
            const rmStream = await this.exec(`rm ${bundleTempPath}`, {
                cwd: "~",
                timeout: 10,
                abortSignal,
            });
            const rmExitCode = await rmStream.exitCode;
            if (rmExitCode !== 0) {
                log_1.log.info(`Failed to remove bundle file ${bundleTempPath}, but continuing`);
            }
            initLogger.logStep(`Repository cloned successfully`);
        }
        catch (error) {
            // Try to clean up bundle file on error
            try {
                const rmStream = await this.exec(`rm -f ${bundleTempPath}`, {
                    cwd: "~",
                    timeout: 10,
                    abortSignal,
                });
                await rmStream.exitCode;
            }
            catch {
                // Ignore cleanup errors
            }
            throw error;
        }
    }
    /**
     * Run .cmux/init hook on remote machine if it exists
     */
    async runInitHook(projectPath, workspacePath, initLogger, abortSignal) {
        // Check if hook exists locally (we synced the project, so local check is sufficient)
        const hookExists = await (0, initHook_1.checkInitHookExists)(projectPath);
        if (!hookExists) {
            return;
        }
        // Construct hook path - expand tilde if present
        const remoteHookPath = `${workspacePath}/.cmux/init`;
        initLogger.logStep(`Running init hook: ${remoteHookPath}`);
        // Expand tilde in hook path for execution
        // Tilde won't be expanded when the path is quoted, so we need to expand it ourselves
        const hookCommand = (0, tildeExpansion_1.expandTildeForSSH)(remoteHookPath);
        // Run hook remotely and stream output
        // No timeout - user init hooks can be arbitrarily long
        const hookStream = await this.exec(hookCommand, {
            cwd: workspacePath, // Run in the workspace directory
            timeout: 3600, // 1 hour - generous timeout for init hooks
            abortSignal,
        });
        // Create line-buffered loggers
        const loggers = (0, initHook_1.createLineBufferedLoggers)(initLogger);
        // Stream stdout/stderr through line-buffered loggers
        const stdoutReader = hookStream.stdout.getReader();
        const stderrReader = hookStream.stderr.getReader();
        const decoder = new TextDecoder();
        // Read stdout in parallel
        const readStdout = async () => {
            try {
                while (true) {
                    const { done, value } = await stdoutReader.read();
                    if (done)
                        break;
                    loggers.stdout.append(decoder.decode(value, { stream: true }));
                }
                loggers.stdout.flush();
            }
            finally {
                stdoutReader.releaseLock();
            }
        };
        // Read stderr in parallel
        const readStderr = async () => {
            try {
                while (true) {
                    const { done, value } = await stderrReader.read();
                    if (done)
                        break;
                    loggers.stderr.append(decoder.decode(value, { stream: true }));
                }
                loggers.stderr.flush();
            }
            finally {
                stderrReader.releaseLock();
            }
        };
        // Wait for completion
        const [exitCode] = await Promise.all([hookStream.exitCode, readStdout(), readStderr()]);
        initLogger.logComplete(exitCode);
    }
    getWorkspacePath(projectPath, workspaceName) {
        const projectName = (0, helpers_1.getProjectName)(projectPath);
        return path.posix.join(this.config.srcBaseDir, projectName, workspaceName);
    }
    async createWorkspace(params) {
        try {
            const { projectPath, branchName, initLogger, abortSignal } = params;
            // Compute workspace path using canonical method
            const workspacePath = this.getWorkspacePath(projectPath, branchName);
            // Prepare parent directory for git clone (fast - returns immediately)
            // Note: git clone will create the workspace directory itself during initWorkspace,
            // but the parent directory must exist first
            initLogger.logStep("Preparing remote workspace...");
            try {
                // Extract parent directory from workspace path
                // Example: ~/workspace/project/branch -> ~/workspace/project
                const lastSlash = workspacePath.lastIndexOf("/");
                const parentDir = lastSlash > 0 ? workspacePath.substring(0, lastSlash) : "~";
                // Expand tilde for mkdir command
                const expandedParentDir = (0, tildeExpansion_1.expandTildeForSSH)(parentDir);
                const parentDirCommand = `mkdir -p ${expandedParentDir}`;
                const mkdirStream = await this.exec(parentDirCommand, {
                    cwd: "/tmp",
                    timeout: 10,
                    abortSignal,
                });
                const mkdirExitCode = await mkdirStream.exitCode;
                if (mkdirExitCode !== 0) {
                    const stderr = await streamToString(mkdirStream.stderr);
                    return {
                        success: false,
                        error: `Failed to prepare remote workspace: ${stderr}`,
                    };
                }
            }
            catch (error) {
                return {
                    success: false,
                    error: `Failed to prepare remote workspace: ${(0, errors_1.getErrorMessage)(error)}`,
                };
            }
            initLogger.logStep("Remote workspace prepared");
            return {
                success: true,
                workspacePath,
            };
        }
        catch (error) {
            return {
                success: false,
                error: (0, errors_1.getErrorMessage)(error),
            };
        }
    }
    async initWorkspace(params) {
        const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params;
        try {
            // 1. Sync project to remote (opportunistic rsync with scp fallback)
            initLogger.logStep("Syncing project files to remote...");
            try {
                await this.syncProjectToRemote(projectPath, workspacePath, initLogger, abortSignal);
            }
            catch (error) {
                const errorMsg = (0, errors_1.getErrorMessage)(error);
                initLogger.logStderr(`Failed to sync project: ${errorMsg}`);
                initLogger.logComplete(-1);
                return {
                    success: false,
                    error: `Failed to sync project: ${errorMsg}`,
                };
            }
            initLogger.logStep("Files synced successfully");
            // 2. Checkout branch remotely
            // If branch exists locally, check it out; otherwise create it from the specified trunk branch
            // Note: We've already created local branches for all remote refs in syncProjectToRemote
            initLogger.logStep(`Checking out branch: ${branchName}`);
            // Try to checkout existing branch, or create new branch from trunk
            // Since we've created local branches for all remote refs, we can use branch names directly
            const checkoutCmd = `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`;
            const checkoutStream = await this.exec(checkoutCmd, {
                cwd: workspacePath, // Use the full workspace path for git operations
                timeout: 300, // 5 minutes for git checkout (can be slow on large repos)
                abortSignal,
            });
            const [stdout, stderr, exitCode] = await Promise.all([
                streamToString(checkoutStream.stdout),
                streamToString(checkoutStream.stderr),
                checkoutStream.exitCode,
            ]);
            if (exitCode !== 0) {
                const errorMsg = `Failed to checkout branch: ${stderr || stdout}`;
                initLogger.logStderr(errorMsg);
                initLogger.logComplete(-1);
                return {
                    success: false,
                    error: errorMsg,
                };
            }
            initLogger.logStep("Branch checked out successfully");
            // 3. Run .cmux/init hook if it exists
            // Note: runInitHook calls logComplete() internally if hook exists
            const hookExists = await (0, initHook_1.checkInitHookExists)(projectPath);
            if (hookExists) {
                await this.runInitHook(projectPath, workspacePath, initLogger, abortSignal);
            }
            else {
                // No hook - signal completion immediately
                initLogger.logComplete(0);
            }
            return { success: true };
        }
        catch (error) {
            const errorMsg = (0, errors_1.getErrorMessage)(error);
            initLogger.logStderr(`Initialization failed: ${errorMsg}`);
            initLogger.logComplete(-1);
            return {
                success: false,
                error: errorMsg,
            };
        }
    }
    async renameWorkspace(projectPath, oldName, newName, abortSignal) {
        // Check if already aborted
        if (abortSignal?.aborted) {
            return { success: false, error: "Rename operation aborted" };
        }
        // Compute workspace paths using canonical method
        const oldPath = this.getWorkspacePath(projectPath, oldName);
        const newPath = this.getWorkspacePath(projectPath, newName);
        try {
            // SSH runtimes use plain directories, not git worktrees
            // Expand tilde and quote paths (expandTildeForSSH handles both expansion and quoting)
            const expandedOldPath = (0, tildeExpansion_1.expandTildeForSSH)(oldPath);
            const expandedNewPath = (0, tildeExpansion_1.expandTildeForSSH)(newPath);
            // Just use mv to rename the directory on the remote host
            const moveCommand = `mv ${expandedOldPath} ${expandedNewPath}`;
            // Execute via the runtime's exec method (handles SSH connection multiplexing, etc.)
            const stream = await this.exec(moveCommand, {
                cwd: this.config.srcBaseDir,
                timeout: 30,
                abortSignal,
            });
            await stream.stdin.close();
            const exitCode = await stream.exitCode;
            if (exitCode !== 0) {
                // Read stderr for error message
                const stderrReader = stream.stderr.getReader();
                const decoder = new TextDecoder();
                let stderr = "";
                try {
                    while (true) {
                        const { done, value } = await stderrReader.read();
                        if (done)
                            break;
                        stderr += decoder.decode(value, { stream: true });
                    }
                }
                finally {
                    stderrReader.releaseLock();
                }
                return {
                    success: false,
                    error: `Failed to rename directory: ${stderr || "Unknown error"}`,
                };
            }
            return { success: true, oldPath, newPath };
        }
        catch (error) {
            return { success: false, error: `Failed to rename directory: ${(0, errors_1.getErrorMessage)(error)}` };
        }
    }
    async deleteWorkspace(projectPath, workspaceName, force, abortSignal) {
        // Check if already aborted
        if (abortSignal?.aborted) {
            return { success: false, error: "Delete operation aborted" };
        }
        // Compute workspace path using canonical method
        const deletedPath = this.getWorkspacePath(projectPath, workspaceName);
        try {
            // Combine all pre-deletion checks into a single bash script to minimize round trips
            // Exit codes: 0=ok to delete, 1=uncommitted changes, 2=unpushed commits, 3=doesn't exist
            const checkScript = force
                ? // When force=true, only check existence
                    `test -d ${shescape.quote(deletedPath)} || exit 3`
                : // When force=false, perform all safety checks
                    `
            test -d ${shescape.quote(deletedPath)} || exit 3
            cd ${shescape.quote(deletedPath)} || exit 1
            git diff --quiet --exit-code && git diff --quiet --cached --exit-code || exit 1
            if git remote | grep -q .; then
              unpushed=$(git log --branches --not --remotes --oneline)
              if [ -n "$unpushed" ]; then
                echo "$unpushed" | head -10 >&2
                exit 2
              fi
            fi
            exit 0
          `;
            const checkStream = await this.exec(checkScript, {
                cwd: this.config.srcBaseDir,
                timeout: 10,
                abortSignal,
            });
            await checkStream.stdin.close();
            const checkExitCode = await checkStream.exitCode;
            // Handle check results
            if (checkExitCode === 3) {
                // Directory doesn't exist - deletion is idempotent (success)
                return { success: true, deletedPath };
            }
            if (checkExitCode === 1) {
                return {
                    success: false,
                    error: `Workspace contains uncommitted changes. Use force flag to delete anyway.`,
                };
            }
            if (checkExitCode === 2) {
                // Read stderr which contains the unpushed commits output
                const stderrReader = checkStream.stderr.getReader();
                const decoder = new TextDecoder();
                let stderr = "";
                try {
                    while (true) {
                        const { done, value } = await stderrReader.read();
                        if (done)
                            break;
                        stderr += decoder.decode(value, { stream: true });
                    }
                }
                finally {
                    stderrReader.releaseLock();
                }
                const commitList = stderr.trim();
                const errorMsg = commitList
                    ? `Workspace contains unpushed commits:\n\n${commitList}`
                    : `Workspace contains unpushed commits. Use force flag to delete anyway.`;
                return {
                    success: false,
                    error: errorMsg,
                };
            }
            if (checkExitCode !== 0) {
                // Unexpected error
                const stderrReader = checkStream.stderr.getReader();
                const decoder = new TextDecoder();
                let stderr = "";
                try {
                    while (true) {
                        const { done, value } = await stderrReader.read();
                        if (done)
                            break;
                        stderr += decoder.decode(value, { stream: true });
                    }
                }
                finally {
                    stderrReader.releaseLock();
                }
                return {
                    success: false,
                    error: `Failed to check workspace state: ${stderr || `exit code ${checkExitCode}`}`,
                };
            }
            // SSH runtimes use plain directories, not git worktrees
            // Use rm -rf to remove the directory on the remote host
            const removeCommand = `rm -rf ${shescape.quote(deletedPath)}`;
            // Execute via the runtime's exec method (handles SSH connection multiplexing, etc.)
            const stream = await this.exec(removeCommand, {
                cwd: this.config.srcBaseDir,
                timeout: 30,
                abortSignal,
            });
            await stream.stdin.close();
            const exitCode = await stream.exitCode;
            if (exitCode !== 0) {
                // Read stderr for error message
                const stderrReader = stream.stderr.getReader();
                const decoder = new TextDecoder();
                let stderr = "";
                try {
                    while (true) {
                        const { done, value } = await stderrReader.read();
                        if (done)
                            break;
                        stderr += decoder.decode(value, { stream: true });
                    }
                }
                finally {
                    stderrReader.releaseLock();
                }
                return {
                    success: false,
                    error: `Failed to delete directory: ${stderr || "Unknown error"}`,
                };
            }
            return { success: true, deletedPath };
        }
        catch (error) {
            return { success: false, error: `Failed to delete directory: ${(0, errors_1.getErrorMessage)(error)}` };
        }
    }
    forkWorkspace(_params) {
        // SSH forking is not yet implemented due to unresolved complexities:
        // - Users expect the new workspace's filesystem state to match the remote workspace,
        //   not the local project (which may be out of sync or on a different commit)
        // - This requires: detecting the branch, copying remote state, handling uncommitted changes
        // - For now, users should create a new workspace from the desired branch instead
        return Promise.resolve({
            success: false,
            error: "Forking SSH workspaces is not yet implemented. Create a new workspace instead.",
        });
    }
}
exports.SSHRuntime = SSHRuntime;
/**
 * Helper to convert a ReadableStream to a string
 */
async function streamToString(stream) {
    const reader = stream.getReader();
    const decoder = new TextDecoder("utf-8");
    let result = "";
    try {
        while (true) {
            const { done, value } = await reader.read();
            if (done)
                break;
            result += decoder.decode(value, { stream: true });
        }
        result += decoder.decode();
        return result;
    }
    finally {
        reader.releaseLock();
    }
}
//# sourceMappingURL=SSHRuntime.js.map