#!/usr/bin/env node
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync } from "node:fs";
import { resolve, join } from "node:path";
import { randomBytes } from "node:crypto";
const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
// Brand manifest — read from payload to derive all brand-specific installation values.
// The bundler stamps brand.json into the payload at build time.
const BRAND_PATH = join(PAYLOAD_DIR, "platform", "config", "brand.json");
if (!existsSync(BRAND_PATH)) {
    console.error("FATAL: brand.json not found in payload. Package may be corrupted.");
    process.exit(1);
}
let BRAND;
try {
    BRAND = JSON.parse(readFileSync(BRAND_PATH, "utf-8"));
}
catch (err) {
    console.error(`FATAL: Failed to parse brand.json: ${err.message}`);
    process.exit(1);
}
const INSTALL_DIR = resolve(process.env.HOME ?? "/root", BRAND.installDir);
const LOG_DIR = resolve(process.env.HOME ?? "/root", BRAND.configDir, "logs");
const LOG_FILE = join(LOG_DIR, `install-${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
// The device's actual hostname — may differ from BRAND.hostname if the user customized it.
// Updated by installSystemDeps() after hostname setup; used for user-facing URLs.
let DEVICE_HOSTNAME = BRAND.hostname;
// npm flags tuned for Raspberry Pi — reduce parallelism, increase patience
const NPM_NET_FLAGS = [
    "--fetch-retries=5",
    "--fetch-retry-mintimeout=30000",
    "--fetch-retry-maxtimeout=120000",
    "--maxsockets=3",
];
// ---------------------------------------------------------------------------
// Logging — timestamped to console AND persistent log file
// ---------------------------------------------------------------------------
function initLogging() {
    mkdirSync(LOG_DIR, { recursive: true });
    appendFileSync(LOG_FILE, [
        "",
        "=".repeat(64),
        `  ${BRAND.productName} Install Log — ${new Date().toISOString()}`,
        `  Node ${process.version} | ${process.platform} ${process.arch}`,
        "=".repeat(64),
        "",
    ].join("\n") + "\n");
}
function logFile(msg) {
    try {
        appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
    }
    catch { /* fs full */ }
}
// Mirror all console output into the log file
const _log = console.log;
const _err = console.error;
console.log = (...args) => { _log(...args); logFile(args.map(String).join(" ")); };
console.error = (...args) => { _err(...args); logFile(`[ERROR] ${args.map(String).join(" ")}`); };
// ---------------------------------------------------------------------------
// Diagnostics — system state snapshot for post-mortem analysis
// ---------------------------------------------------------------------------
function logDiagnostics(label) {
    logFile(`\n--- Diagnostics: ${label} ---`);
    if (!isLinux()) {
        logFile("  (not Linux — limited diagnostics)");
        return;
    }
    const run = (cmd, args) => {
        const r = spawnSync(cmd, args, { encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
        return (r.stdout || r.stderr || "").trim();
    };
    logFile(`Disk:\n${run("df", ["-h", "/", "/tmp"])}`);
    logFile(`Memory:\n${run("free", ["-h"])}`);
    logFile(`Uptime: ${run("uptime", [])}`);
    for (const host of ["registry.npmjs.org", "github.com"]) {
        const dns = spawnSync("host", ["-W", "5", host], { encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
        logFile(`DNS ${host}: ${dns.status === 0 ? "OK" : "FAIL"} — ${(dns.stdout || dns.stderr || "").trim().split("\n")[0]}`);
    }
    const curl = spawnSync("curl", [
        "-sf", "-o", "/dev/null",
        "-w", "HTTP %{http_code} in %{time_total}s (dns: %{time_namelookup}s, connect: %{time_connect}s)",
        "--connect-timeout", "10",
        "https://registry.npmjs.org/",
    ], { encoding: "utf-8", stdio: "pipe", timeout: 15_000 });
    logFile(`Registry HTTP: ${curl.stdout?.trim() || `FAIL — ${curl.stderr?.trim()}`}`);
    logFile(`--- End Diagnostics ---\n`);
}
/** Append the most recent npm debug log into our install log for post-mortem. */
function captureNpmDebugLog() {
    const logsDir = resolve(process.env.HOME ?? "/root", ".npm/_logs");
    if (!existsSync(logsDir))
        return;
    try {
        const files = readdirSync(logsDir).filter(f => f.endsWith("-debug-0.log")).sort().reverse();
        if (files[0]) {
            const content = readFileSync(join(logsDir, files[0]), "utf-8");
            logFile(`\n--- npm debug log: ${files[0]} (last 200 lines) ---`);
            const lines = content.split("\n");
            logFile(lines.slice(Math.max(0, lines.length - 200)).join("\n"));
            logFile(`--- end npm debug log ---\n`);
        }
    }
    catch { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function log(step, total, message) {
    console.log(`[${step}/${total}] ${message}`);
}
function shell(command, args, options) {
    const cmd = options?.sudo ? "sudo" : command;
    const cmdArgs = options?.sudo ? [command, ...args] : args;
    const start = Date.now();
    logFile(`> ${cmd} ${cmdArgs.join(" ")}${options?.cwd ? ` [cwd: ${options.cwd}]` : ""}`);
    const result = spawnSync(cmd, cmdArgs, {
        stdio: "inherit",
        timeout: options?.timeout ?? 300_000,
        cwd: options?.cwd,
    });
    const dur = ((Date.now() - start) / 1000).toFixed(1);
    if (result.signal) {
        logFile(`  KILLED (${result.signal}) after ${dur}s`);
        throw new Error(`Command killed (${result.signal}) after ${dur}s: ${cmd} ${cmdArgs.join(" ")}`);
    }
    if (result.status !== 0) {
        logFile(`  FAILED (exit ${result.status}) after ${dur}s`);
        throw new Error(`Command failed (exit ${result.status}) after ${dur}s: ${cmd} ${cmdArgs.join(" ")}`);
    }
    logFile(`  OK in ${dur}s`);
}
/** Wait until DNS resolves for a host. Returns true if recovered. */
function waitForDns(host, maxSec = 60) {
    for (let elapsed = 0; elapsed < maxSec; elapsed += 5) {
        const r = spawnSync("host", ["-W", "3", host], { stdio: "pipe", timeout: 5_000 });
        if (r.status === 0) {
            logFile(`  DNS ${host}: recovered after ${elapsed}s`);
            return true;
        }
        console.log(`  DNS ${host} not resolving — waiting (${elapsed}s/${maxSec}s)...`);
        spawnSync("sleep", ["5"]);
    }
    return false;
}
/** Retry a shell command with exponential backoff. Runs network diagnostics between attempts. */
function shellRetry(command, args, options, maxAttempts = 3, backoffSec = 20) {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            shell(command, args, options);
            return;
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            logFile(`  Attempt ${attempt}/${maxAttempts} error: ${msg}`);
            if (attempt === maxAttempts) {
                console.error(`  All ${maxAttempts} attempts failed.`);
                logDiagnostics(`final failure — ${command}`);
                if (command === "npm")
                    captureNpmDebugLog();
                throw err;
            }
            const wait = backoffSec * attempt;
            console.log(`  Attempt ${attempt}/${maxAttempts} failed. Retrying in ${wait}s...`);
            // On network errors, diagnose and wait for DNS recovery before retrying
            if (/ETIMEDOUT|EAI_AGAIN|ECONNRESET|ENOTFOUND|ENETUNREACH/.test(msg)) {
                console.log("  Network error — running diagnostics...");
                logDiagnostics(`network failure — attempt ${attempt}`);
                waitForDns("registry.npmjs.org");
            }
            if (command === "npm")
                captureNpmDebugLog();
            spawnSync("sleep", [String(wait)]);
        }
    }
}
function commandExists(cmd) {
    try {
        execFileSync("which", [cmd], { stdio: "pipe" });
        return true;
    }
    catch {
        return false;
    }
}
function nodeVersion() {
    try {
        const v = execFileSync("node", ["-v"], { encoding: "utf-8" }).trim();
        return parseInt(v.replace("v", "").split(".")[0], 10);
    }
    catch {
        return 0;
    }
}
function isLinux() {
    return process.platform === "linux";
}
function isArm64() {
    return process.arch === "arm64";
}
// ---------------------------------------------------------------------------
// Installation steps
// ---------------------------------------------------------------------------
const TOTAL = "11";
function installSystemDeps() {
    log("1", TOTAL, "System dependencies and network...");
    if (!isLinux()) {
        console.log("  Skipping Linux-specific setup (not on Linux).");
        return;
    }
    shell("apt-get", ["update"], { sudo: true });
    shell("apt-get", ["install", "-y", "curl", "git", "unzip", "jq", "avahi-daemon", "avahi-utils", "poppler-utils", "ffmpeg"], { sudo: true });
    shell("apt-get", ["install", "-y", "tigervnc-standalone-server", "python3-websockify", "novnc", "xdg-utils", "chromium"], { sudo: true });
    // Hostname resolution — three sources, in priority order:
    //   1. --hostname flag (unconditional — the caller is the authority)
    //   2. OS detection on upgrade (service exists → keep whatever is currently set)
    //   3. BRAND.hostname on fresh install (brand default)
    if (HOSTNAME_FLAG) {
        // --hostname flag: set unconditionally, no detection, no preservation logic.
        console.log(`  Hostname: ${HOSTNAME_FLAG} (from --hostname flag)`);
        try {
            shell("hostnamectl", ["set-hostname", HOSTNAME_FLAG], { sudo: true });
            shell("sed", ["-i", `s/127\\.0\\.1\\.1.*$/127.0.1.1\\t${HOSTNAME_FLAG}/`, "/etc/hosts"], { sudo: true });
        }
        catch (err) {
            console.error(`  WARNING: Failed to set hostname to '${HOSTNAME_FLAG}': ${err instanceof Error ? err.message : String(err)}`);
        }
        DEVICE_HOSTNAME = HOSTNAME_FLAG;
    }
    else {
        // No flag — fall back to detection (upgrade) or brand default (fresh install).
        const serviceExists = existsSync(join(resolve(process.env.HOME ?? "/root", ".config/systemd/user"), BRAND.serviceName));
        try {
            const currentHostname = execFileSync("hostname", [], { encoding: "utf-8" }).trim();
            if (currentHostname !== BRAND.hostname) {
                if (serviceExists) {
                    console.log(`  Hostname: ${currentHostname} (detected from OS — differs from brand '${BRAND.hostname}')`);
                }
                else {
                    console.log(`  Hostname: ${BRAND.hostname} (brand default — fresh install, was '${currentHostname}')`);
                    shell("hostnamectl", ["set-hostname", BRAND.hostname], { sudo: true });
                    shell("sed", ["-i", `s/127\\.0\\.1\\.1.*$/127.0.1.1\\t${BRAND.hostname}/`, "/etc/hosts"], { sudo: true });
                }
            }
            else {
                console.log(`  Hostname: ${currentHostname} (detected from OS)`);
            }
        }
        catch { /* not critical */ }
        // Read the actual hostname after any changes — used for user-facing URLs.
        try {
            DEVICE_HOSTNAME = execFileSync("hostname", [], { encoding: "utf-8" }).trim();
        }
        catch { /* fallback to brand — set at declaration */ }
    }
    // Avahi service file for LAN discovery
    const avahiService = `<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
  <name replace-wildcards="yes">${BRAND.productName} on %h</name>
  <service>
    <type>_http._tcp</type>
    <port>${PORT}</port>
    <txt-record>role=${BRAND.hostname}</txt-record>
  </service>
</service-group>`;
    const avahiTmpPath = `/tmp/${BRAND.hostname}-avahi.service`;
    const avahiDestPath = `/etc/avahi/services/${BRAND.hostname}.service`;
    try {
        writeFileSync(avahiTmpPath, avahiService);
        shell("cp", [avahiTmpPath, avahiDestPath], { sudo: true });
        shell("systemctl", ["enable", "avahi-daemon"], { sudo: true });
        shell("systemctl", ["restart", "avahi-daemon"], { sudo: true });
    }
    catch { /* not critical */ }
    // Hostname collision detection: check if another device already claims this hostname.
    // Avahi appends -2 on collision, which causes the agent to construct URLs to the wrong device.
    try {
        const resolveResult = spawnSync("avahi-resolve", ["-n", `${BRAND.hostname}.local`], {
            encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
        });
        if (resolveResult.status === 0 && resolveResult.stdout.trim()) {
            const resolvedIp = resolveResult.stdout.trim().split(/\s+/)[1];
            // Get this device's IP addresses to compare
            const hostnameResult = spawnSync("hostname", ["-I"], { encoding: "utf-8", timeout: 3000 });
            const localIps = hostnameResult.stdout?.trim().split(/\s+/) ?? [];
            if (resolvedIp && !localIps.includes(resolvedIp)) {
                console.log(`  WARNING: ${BRAND.hostname}.local already resolves to ${resolvedIp} — this device may get ${BRAND.hostname}-2.local`);
                logFile(`  WARNING: hostname collision detected — ${BRAND.hostname}.local resolves to ${resolvedIp}, local IPs: ${localIps.join(", ")}`);
            }
        }
    }
    catch { /* non-critical — avahi-resolve may not be ready yet */ }
    // Disable WiFi power management — the Pi's WiFi driver aggressively sleeps
    // the radio between transmissions, causing dropped DNS/mDNS packets and
    // intermittent connection resets on LAN.
    try {
        const nmConfDir = "/etc/NetworkManager/conf.d";
        const nmConfFile = join(nmConfDir, `${BRAND.hostname}-no-powersave.conf`);
        if (existsSync("/usr/bin/nmcli") && !existsSync(nmConfFile)) {
            console.log("  Disabling WiFi power save...");
            writeFileSync(`/tmp/${BRAND.hostname}-no-powersave.conf`, "[connection]\nwifi.powersave = 2\n");
            shell("cp", [`/tmp/${BRAND.hostname}-no-powersave.conf`, nmConfFile], { sudo: true });
            spawnSync("sudo", ["systemctl", "restart", "NetworkManager"], { stdio: "pipe" });
        }
    }
    catch { /* not critical — wired connections unaffected */ }
    console.log(`  Device reachable at http://${DEVICE_HOSTNAME}.local:${PORT}`);
}
function installNodejs() {
    if (commandExists("node") && nodeVersion() >= 20) {
        log("2", TOTAL, `Node.js v${nodeVersion()} already installed.`);
        return;
    }
    log("2", TOTAL, "Installing Node.js...");
    if (!isLinux()) {
        throw new Error("Automatic Node.js installation is only supported on Linux. Install Node.js 20+ manually.");
    }
    spawnSync("bash", ["-c", "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -"], { stdio: "inherit" });
    shell("apt-get", ["install", "-y", "nodejs"], { sudo: true });
}
function installClaudeCode() {
    let needsInstall = true;
    if (commandExists("claude")) {
        try {
            const installed = execFileSync("claude", ["--version"], { encoding: "utf-8", timeout: 10_000 }).trim();
            let latest = null;
            try {
                latest = execFileSync("npm", ["view", "@anthropic-ai/claude-code", "version"], { encoding: "utf-8", timeout: 30_000 }).trim();
            }
            catch {
                logFile("  Could not check latest version — will attempt install anyway");
            }
            if (latest && installed === latest) {
                log("3", TOTAL, `Claude Code v${installed} already up to date.`);
                needsInstall = false;
            }
            else {
                log("3", TOTAL, `Upgrading Claude Code (${installed}${latest ? ` → ${latest}` : ""})...`);
            }
        }
        catch {
            log("3", TOTAL, "Claude Code found but version check failed. Reinstalling...");
        }
    }
    else {
        log("3", TOTAL, "Installing Claude Code...");
    }
    if (needsInstall) {
        console.log("  This may take 15–30 minutes on Raspberry Pi...");
        shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: true, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
        3, 30);
    }
    console.log("  Registering claude-plugins-official marketplace...");
    const marketplaceList = spawnSync("claude", ["plugin", "marketplace", "list"], { stdio: "pipe", encoding: "utf-8" });
    if (marketplaceList.stderr)
        process.stderr.write(marketplaceList.stderr);
    if (!marketplaceList.stdout?.includes("claude-plugins-official")) {
        shell("claude", ["plugin", "marketplace", "add", "anthropics/claude-plugins-official"]);
    }
    console.log("  Configuring git to use HTTPS for GitHub (plugin install support)...");
    shell("git", ["config", "--global", "url.https://github.com/.insteadOf", "git@github.com:"]);
    // Remove the Playwright plugin if previously installed. The programmatic MCP
    // server in claude-agent.ts (with --cdp-endpoint) is the single correct path.
    // The plugin creates a shadow server that intermittently handles tool calls
    // without --cdp-endpoint, causing Chrome launch failures on ARM64.
    const pluginList = spawnSync("claude", ["plugin", "list"], { stdio: "pipe", encoding: "utf-8" });
    if (pluginList.stdout?.includes("playwright")) {
        console.log("  Removing Playwright plugin (replaced by programmatic CDP server)...");
        spawnSync("claude", ["plugin", "uninstall", "playwright"], { stdio: "inherit" });
    }
    // Ensure @playwright/mcp and all its dependencies (including playwright-core)
    // are cached. Wipe any stale npx cache for it first — killed installs on Pi
    // leave corrupt temp dirs that block subsequent npm operations.
    console.log("  Pre-caching Playwright MCP server...");
    const npxDir = resolve(process.env.HOME ?? "/root", ".npm/_npx");
    const findResult = spawnSync("find", [npxDir, "-path", "*/@playwright/mcp/package.json", "-print", "-quit"], {
        encoding: "utf-8", stdio: "pipe",
    });
    const existingCache = findResult.stdout?.trim();
    if (existingCache) {
        // Nuke the entire npx cache entry to avoid ENOTEMPTY errors
        const cacheEntry = resolve(existingCache, "../../..");
        spawnSync("rm", ["-rf", cacheEntry], { stdio: "pipe" });
    }
    // Fresh install — npx creates a clean cache with all deps
    const npxResult = spawnSync("npx", ["-y", "@playwright/mcp@latest", "--version"], {
        stdio: "pipe",
        timeout: 180000,
        encoding: "utf-8",
    });
    // Verify playwright-core landed
    const verifyResult = spawnSync("find", [npxDir, "-path", "*/playwright-core/package.json", "-print", "-quit"], {
        encoding: "utf-8", stdio: "pipe",
    });
    if (verifyResult.stdout?.trim()) {
        console.log("  Playwright MCP server cached with all dependencies.");
    }
    else {
        console.log(`  Warning: Playwright MCP cache may be incomplete (browser automation may not work).${npxResult.stderr ? ` npx stderr: ${npxResult.stderr.slice(0, 200)}` : ""}`);
    }
}
function resetNeo4jAuth() {
    const password = randomBytes(24).toString("base64url");
    console.log("  Resetting Neo4j auth with fresh password...");
    spawnSync("sudo", ["systemctl", "stop", "neo4j"], { stdio: "inherit" });
    // Clear the system database (stores auth/roles) and dbms auth config.
    // The neo4j user database (graph data) is preserved.
    // set-initial-password only works before the system DB's first start,
    // so we must delete it to make Neo4j treat the next start as initial.
    spawnSync("sudo", ["rm", "-rf",
        "/var/lib/neo4j/data/dbms",
        "/var/lib/neo4j/data/databases/system",
        "/var/lib/neo4j/data/transactions/system",
    ], { stdio: "inherit" });
    shell("neo4j-admin", ["dbms", "set-initial-password", password], { sudo: true });
    shell("systemctl", ["start", "neo4j"], { sudo: true });
    console.log("  Waiting for Neo4j to start...");
    for (let i = 0; i < 15; i++) {
        const check = spawnSync("cypher-shell", [
            "-u", "neo4j", "-p", password,
            "-a", "bolt://localhost:7687",
            "RETURN 1",
        ], { stdio: "pipe", timeout: 5000 });
        if (check.status === 0)
            break;
        spawnSync("sleep", ["2"]);
    }
    return password;
}
/** Check Neo4j has a working password. Called AFTER deploy so config is in place. */
function ensureNeo4jPassword() {
    const passwordFile = join(INSTALL_DIR, "platform/config/.neo4j-password");
    const configDir = resolve(INSTALL_DIR, "platform/config");
    mkdirSync(configDir, { recursive: true });
    // If password file exists, verify it works
    if (existsSync(passwordFile)) {
        const existingPassword = readFileSync(passwordFile, "utf-8").trim();
        const check = spawnSync("cypher-shell", [
            "-u", "neo4j", "-p", existingPassword,
            "-a", "bolt://localhost:7687",
            "RETURN 1",
        ], { stdio: "pipe", timeout: 10000 });
        if (check.status === 0) {
            return; // Password works, data intact
        }
        console.log("  Stored password doesn't match Neo4j. Setting new password...");
    }
    else {
        console.log("  No Neo4j password file found. Setting initial password...");
    }
    // Reset auth and set a new password. Graph data is preserved —
    // only the auth config (/var/lib/neo4j/data/dbms/) is cleared.
    const hasData = existsSync("/var/lib/neo4j/data/databases/neo4j");
    if (hasData) {
        console.log("  Neo4j has existing data. Resetting auth only (data preserved)...");
        logFile("  Neo4j auth reset — clearing dbms auth, preserving databases + transactions");
    }
    const password = resetNeo4jAuth();
    writeFileSync(passwordFile, password, { mode: 0o600 });
    // Also save to persistent location (~/{configDir}/)
    const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
    mkdirSync(persistDir, { recursive: true });
    writeFileSync(join(persistDir, ".neo4j-password"), password, { mode: 0o600 });
}
function installNeo4j() {
    if (commandExists("neo4j")) {
        log("4", TOTAL, "Neo4j already installed.");
        return;
    }
    log("4", TOTAL, "Installing Neo4j Community Edition 5...");
    if (!isLinux()) {
        throw new Error("Automatic Neo4j installation is only supported on Linux. Install Neo4j 5.11+ manually.");
    }
    // Neo4j 5.x supports Java 17 and 21. Debian Bookworm ships 17, Trixie ships 21.
    // apt-cache policy shows "Candidate: (none)" when no installable version exists.
    const policyResult = spawnSync("apt-cache", ["policy", "openjdk-17-jre-headless"], { stdio: "pipe" });
    const policyOutput = policyResult.stdout?.toString() ?? "";
    const has17 = policyResult.status === 0 && !policyOutput.includes("Candidate: (none)");
    const javaPackage = has17 ? "openjdk-17-jre-headless" : "openjdk-21-jre-headless";
    console.log(`  Installing Java (${javaPackage})...`);
    shell("apt-get", ["install", "-y", javaPackage], { sudo: true });
    spawnSync("bash", ["-c", "curl -fsSL https://debian.neo4j.com/neotechnology.gpg.key | sudo gpg --yes --dearmor -o /usr/share/keyrings/neo4j.gpg 2>/dev/null"], { stdio: "inherit" });
    spawnSync("bash", ["-c", 'echo "deb [signed-by=/usr/share/keyrings/neo4j.gpg] https://debian.neo4j.com stable 5" | sudo tee /etc/apt/sources.list.d/neo4j.list'], { stdio: "inherit" });
    shell("apt-get", ["update"], { sudo: true });
    shell("apt-get", ["install", "-y", "neo4j"], { sudo: true });
    shell("sed", ["-i", "s/#server.default_listen_address=0.0.0.0/server.default_listen_address=127.0.0.1/", "/etc/neo4j/neo4j.conf"], { sudo: true });
    // Generate strong random password — stored in persistent location (~/{configDir}/)
    const password = randomBytes(24).toString("base64url");
    const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
    mkdirSync(persistDir, { recursive: true });
    writeFileSync(join(persistDir, ".neo4j-password"), password, { mode: 0o600 });
    // Also write to install dir (will be there when deploy step runs)
    const configDir = resolve(INSTALL_DIR, "platform/config");
    mkdirSync(configDir, { recursive: true });
    writeFileSync(join(configDir, ".neo4j-password"), password, { mode: 0o600 });
    shell("neo4j-admin", ["dbms", "set-initial-password", password], { sudo: true });
    shell("systemctl", ["enable", "neo4j"], { sudo: true });
    shell("systemctl", ["start", "neo4j"], { sudo: true });
    console.log("  Neo4j started. Password stored securely.");
}
function ensureOllamaServing() {
    const check = () => spawnSync("curl", ["-sf", "http://localhost:11434/api/tags"], {
        stdio: "pipe", timeout: 5_000,
    });
    if (check().status === 0)
        return;
    // Log which ollama binary we're using and its version
    const which = spawnSync("which", ["ollama"], { stdio: "pipe" });
    const ollamaPath = which.stdout?.toString().trim() || "not found";
    logFile(`  Ollama binary: ${ollamaPath}`);
    const version = spawnSync("ollama", ["--version"], { stdio: "pipe", timeout: 5_000 });
    logFile(`  Ollama version: ${version.stdout?.toString().trim() || version.stderr?.toString().trim() || `exit ${version.status}`}`);
    // Check if systemd service exists
    const svcCheck = spawnSync("systemctl", ["cat", "ollama"], { stdio: "pipe", timeout: 5_000 });
    logFile(`  Ollama systemd service: ${svcCheck.status === 0 ? "exists" : "not found"}`);
    // Check if port 11434 is already in use by something else
    const portCheck = spawnSync("ss", ["-tlnp"], { stdio: "pipe", timeout: 5_000 });
    const portLines = portCheck.stdout?.toString().split("\n").filter((l) => l.includes("11434")) || [];
    if (portLines.length > 0)
        logFile(`  Port 11434 in use: ${portLines.join("; ")}`);
    console.log("  Starting Ollama server...");
    logFile("  Ollama server not responding — starting ollama serve");
    // Capture ollama serve output to a log file for diagnostics
    const ollamaLog = join(LOG_DIR, "ollama-serve.log");
    const logFd = openSync(ollamaLog, "a");
    const child = spawn("ollama", ["serve"], {
        stdio: ["ignore", logFd, logFd], detached: true,
    });
    child.unref();
    closeSync(logFd);
    logFile(`  Spawned ollama serve (PID ${child.pid}), log: ${ollamaLog}`);
    for (let elapsed = 0; elapsed < 30; elapsed += 3) {
        spawnSync("sleep", ["3"]);
        // Check if process is still alive
        const alive = child.pid && spawnSync("kill", ["-0", String(child.pid)], { stdio: "pipe" }).status === 0;
        logFile(`  Poll ${elapsed + 3}s: pid ${child.pid} alive=${alive}`);
        if (check().status === 0) {
            logFile(`  Ollama server ready after ${elapsed + 3}s`);
            return;
        }
        if (!alive) {
            // Process died — read the log to find out why
            const serveLog = readFileSync(ollamaLog, "utf-8").slice(-2000);
            logFile(`  Ollama serve exited. Log tail:\n${serveLog}`);
            console.error(`  Ollama serve exited unexpectedly. Log: ${ollamaLog}`);
            throw new Error(`Ollama serve failed to start. Log: ${ollamaLog}\n${serveLog}`);
        }
    }
    // Timed out — include diagnostics
    const serveLog = readFileSync(ollamaLog, "utf-8").slice(-2000);
    logFile(`  Ollama serve timed out after 30s. Log tail:\n${serveLog}`);
    throw new Error(`Ollama server did not respond within 30s. Log: ${ollamaLog}\n${serveLog}`);
}
function installOllama() {
    if (!commandExists("ollama")) {
        log("5", TOTAL, "Installing Ollama...");
        spawnSync("bash", ["-c", "curl -fsSL https://ollama.ai/install.sh | sh"], { stdio: "inherit" });
    }
    else {
        log("5", TOTAL, "Ollama already installed.");
    }
    ensureOllamaServing();
    console.log("  Pulling nomic-embed-text model (~300MB)...");
    shellRetry("ollama", ["pull", "nomic-embed-text"], { timeout: 600_000 }, 3, 15);
}
function installCloudflared() {
    if (commandExists("cloudflared")) {
        log("6", TOTAL, "Cloudflared already installed.");
        return;
    }
    log("6", TOTAL, "Installing cloudflared...");
    if (!isLinux()) {
        console.log("  Skipping — install manually for your platform.");
        return;
    }
    const arch = isArm64() ? "arm64" : "amd64";
    const debPath = "/tmp/cloudflared.deb";
    shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`, "-o", debPath], { timeout: 120_000 }, 3, 10);
    shell("dpkg", ["-i", debPath], { sudo: true });
    spawnSync("rm", ["-f", debPath]);
}
function installWhisperCpp() {
    const WHISPER_DIR = "/opt/whisper.cpp";
    const WHISPER_BINARY = join(WHISPER_DIR, "build/bin/whisper-cli");
    const WHISPER_MODEL = join(WHISPER_DIR, "models", "ggml-base.bin");
    if (existsSync(WHISPER_BINARY) && existsSync(WHISPER_MODEL)) {
        log("7", TOTAL, "whisper.cpp already installed.");
        return;
    }
    log("7", TOTAL, "Installing whisper.cpp (speech-to-text)...");
    if (!isLinux()) {
        console.log("  Skipping — install manually for your platform.");
        return;
    }
    // Build dependencies — cmake is required since whisper.cpp migrated from plain make
    shell("apt-get", ["install", "-y", "build-essential", "cmake"], { sudo: true });
    // Clone or update the repository
    if (!existsSync(WHISPER_DIR)) {
        console.log("  Cloning whisper.cpp...");
        shell("git", ["clone", "--depth", "1", "https://github.com/ggerganov/whisper.cpp.git", WHISPER_DIR], { sudo: true });
    }
    // Compile via cmake (whisper.cpp's Makefile is a thin cmake wrapper)
    console.log("  Compiling whisper.cpp (this takes a few minutes on Pi)...");
    shell("cmake", ["-B", "build"], { cwd: WHISPER_DIR, sudo: true, timeout: 120_000 });
    shell("cmake", ["--build", "build", "--config", "Release", "-j2"], { cwd: WHISPER_DIR, sudo: true, timeout: 600_000 });
    // Download the base model (~150MB)
    if (!existsSync(WHISPER_MODEL)) {
        console.log("  Downloading ggml-base model (~150MB)...");
        shellRetry("bash", ["-c", `cd ${WHISPER_DIR} && bash models/download-ggml-model.sh base`], { sudo: true, timeout: 300_000 }, 3, 15);
    }
    console.log("  whisper.cpp installed successfully.");
}
function deployPayload() {
    log("8", TOTAL, `Deploying ${BRAND.productName}...`);
    if (!existsSync(PAYLOAD_DIR)) {
        throw new Error(`Payload not found at ${PAYLOAD_DIR}. Package may be corrupted.`);
    }
    // Persistent config lives at ~/{configDir}/ — survives rm -rf ~/{installDir}
    const persistentDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
    const persistentPasswordFile = join(persistentDir, ".neo4j-password");
    const persistentAccountsDir = join(persistentDir, "accounts");
    // Migrate: if password is in old location, move to persistent
    const oldPasswordFile = join(INSTALL_DIR, "platform/config/.neo4j-password");
    if (existsSync(oldPasswordFile) && !existsSync(persistentPasswordFile)) {
        mkdirSync(persistentDir, { recursive: true });
        cpSync(oldPasswordFile, persistentPasswordFile);
    }
    const oldAccountsDir = join(INSTALL_DIR, "platform/config/accounts");
    if (existsSync(oldAccountsDir) && !existsSync(persistentAccountsDir)) {
        mkdirSync(persistentDir, { recursive: true });
        cpSync(oldAccountsDir, persistentAccountsDir, { recursive: true });
    }
    // Migrate users.json to persistent dir — userId must survive across installs.
    // Without this, every reinstall generates a new UUID and orphans all prior conversations.
    const persistentUsersFile = join(persistentDir, "users.json");
    const oldUsersFile = join(INSTALL_DIR, "platform/config/users.json");
    if (existsSync(oldUsersFile) && !existsSync(persistentUsersFile)) {
        mkdirSync(persistentDir, { recursive: true });
        cpSync(oldUsersFile, persistentUsersFile);
        console.log("  Migrated users.json to persistent storage.");
    }
    // Migrate secrets from ~/.maxy/ to brand-specific config dir.
    // Pre-fix code (before Task 262) hardcoded ~/.maxy/ for all brands.
    // On branded builds (configDir != .maxy), secrets may exist at ~/.maxy/ but not
    // at the brand-specific location. Copy them over so they survive the path fix.
    if (BRAND.configDir !== ".maxy") {
        const legacyDir = resolve(process.env.HOME ?? "/root", ".maxy");
        const MIGRATABLE_SECRETS = [".anthropic-api-key", ".admin-pin", ".remote-password"];
        for (const secret of MIGRATABLE_SECRETS) {
            const legacyFile = join(legacyDir, secret);
            const brandFile = join(persistentDir, secret);
            if (existsSync(legacyFile) && !existsSync(brandFile)) {
                mkdirSync(persistentDir, { recursive: true });
                cpSync(legacyFile, brandFile);
                console.log(`  Migrated ${secret} from ~/.maxy/ to ~/${BRAND.configDir}/`);
            }
        }
    }
    // Stop the running service before wiping directories (upgrade path).
    // The server holds open files in platform/ — rmSync fails with ENOTEMPTY if it's running.
    // systemctl stop returns when the main process exits, but ExecStopPost (e.g. VNC cleanup)
    // may still hold file handles. Poll is-active to wait for full deactivation.
    const svcName = BRAND.serviceName.replace(".service", "");
    spawnSync("systemctl", ["--user", "stop", svcName], { stdio: "pipe" });
    const MAX_STOP_WAIT = 5;
    for (let i = 0; i < MAX_STOP_WAIT; i++) {
        const result = spawnSync("systemctl", ["--user", "is-active", svcName], { stdio: "pipe" });
        const status = result.stdout?.toString().trim();
        if (status !== "active" && status !== "deactivating") {
            console.log(`  Service stopped (${status || "not found"}).`);
            break;
        }
        if (i === MAX_STOP_WAIT - 1) {
            console.log(`  [WARN] Service still ${status} after ${MAX_STOP_WAIT}s — proceeding with directory wipe.`);
            break;
        }
        spawnSync("sleep", ["1"]);
    }
    // Migrate user data to {installDir}/data/ — outside the platform/ wipe zone.
    // Runs after service stop to avoid copying files while the agent is writing.
    // Once data/ is populated, subsequent reinstalls skip this entirely.
    const dataDir = join(INSTALL_DIR, "data");
    const dataAccountsDir = join(dataDir, "accounts");
    const dataUploadsDir = join(dataDir, "uploads");
    const oldLiveAccounts = join(INSTALL_DIR, "platform/config/accounts");
    if (!existsSync(dataAccountsDir)) {
        if (existsSync(oldLiveAccounts)) {
            mkdirSync(dataDir, { recursive: true });
            cpSync(oldLiveAccounts, dataAccountsDir, { recursive: true });
            console.log("  Migrated accounts to data/.");
        }
        else if (existsSync(persistentAccountsDir)) {
            mkdirSync(dataDir, { recursive: true });
            cpSync(persistentAccountsDir, dataAccountsDir, { recursive: true });
            console.log("  Restored accounts from persistent backup to data/.");
        }
    }
    const oldLiveUploads = join(INSTALL_DIR, "platform/data/attachments");
    if (!existsSync(dataUploadsDir) && existsSync(oldLiveUploads)) {
        mkdirSync(dataDir, { recursive: true });
        cpSync(oldLiveUploads, dataUploadsDir, { recursive: true });
        console.log("  Migrated uploads to data/.");
    }
    // Wipe deployment directories to prevent stale files.
    // data/ is NOT wiped — it contains user data (accounts, uploads) that must survive.
    for (const dir of ["platform", "server", "maxy", "premium-plugins", "docs", ".claude"]) {
        const target = join(INSTALL_DIR, dir);
        if (existsSync(target)) {
            rmSync(target, { recursive: true, force: true });
        }
    }
    mkdirSync(INSTALL_DIR, { recursive: true });
    // Deploy payload
    cpSync(PAYLOAD_DIR, INSTALL_DIR, {
        recursive: true,
        force: true,
    });
    // Link persistent config into install directory
    const configDir = join(INSTALL_DIR, "platform/config");
    mkdirSync(configDir, { recursive: true });
    if (existsSync(persistentPasswordFile)) {
        cpSync(persistentPasswordFile, join(configDir, ".neo4j-password"));
        console.log("  Restored Neo4j password.");
    }
    if (existsSync(persistentUsersFile)) {
        cpSync(persistentUsersFile, join(configDir, "users.json"));
        console.log("  Restored users.json — userId preserved across install.");
    }
    // Account data no longer needs restoring — it lives at {installDir}/data/accounts/
    // which is outside the wipe zone. The migration block above handles the one-time move
    // from the old platform/config/accounts/ location.
    // Write version marker so the running platform knows which create-maxy produced this deployment
    writeFileSync(join(configDir, `.${BRAND.hostname}-version`), PKG_VERSION, "utf-8");
    console.log(`  Deployed to ${INSTALL_DIR}`);
}
function buildPlatform() {
    log("9", TOTAL, "Installing dependencies and building...");
    console.log(`  Installing platform dependencies (${join(INSTALL_DIR, "platform")})...`);
    shellRetry("npm", ["install", ...NPM_NET_FLAGS], { cwd: join(INSTALL_DIR, "platform") }, 3, 15);
    // MCP server dist/ files are pre-compiled in the payload — no build step needed.
    // Server external dependencies (neo4j-driver, @anthropic-ai/sdk) are listed in
    // server/package.json but NOT shipped as pre-built node_modules — npm pack silently
    // strips files from nested node_modules (e.g. rxjs/package.json), breaking require().
    // Install fresh on device to guarantee a complete dependency tree.
    console.log(`  Installing server dependencies (${join(INSTALL_DIR, "server")})...`);
    shellRetry("npm", ["install", "--omit=dev", ...NPM_NET_FLAGS], { cwd: join(INSTALL_DIR, "server") }, 3, 15);
}
function setupVncViewer() {
    if (!isLinux())
        return;
    const novncSrc = "/usr/share/novnc";
    const novncDest = join(INSTALL_DIR, "server/public/novnc");
    if (!existsSync(join(novncSrc, "core"))) {
        console.log("  noVNC not found — skipping VNC viewer setup.");
        return;
    }
    console.log("  Installing VNC viewer...");
    // Copy core/ and vendor/ (pako compression library) — both required by rfb.js
    cpSync(join(novncSrc, "core"), join(novncDest, "core"), { recursive: true, force: true });
    const vendorSrc = join(novncSrc, "vendor");
    if (existsSync(vendorSrc)) {
        cpSync(vendorSrc, join(novncDest, "vendor"), { recursive: true, force: true });
    }
    // Custom viewer: no toolbar, scales to fit, auto-connects with retry.
    //
    // Transport: same-origin WebSocket proxied through the Maxy server's
    // /websockify upgrade handler. The URL is built from location.protocol
    // and location.host so that:
    //   - HTTP origin  → ws://<host>:<port>/websockify   (LAN)
    //   - HTTPS origin → wss://<host>:<port>/websockify  (Cloudflare tunnel)
    // This eliminates the mixed-content block that previously killed the
    // viewer when accessed via https://admin.maxy.bot.
    //
    // The viewer does NOT read a host/port from query string — those
    // parameters are ignored if present (kept for backward compatibility
    // with any cached callers) and the connection is always same-origin.
    //
    // A per-session correlation ID is generated client-side and included
    // in the WebSocket URL (?corrId=X) and in the POST to
    // /api/vnc/client-event so the server-side log can correlate the
    // noVNC disconnect reason to the specific WS upgrade entry.
    const html = `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
  <title>Connect Claude</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html, body { width: 100%; height: 100%; background: #111; overflow: hidden; }
    #screen { position: relative; width: 100%; height: 100%; }
    #status {
      position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
      color: #888; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      font-size: 14px; text-align: center; z-index: 10;
      transition: opacity 0.3s;
    }
    #status.hidden { opacity: 0; pointer-events: none; }
    .status-spinner {
      display: inline-block; width: 20px; height: 20px;
      border: 2px solid #444; border-top-color: #888; border-radius: 50%;
      animation: spin 0.8s linear infinite; margin-bottom: 8px;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
    .status-reason { font-size: 12px; color: #666; margin-top: 6px; }
  </style>
</head>
<body>
  <div id="screen"></div>
  <div id="status">
    <div class="status-spinner"></div>
    <div>Connecting to browser…</div>
    <div class="status-reason"></div>
  </div>
  <script type="module">
    import RFB from '/novnc/core/rfb.js';

    // Build a same-origin WebSocket URL. Protocol auto-matches the
    // parent page so https pages use wss and http pages use ws.
    const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
    // Generate a client-side correlation ID. This ID is used only for
    // client-side log correlation (the server assigns its own corrId
    // on the upgrade handler so an attacker cannot forge server
    // internal ids). Base-36 random concatenation yields up to 16
    // chars — collision-free in practice for the 1-session-at-a-time
    // VNC use case.
    const corrId = (Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10))
      .replace(/[^a-z0-9]/gi, '') || 'c' + Date.now().toString(36);
    const wsUrl = wsScheme + '//' + location.host + '/websockify?corrId=' + encodeURIComponent(corrId);

    const screen = document.getElementById('screen');
    const status = document.getElementById('status');
    let retryCount = 0;
    const MAX_RETRIES = 30;

    // Best-effort POST of a disconnect/error reason to the server so
    // the noVNC-observed failure mode ends up in vnc-boot.log alongside
    // the server-side proxy events. Network failures are swallowed —
    // this is a telemetry side channel, not a critical path.
    function reportClientEvent(phase, reason) {
      try {
        fetch('/api/vnc/client-event', {
          method: 'POST',
          credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ corrId: corrId, phase: phase, reason: reason || '' }),
          keepalive: true,
        }).catch(function() { /* swallow */ });
      } catch (e) { /* swallow */ }
    }

    function connect() {
      status.classList.remove('hidden');
      status.querySelector('.status-spinner').style.display = '';
      status.querySelector('div:nth-child(2)').textContent =
        retryCount > 0 ? 'Reconnecting… (' + retryCount + ')' : 'Connecting to browser…';
      status.querySelector('.status-reason').textContent = '';

      const rfb = new RFB(screen, wsUrl);
      rfb.scaleViewport = true;
      rfb.clipViewport = true;
      rfb.resizeSession = false;
      window.rfb = rfb;

      rfb.addEventListener('connect', () => {
        status.classList.add('hidden');
        retryCount = 0;
      });

      rfb.addEventListener('disconnect', (e) => {
        status.classList.remove('hidden');
        const detail = e.detail || {};
        const reason = detail.reason || (detail.clean === false ? 'Connection refused' : '');
        reportClientEvent('disconnect', reason || (detail.clean === false ? 'unclean-close' : 'normal'));
        const reasonEl = status.querySelector('.status-reason');
        if (retryCount < MAX_RETRIES) {
          retryCount++;
          const delay = Math.min(1000 * retryCount, 5000);
          status.querySelector('div:nth-child(2)').textContent = 'Reconnecting in ' + Math.ceil(delay/1000) + 's…';
          reasonEl.textContent = reason;
          setTimeout(connect, delay);
        } else {
          status.querySelector('.status-spinner').style.display = 'none';
          status.querySelector('div:nth-child(2)').textContent = 'Connection lost. Reload the page to retry.';
          reasonEl.textContent = reason;
        }
      });

      // --- Clipboard bridge (local ↔ remote) ---

      // Paste bridge: intercept Cmd/Ctrl+V in the capturing phase before
      // noVNC's keydown handler can call preventDefault(). noVNC v1.3.0
      // binds its handler with .bind(this) in the Keyboard constructor and
      // registers the bound reference via addEventListener — so monkey-
      // patching the unbound method on the instance is a no-op. A capturing-
      // phase listener on document fires before noVNC's target-phase handler
      // regardless of registration timing or internal structure.
      document.addEventListener('keydown', (e) => {
        if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
          e.stopImmediatePropagation();
          reportClientEvent('paste-bridge', 'keydown-intercepted');
        }
      }, true);

      // Catch the paste event, sync text to VNC server clipboard, then
      // send Ctrl+V keystrokes so the remote app pastes the synced content.
      document.addEventListener('paste', (e) => {
        e.preventDefault();
        const text = (e.clipboardData || window.clipboardData)?.getData('text');
        if (!text || !window.rfb) {
          reportClientEvent('paste-bridge', 'empty-clipboard');
          return;
        }
        reportClientEvent('paste-bridge', 'paste-event text-length=' + text.length);
        window.rfb.clipboardPasteFrom(text);
        reportClientEvent('paste-bridge', 'clipboard-synced');
        setTimeout(() => {
          window.rfb.sendKey(0xFFE3, 'ControlLeft', true);
          window.rfb.sendKey(0x0076, 'v', true);
          window.rfb.sendKey(0x0076, 'v', false);
          window.rfb.sendKey(0xFFE3, 'ControlLeft', false);
        }, 50);
      });

      // Copy bridge: when the remote clipboard changes, notify the parent frame.
      rfb.addEventListener('clipboard', (e) => {
        window.parent.postMessage({ type: 'vnc-clipboard', text: e.detail.text }, '*');
      });
    }

    connect();
  </script>
