/**
 * install/detached-spawn.mjs — detached child + env isolation (C54 R3).
 *
 * Spawns an install child as a DETACHED process so the parent (apt-tools
 * worktree-install) returns immediately with install_status: "running".
 * The child survives parent exit because:
 *   - `detached: true`
 *   - `stdio: ['ignore', logFd, logFd]` — child inherits the log-file fd
 *   - `child.unref()` — parent's event loop isn't tied to the child
 *
 * **Why not `run_in_background: true` (Claude Code Task tool)?**
 * Claude-Code-only API. Does not translate through Gemini / Codex /
 * OpenCode install transforms. C54 is runtime-agnostic — every runtime's
 * worktree-install invocation goes through this same Node-level detached
 * spawn primitive.
 *
 * Env allowlist (everything else dropped): PATH, LANG, LC_ALL, TMPDIR,
 * SYSTEMROOT (Windows), plus ecosystem-specific opt-outs. `HOME` is
 * overridden to a per-task tempdir containing ONLY stubbed configs that
 * redirect to allowlisted registries. Without this, `~/.npmrc` auth
 * tokens and `~/.cargo/credentials.toml` leak into postinstall scripts.
 *
 * PID heartbeat: caller (worktree-install) writes `install_status: running`,
 * `install_pid`, `install_started_at` SYNCHRONOUSLY BEFORE calling spawn.
 * If the child crashes before the parent updates state, the record still
 * reflects reality and the R6 dead-PID liveness check in `task get`
 * catches the dangling "running" state.
 *
 * Finalize wrapper: we do NOT call the install binary directly. Instead
 * we write a short shell wrapper that:
 *   1. runs the install command
 *   2. captures its exit code
 *   3. calls `apt-tools worktree-install --finalize ...` with the exit
 *      code so the parent's final-status write happens regardless of
 *      success/failure.
 */
import { spawn } from 'node:child_process';
import { chmodSync, closeSync, mkdirSync, openSync, writeFileSync } from 'node:fs';
import { platform } from 'node:os';
import { dirname, resolve } from 'node:path';
/**
 * Env vars we always carry through (plus ecosystem opt-outs merged in per-call).
 * Everything NOT in this list is dropped from the child's env.
 */