</body>
</html>`;
    writeFileSync(join(INSTALL_DIR, "server/public/vnc-viewer.html"), html);
    console.log("  VNC viewer ready at /vnc-viewer.html");
}
function setupAccount() {
    log("10", TOTAL, "Setting up...");
    const seedScript = join(INSTALL_DIR, "platform/scripts/seed-neo4j.sh");
    if (existsSync(seedScript)) {
        shell("bash", [seedScript], { cwd: INSTALL_DIR });
    }
}
// ---------------------------------------------------------------------------
// Cron Registration
//
// Registers platform cron jobs (heartbeat, email-fetch, email-auto-respond).
// Uses BEGIN/END markers for idempotent replacement on re-install.
// Crons persist across reboots — registered once at install time.
// Email crons run unconditionally; scripts exit early if unconfigured.
// ---------------------------------------------------------------------------
const CRON_BLOCK_BEGIN = `# BEGIN ${BRAND.productName.toUpperCase()} CRONS`;
const CRON_BLOCK_END = `# END ${BRAND.productName.toUpperCase()} CRONS`;
function installCrons() {
    if (!isLinux())
        return;
    const nodeBin = spawnSync("which", ["node"], { encoding: "utf-8" }).stdout.trim() || "/usr/bin/node";
    const platformRoot = join(INSTALL_DIR, "platform");
    // Discover the account — required for log directory and ACCOUNT_ID env
    const accountsDir = join(INSTALL_DIR, "data/accounts");
    let accountId = "";
    if (existsSync(accountsDir)) {
        try {
            const dirs = readdirSync(accountsDir);
            for (const d of dirs) {
                if (existsSync(join(accountsDir, d, "account.json"))) {
                    accountId = d;
                    break;
                }
            }
        }
        catch { /* directory unreadable */ }
    }
    if (!accountId) {
        console.error("  Cron jobs: skipped — no account found. Crons will register on the next install after account creation.");
        logFile("  cron registration skipped: no account directory with account.json found");
        return;
    }
    const accountLogDir = join(accountsDir, accountId, "logs");
    const accountEnv = `ACCOUNT_ID=${accountId} `;
    const cronEntries = [
        `* * * * * mkdir -p ${accountLogDir} && PLATFORM_ROOT=${platformRoot} ${accountEnv}${nodeBin} ${platformRoot}/plugins/scheduling/mcp/dist/scripts/check-due-events.js >> ${accountLogDir}/check-due-events.log 2>&1`,
        `* * * * * mkdir -p ${accountLogDir} && PLATFORM_ROOT=${platformRoot} ${accountEnv}${nodeBin} ${platformRoot}/plugins/email/mcp/dist/scripts/email-fetch.js >> ${accountLogDir}/email-fetch.log 2>&1`,
        `* * * * * mkdir -p ${accountLogDir} && PLATFORM_ROOT=${platformRoot} ${accountEnv}${nodeBin} ${platformRoot}/plugins/email/mcp/dist/scripts/email-auto-respond.js >> ${accountLogDir}/email-auto-respond.log 2>&1`,
    ];
    // Read existing crontab (empty string if none)
    const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
    const currentCrontab = existing.status === 0 ? existing.stdout : "";
    // Strip any existing Maxy cron block
    const blockPattern = new RegExp(`${CRON_BLOCK_BEGIN}[\\s\\S]*?${CRON_BLOCK_END}\\n?`, "g");
    const cleaned = currentCrontab.replace(blockPattern, "").trimEnd();
    // Build new crontab with Maxy block
    const newBlock = [
        CRON_BLOCK_BEGIN,
        ...cronEntries,
        CRON_BLOCK_END,
    ].join("\n");
    const newCrontab = cleaned ? `${cleaned}\n${newBlock}\n` : `${newBlock}\n`;
    // Write the new crontab
    const write = spawnSync("crontab", ["-"], {
        input: newCrontab,
        encoding: "utf-8",
        stdio: ["pipe", "pipe", "pipe"],
    });
    if (write.status === 0) {
        console.log("  Cron jobs: registered (heartbeat, email-fetch, email-auto-respond)");
    }
    else {
        console.error(`  Cron jobs: failed to register — ${(write.stderr || "").trim()}`);
        logFile(`  crontab write failed: ${write.stderr}`);
    }
}
function installService() {
    log("11", TOTAL, `Starting ${BRAND.productName}...`);
    if (!isLinux()) {
        console.log("  Skipping systemd service (not Linux). Start manually with:");
        console.log(`  cd ${INSTALL_DIR}/server && MAXY_PLATFORM_ROOT=${INSTALL_DIR}/platform PORT=${PORT} HOSTNAME=0.0.0.0 KEEP_ALIVE_TIMEOUT=61000 node --require ./server-init.cjs server.js`);
        return;
    }
    // Persist UDP buffer sizes for cloudflared QUIC stability (applied on every boot via sysctl.d)
    const sysctlTmpPath = `/tmp/99-${BRAND.hostname}-quic.conf`;
    const sysctlDestPath = `/etc/sysctl.d/99-${BRAND.hostname}-quic.conf`;
    try {
        const sysctlConf = "net.core.rmem_max=7340032\nnet.core.wmem_max=7340032\n";
        writeFileSync(sysctlTmpPath, sysctlConf);
        shell("cp", [sysctlTmpPath, sysctlDestPath], { sudo: true });
        spawnSync("rm", ["-f", sysctlTmpPath]);
        spawnSync("sudo", ["sysctl", "--system"], { stdio: "ignore", timeout: 10_000 });
    }
    catch { /* non-critical — values applied on next reboot */ }
    // Stop and disable conflicting brand services and pre-migration artifacts.
    // Brand services derived from brands/*/brand.json; taskmaster is a known pre-migration leftover.
    const KNOWN_BRAND_SERVICES = ["maxy.service", "realagent.service", "taskmaster-gateway.service"];
    const serviceDir = resolve(process.env.HOME ?? "/root", ".config/systemd/user");
    mkdirSync(serviceDir, { recursive: true });
    for (const svc of KNOWN_BRAND_SERVICES) {
        if (svc === BRAND.serviceName)
            continue;
        const svcPath = join(serviceDir, svc);
        if (!existsSync(svcPath))
            continue;
        const unitName = svc.replace(".service", "");
        console.log(`  Disabling conflicting brand service: ${svc}`);
        spawnSync("systemctl", ["--user", "stop", unitName], { stdio: "inherit", timeout: 15_000 });
        spawnSync("systemctl", ["--user", "disable", unitName], { stdio: "inherit", timeout: 10_000 });
        const rmResult = spawnSync("rm", ["-f", svcPath]);
        if (rmResult.status === 0) {
            console.log(`  Removed ${svcPath}`);
        }
        else {
            console.error(`  WARNING: failed to remove ${svcPath}`);
        }
    }
    // Create systemd user service
    const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
    mkdirSync(join(persistDir, "logs"), { recursive: true });
    const serviceFile = `[Unit]
Description=${BRAND.productName} AI Assistant
After=neo4j.service

[Service]
Type=notify
NotifyAccess=all
WorkingDirectory=${INSTALL_DIR}/server
ExecStartPre=/bin/bash ${INSTALL_DIR}/platform/scripts/vnc.sh start
ExecStartPre=-/bin/bash ${INSTALL_DIR}/platform/scripts/resume-tunnel.sh
ExecStart=/usr/bin/node --require ./server-init.cjs server.js
ExecStopPost=/bin/bash ${INSTALL_DIR}/platform/scripts/vnc.sh stop
Restart=on-failure
RestartSec=5
WatchdogSec=30
TimeoutStartSec=60
TimeoutStopSec=10
Environment=NODE_ENV=production
Environment=PORT=${PORT}
Environment=HOSTNAME=0.0.0.0
Environment=KEEP_ALIVE_TIMEOUT=61000
Environment=DISPLAY=:99
Environment=PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
Environment=MAXY_PLATFORM_ROOT=${INSTALL_DIR}/platform
EnvironmentFile=-${persistDir}/.env
StandardOutput=append:${persistDir}/logs/server.log
StandardError=append:${persistDir}/logs/server.log

[Install]
WantedBy=default.target
`;
    writeFileSync(join(serviceDir, BRAND.serviceName), serviceFile);
    // Enable lingering so user services run without login
    try {
        const user = execFileSync("whoami", [], { encoding: "utf-8" }).trim();
        spawnSync("sudo", ["loginctl", "enable-linger", user], { stdio: "inherit" });
    }
    catch { /* not critical */ }
    // Reload and (re)start
    const unitName = BRAND.serviceName.replace(".service", "");
    spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
    spawnSync("systemctl", ["--user", "enable", unitName], { stdio: "inherit" });
    spawnSync("systemctl", ["--user", "restart", unitName], { stdio: "inherit" });
    // Wait for the server to come up
    console.log("  Waiting for web server...");
    let webServerUp = false;
    for (let i = 0; i < 20; i++) {
        try {
            execFileSync("curl", ["-sf", `http://localhost:${PORT}`, "-o", "/dev/null"], { timeout: 3000 });
            webServerUp = true;
            break;
        }
        catch {
            spawnSync("sleep", ["2"]);
        }
    }
    if (!webServerUp) {
        console.log(`  Server may still be starting. Check http://${DEVICE_HOSTNAME}.local:${PORT} in a moment.`);
    }
    // Validate CDP: the programmatic Playwright MCP server connects to Chromium
    // via --cdp-endpoint http://127.0.0.1:9222. Chromium is started by vnc.sh
    // (ExecStartPre) before the web server. If CDP is not responding, browser
    // automation will fail silently during onboarding sessions.
    console.log("  Verifying browser automation (CDP on port 9222)...");
    const cdpCheck = spawnSync("curl", ["-sf", "http://127.0.0.1:9222/json/version", "-o", "/dev/null"], {
        timeout: 5000,
        stdio: "pipe",
    });
    if (cdpCheck.status === 0) {
        console.log("  Browser automation ready (CDP connected).");
    }
    else {
        const vncLogPath = resolve(process.env.HOME ?? "/root", BRAND.configDir, "logs/vnc-boot.log");
        let vncLog = "";
        try {
            vncLog = readFileSync(vncLogPath, "utf-8").slice(-2000);
        }
        catch {
            vncLog = `(no boot log found at ${vncLogPath})`;
        }
        console.error("");
        console.error("  ERROR: Browser automation unavailable — CDP port 9222 not responding.");
        console.error("  Chromium should be started by vnc.sh (ExecStartPre). Check the boot log:");
        console.error("");
        console.error(vncLog);
        process.exit(1);
    }
    // Register cron jobs after the service is running
    installCrons();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
// Route to uninstall if --uninstall flag is present
const _args = process.argv.slice(2);
if (_args.includes("--uninstall")) {
    const { runUninstall } = await import("./uninstall.js");
    const exportIdx = _args.indexOf("--export-data");
    const exportPath = exportIdx !== -1 ? _args[exportIdx + 1] : undefined;
    const skipConfirm = _args.includes("--yes");
    if (exportIdx !== -1 && !exportPath) {
        console.error("--export-data requires a path argument.");
        process.exit(1);
    }
    await runUninstall({ exportPath, skipConfirm });
    process.exit(0);
}
// ---------------------------------------------------------------------------
// Port — install-time flag, not a brand attribute.
//
// Priority: --port flag > .env override > existing service file > default 19200.
// systemd applies EnvironmentFile=-~/.{brand}/.env AFTER Environment=PORT,
// so .env is the final runtime truth and must be checked first on upgrade.
// ---------------------------------------------------------------------------
let PORT = 19200;
let PORT_SOURCE = "default";
const portIdx = _args.indexOf("--port");
if (portIdx !== -1) {
    const raw = _args[portIdx + 1];
    const parsed = raw ? parseInt(raw, 10) : NaN;
    if (isNaN(parsed) || parsed < 1024 || parsed > 65535) {
        console.error(`Error: --port requires a numeric value between 1024 and 65535 (got: ${raw ?? "nothing"}).`);
        process.exit(1);
    }
    PORT = parsed;
    PORT_SOURCE = "--port flag";
}
else {
    // Upgrade detection: check .env first (runtime override), then service file (install-time)
    const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
    const envPath = join(persistDir, ".env");
    try {
        if (existsSync(envPath)) {
            const envContent = readFileSync(envPath, "utf-8");
            const envMatch = envContent.match(/^PORT=(\d+)/m);
            if (envMatch) {
                const envPort = parseInt(envMatch[1], 10);
                if (envPort >= 1024 && envPort <= 65535) {
                    PORT = envPort;
                    PORT_SOURCE = ".env override";
                }
            }
        }
    }
    catch { /* non-critical */ }
    // Fall back to the service file if .env didn't set PORT
    if (PORT_SOURCE === "default") {
        const serviceDir = resolve(process.env.HOME ?? "/root", ".config/systemd/user");
        const servicePath = join(serviceDir, BRAND.serviceName);
        try {
            if (existsSync(servicePath)) {
                const svcContent = readFileSync(servicePath, "utf-8");
                const match = svcContent.match(/^Environment=PORT=(\d+)/m);
                if (match) {
                    const existing = parseInt(match[1], 10);
                    if (existing >= 1024 && existing <= 65535) {
                        PORT = existing;
                        PORT_SOURCE = "running service";
                    }
                }
            }
        }
        catch { /* non-critical — fall through to default */ }
    }
}
// ---------------------------------------------------------------------------
// Hostname — install-time flag, not solely a brand attribute.
//
// Priority: --hostname flag > OS detection (upgrade) > BRAND.hostname (fresh install).
// When --hostname is provided, it is set unconditionally — no detection, no preservation.
// ---------------------------------------------------------------------------
let HOSTNAME_FLAG;
const hostnameIdx = _args.indexOf("--hostname");
if (hostnameIdx !== -1) {
    const raw = _args[hostnameIdx + 1];
    if (!raw || raw.startsWith("--")) {
        console.error("Error: --hostname requires a value (e.g. --hostname muvin).");
        process.exit(1);
    }
    // RFC 1123: lowercase alphanumeric + hyphens, max 63 chars, no leading/trailing hyphen
    if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(raw)) {
        console.error(`Error: --hostname value '${raw}' is invalid. Must be lowercase letters, digits, and hyphens only (max 63 chars, no leading/trailing hyphen).`);
        process.exit(1);
    }
    HOSTNAME_FLAG = raw;
}
const PKG_VERSION = JSON.parse(readFileSync(resolve(import.meta.dirname, "../package.json"), "utf-8")).version;
initLogging();
console.log("================================================================");
console.log(`  ${BRAND.productName} — ${BRAND.tagline}.  (${BRAND.hostname} v${PKG_VERSION})`);
console.log("================================================================");
console.log(`  Install log: ${LOG_FILE}`);
console.log(`  Port: ${PORT} (${PORT_SOURCE})`);
if (HOSTNAME_FLAG)
    console.log(`  Hostname: ${HOSTNAME_FLAG} (from --hostname flag)`);
console.log("");
logDiagnostics("pre-flight");
try {
    installSystemDeps();
    installNodejs();
    installClaudeCode();
    installNeo4j();
    installOllama();
    installCloudflared();
    installWhisperCpp();
    deployPayload(); // Must happen before ensureNeo4jPassword — restores config backup
    ensureNeo4jPassword(); // Now config/.neo4j-password is available if it existed before
    buildPlatform();
    setupVncViewer();
    setupAccount();
    installService();
    console.log("");
    console.log("================================================================");
    console.log("");
    console.log(`  Open in your browser: http://${DEVICE_HOSTNAME}.local:${PORT}`);
    console.log("");
    console.log("================================================================");
}
catch (err) {
    console.error("");
    console.error(`Setup failed: ${err instanceof Error ? err.message : String(err)}`);
    console.error(`  Full log: ${LOG_FILE}`);
    logDiagnostics("post-failure");
    process.exit(1);
}