const BASE_ENV_ALLOWLIST = ['PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TMP', 'TEMP'];
// Windows ships a mix of SYSTEMROOT / SystemRoot / WINDIR that child
// processes need to boot. Preserve those on Windows only.
const WINDOWS_ENV_ALLOWLIST = ['SYSTEMROOT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'APPDATA'];
/**
 * Ecosystem-specific env opt-outs. Merged into the allowed env per call.
 * Values intentionally use `CI=1` (not `true`) to sidestep Codex envvar
 * transforms that flatten boolean string values inconsistently.
 */
export const ECOSYSTEM_ENV_OPTOUTS = {
    pnpm: { CI: '1', npm_config_progress: 'false' },
    npm: { CI: '1', npm_config_progress: 'false' },
    yarn: { CI: '1' },
    'yarn-berry': { CI: '1' },
    bun: { CI: '1' },
    uv: { UV_NO_PROGRESS: '1' },
    poetry: { POETRY_NO_INTERACTION: '1' },
    'pip-venv': { PIP_DISABLE_PIP_VERSION_CHECK: '1' },
    cargo: { CARGO_TERM_PROGRESS_WHEN: 'never' },
    go: { GOFLAGS: '-mod=readonly' },
    bundler: { BUNDLE_SILENCE_ROOT_WARNING: '1' },
    composer: { COMPOSER_NO_INTERACTION: '1' },
    gradle: { GRADLE_OPTS: '-Dorg.gradle.daemon=false' },
};
/**
 * Build the child env from allowlist + ecosystem opt-outs + HOME override.
 *
 * @param {{ ecosystem: string, homeDir: string, parentEnv?: NodeJS.ProcessEnv }} opts
 * @returns {NodeJS.ProcessEnv}
 */
export function buildChildEnv({ ecosystem, homeDir, parentEnv = process.env }) {
    const env = {};
    const allowlist = [...BASE_ENV_ALLOWLIST];
    if (platform() === 'win32')
        allowlist.push(...WINDOWS_ENV_ALLOWLIST);
    for (const key of allowlist) {
        if (parentEnv[key] !== undefined)
            env[key] = parentEnv[key];
    }
    // HOME is mandatory — install scripts need to write to ~/.cache etc.
    // Point at the per-task stubbed home so user credentials don't leak.
    env.HOME = homeDir;
    // Ecosystem-specific opt-outs.
    const optouts = ECOSYSTEM_ENV_OPTOUTS[ecosystem] || {};
    for (const [k, v] of Object.entries(optouts)) {
        env[k] = v;
    }
    return env;
}
/**
 * Write the finalize wrapper shell script. The script runs the install
 * command, captures its exit code, and invokes
 * `apt-tools worktree-install --finalize` so the final status lands on
 * state.json whether the install succeeds or fails.
 *
 * Shell choice:
 *   - POSIX (macOS/Linux): /bin/sh — minimal dependencies
 *   - Windows: .cmd file, invoked via cmd.exe — pure detached-spawn on
 *     Windows is more fragile; we degrade gracefully by still capturing
 *     the exit code + calling --finalize.
 *
 * @param {{
 *   scriptPath: string,
 *   cwd: string,
 *   cmd: string[],
 *   aptToolsPath: string,
 *   taskId: string,
 *   ecosystem: string,
 *   projectDir: string,
 *   shellWrapper?: string[] | null,
 * }} opts
 * @returns {string} path of the written script
 */
export function writeWrapperScript({ scriptPath, cwd, cmd, aptToolsPath, taskId, ecosystem, projectDir, shellWrapper, }) {
    const isWin = platform() === 'win32';
    // Escape single quotes for POSIX shell: ' → '\''
    const esc = (s) => String(s).replace(/'/g, `'\\''`);
    // For the wrapper we need the cmd as a single space-joined string.
    // Either use the ecosystem's shell_wrapper (pip-venv) or join argv.
    const install = shellWrapper && Array.isArray(shellWrapper) && shellWrapper.length > 0
        ? shellWrapper.join(' && ')
        : cmd.map((c) => `'${esc(c)}'`).join(' ');
    if (isWin) {
        // Minimal cmd.exe wrapper — quoted with double quotes.
        // Guard `cd /d "…"` so a missing-cwd failure doesn't run the install
        // command in the caller's CWD (which on Windows is typically
        // `C:\Windows\System32` for detached children — a confused-deputy
        // hazard). We still fall through to `--finalize` so state.json is
        // updated with exit=1 rather than the task wedging in "running".
        const winLines = [
            '@echo off',
            'setlocal',
            'set INSTALL_EXIT=0',
            `cd /d "${cwd}" || (set INSTALL_EXIT=1 && goto FINALIZE)`,
            install,
            'set INSTALL_EXIT=%ERRORLEVEL%',
            ':FINALIZE',
            `"${process.execPath}" "${aptToolsPath}" worktree-install "${projectDir}" --finalize --task "${taskId}" --ecosystem "${ecosystem}" --exit-code %INSTALL_EXIT%`,
            'exit /b %INSTALL_EXIT%',
        ];
        writeFileSync(scriptPath, winLines.join('\r\n'), { mode: 0o755 });
    }
    else {
        // `cd '…'` failure check — do NOT run the install command in the
        // parent's cwd if the worktree dir was removed between `worktree create`
        // and the detached child starting up. Without this guard, `pnpm install`
        // would install into whatever happened to be inherited as the cwd
        // (typically `/` or the parent's launch dir — a confused-deputy hazard).
        // We still fall through to the --finalize call so state.json is updated
        // with exit=1 rather than the task wedging in "running".
        const posixLines = [
            '#!/bin/sh',
            'set +e',
            'INSTALL_EXIT=0',
            `if cd '${esc(cwd)}'; then`,
            `  ${install}`,
            '  INSTALL_EXIT=$?',
            'else',
            '  INSTALL_EXIT=1',
            'fi',
            `'${esc(process.execPath)}' '${esc(aptToolsPath)}' worktree-install '${esc(projectDir)}' --finalize --task '${esc(taskId)}' --ecosystem '${esc(ecosystem)}' --exit-code "$INSTALL_EXIT"`,
            'exit $INSTALL_EXIT',
        ];
        writeFileSync(scriptPath, posixLines.join('\n'), { mode: 0o755 });
    }
    // Ensure +x on POSIX even if mkdir-based umask ate the mode bit.
    try {
        chmodSync(scriptPath, 0o755);
    }
    catch {
        /* best-effort on Windows */
    }
    return scriptPath;
}
/**
 * Spawn the detached install child. Returns synchronously with the child's
 * pid + start timestamp. The caller MUST have already:
 *   1. Validated homeDir is a per-task tempdir (not the user's real HOME).
 *   2. Run the manifest-scanner and confirmed ok === true.
 *   3. Acquired a semaphore slot (or marked the install queued).
 *   4. Written install_status: "running", install_pid: null,
 *      install_started_at, install_log_path to state.json.
 * This function then spawns and the caller updates state with the real pid.
 *
 * @param {{
 *   argv: string[],               // ignored when wrapperPath is set
 *   cwd: string,
 *   logPath: string,
 *   homeDir: string,
 *   ecosystem: string,
 *   wrapperPath: string,          // path to the finalize wrapper script
 *   parentEnv?: NodeJS.ProcessEnv,
 * }} opts
 * @returns {{ pid: number, started_at: string, log_path: string }}
 */
export function spawnDetachedInstall({ argv: _argv, cwd, logPath, homeDir, ecosystem, wrapperPath, parentEnv = process.env, }) {
    // Defense in depth: homeDir MUST be inside .aperant/tasks/... to prevent
    // accidental use of the caller's real HOME.
    const absHome = resolve(homeDir);
    if (!/\.aperant[\\/]tasks[\\/]/.test(absHome)) {
        throw new Error(`detached-spawn: homeDir must live inside .aperant/tasks/<id>/install/home; got ${absHome}`);
    }
    if (!wrapperPath) {
        throw new Error('detached-spawn: wrapperPath is required (writeWrapperScript first)');
    }
    // Ensure the log's parent directory exists; openSync fails otherwise.
    try {
        mkdirSync(dirname(logPath), { recursive: true });
    }
    catch {
        // Fall through — let openSync surface the real error if it persists.
    }
    const logFd = openSync(logPath, 'a');
    const env = buildChildEnv({ ecosystem, homeDir: absHome, parentEnv });
    // On Windows detached processes need `shell: true` when the exec is
    // a .cmd file. POSIX can exec the #!/bin/sh shebang directly.
    const isWin = platform() === 'win32';
    const spawnOpts = {
        cwd,
        detached: true,
        stdio: ['ignore', logFd, logFd],
        env,
        shell: isWin,
    };
    const child = spawn(wrapperPath, [], spawnOpts);
    const pid = child.pid;
    child.unref();
    // Close the parent's fd — the child has its own copy.
    try {
        closeSync(logFd);
    }
    catch {
        /* already closed */
    }
    return {
        pid: typeof pid === 'number' ? pid : -1,
        started_at: new Date().toISOString(),
        log_path: logPath,
    };
}
//# sourceMappingURL=detached-spawn.mjs.map