import { execFileSync, spawn } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, unlinkSync, copyFileSync, statSync, } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { Resolver } from "node:dns/promises";
let cachedBrand = null;
export function loadBrand() {
    if (cachedBrand)
        return cachedBrand;
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT env var is not set — cannot determine brand configuration");
    }
    const brandPath = join(platformRoot, "config", "brand.json");
    if (!existsSync(brandPath)) {
        throw new Error(`brand.json not found at ${brandPath} — PLATFORM_ROOT may be incorrect`);
    }
    const parsed = JSON.parse(readFileSync(brandPath, "utf-8"));
    cachedBrand = {
        configDir: parsed.configDir ?? ".maxy",
        productName: parsed.productName ?? "Maxy",
        vncDisplay: typeof parsed.vncDisplay === "number" ? parsed.vncDisplay : 99,
    };
    return cachedBrand;
}
/**
 * Test-only: reset the cached brand so a subsequent loadBrand() re-reads
 * the manifest. Production code never calls this.
 */
export function _resetBrandCache() {
    cachedBrand = null;
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
let cachedBinaryPath = null;
const SEARCH_PATHS = [
    "/usr/local/bin/cloudflared",
    "/usr/bin/cloudflared",
    join(homedir(), ".local/bin/cloudflared"),
];
export function findBinary() {
    if (cachedBinaryPath)
        return cachedBinaryPath;
    try {
        cachedBinaryPath = execFileSync("which", ["cloudflared"], {
            encoding: "utf-8",
            timeout: 5000,
        }).trim();
        return cachedBinaryPath;
    }
    catch {
        // not on PATH
    }
    for (const p of SEARCH_PATHS) {
        if (existsSync(p)) {
            cachedBinaryPath = p;
            return p;
        }
    }
    return null;
}
export function isInstalled() {
    return findBinary() !== null;
}
export function version() {
    const bin = findBinary();
    if (!bin)
        return null;
    try {
        const out = execFileSync(bin, ["--version"], {
            encoding: "utf-8",
            timeout: 5000,
        });
        const match = out.match(/cloudflared version (\S+)/);
        return match?.[1] ?? out.trim();
    }
    catch {
        return null;
    }
}
function bindingDir() {
    return join(homedir(), loadBrand().configDir, "cloudflare");
}
function bindingFile() {
    return join(bindingDir(), "account-binding.json");
}
export function readAccountBinding() {
    try {
        const path = bindingFile();
        if (!existsSync(path))
            return null;
        const parsed = JSON.parse(readFileSync(path, "utf-8"));
        if (typeof parsed?.accountId !== "string" || typeof parsed?.boundAt !== "string") {
            console.error(`[cloudflare:binding] malformed account-binding.json at ${path} — treating as absent`);
            return null;
        }
        return { accountId: parsed.accountId, boundAt: parsed.boundAt };
    }
    catch (err) {
        console.error(`[cloudflare:binding] failed to read ${bindingFile()}: ${err}`);
        return null;
    }
}
/**
 * @internal — production code MUST go through `materializeBinding` so the
 * source-of-truth lifecycle (fresh-login vs migration) is preserved and
 * logged. Exported only so unit tests can pre-seed bindings without
 * re-implementing the file format.
 */
export function writeAccountBinding(accountId) {
    const dir = bindingDir();
    mkdirSync(dir, { recursive: true });
    const binding = { accountId, boundAt: new Date().toISOString() };
    writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
    return binding;
}
function resetAccountBinding() {
    const path = bindingFile();
    try {
        unlinkSync(path);
        console.error(`[cloudflare:binding] reset ${path}`);
        return true;
    }
    catch (err) {
        const code = err.code;
        if (code === "ENOENT")
            return false;
        console.error(`[cloudflare:binding] failed to reset ${path}: ${err}`);
        return false;
    }
}
export function logRefuse(detail) {
    const brand = (() => {
        try {
            return loadBrand();
        }
        catch {
            return null;
        }
    })();
    const f = detail.fields ?? {};
    const fields = {
        reason: detail.reason,
        brand: brand?.productName ?? "unknown",
        boundAccountId: f.boundAccountId ?? null,
        certAccountId: f.certAccountId ?? null,
        requestedDomain: f.requestedDomain ?? null,
        requestedHostname: f.requestedHostname ?? null,
        actualFqdn: f.actualFqdn ?? null,
        tunnelId: f.tunnelId ?? null,
    };
    console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
}
/**
 * The only recovery instruction — every refusal that involves the wrong
 * Cloudflare account points here. Single source of truth so the four
 * surfaces that quote it (tunnel-status, tunnel-add-hostname, personal
 * assistant template, setup-tunnel skill) cannot drift.
 */
export const DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION = "Open Cloudflare in your browser. Look at the name in the top-left — " +
    "that is the Cloudflare account this laptop will talk to. If it is not " +
    "the account that owns the domain you want to use, switch accounts using " +
    "the top-left dropdown. Once the correct account is showing, tell me and " +
    "I will start the sign-in again.";
export function recoveryMessage() {
    return ("Recovery: confirm you are signed into the correct Cloudflare account " +
        "in your browser, then ask me to run tunnel-login. If you recently switched " +
        "accounts, ask me to run tunnel-login with force=true so the current sign-in " +
        "is cleared before the new one starts.");
}
// ---------------------------------------------------------------------------
// Reset auth — unlinks cert.pem (both paths) and the account binding
//
// Kill order matters: terminate any in-flight `cloudflared tunnel login`
// process BEFORE deleting cert state, otherwise its browser handshake can
// complete between our unlink and return, writing a fresh cert.pem to the
// legacy path that findCert() would silently re-import on the next read.
// The kill-then-unlink ordering preserves the same race-close pattern.
// ---------------------------------------------------------------------------
export function resetAuth() {
    const result = { deletedCert: false, deletedBinding: false };
    const loginState = readLoginState();
    if (loginState?.pid && isProcessAlive(loginState.pid)) {
        try {
            process.kill(loginState.pid, "SIGTERM");
            console.error(`[tunnel-login] killed stale login process PID ${loginState.pid}`);
        }
        catch {
            // Process may have exited between check and kill
        }
    }
    clearLoginState();
    const cp = certPath();
    try {
        unlinkSync(cp);
        result.deletedCert = true;
        console.error(`[tunnel-login] deleted: ${cp}`);
    }
    catch (err) {
        const code = err.code;
        if (code === "ENOENT") {
            // File already gone — no-op
        }
        else {
            console.error(`[tunnel-login] failed to delete cert.pem ${cp}: ${err}`);
        }
    }
    const legacyCp = legacyCertPath();
    try {
        unlinkSync(legacyCp);
        console.error(`[tunnel-login] deleted legacy cert.pem: ${legacyCp}`);
    }
    catch (err) {
        const code = err.code;
        if (code === "ENOENT") {
            console.error(`[tunnel-login] legacy cert.pem absent: ${legacyCp}`);
        }
        else {
            console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
        }
    }
    result.deletedBinding = resetAccountBinding();
    return result;
}
// ---------------------------------------------------------------------------
// cert.pem — written by `cloudflared tunnel login`
//
// Stored under ~/{configDir}/cloudflared/ so each brand has its own cert.
// Function (not const) because loadBrand() requires PLATFORM_ROOT at runtime.
// ---------------------------------------------------------------------------
function certPath() {
    return join(homedir(), loadBrand().configDir, "cloudflared/cert.pem");
}
// cloudflared tunnel login always writes here regardless of --origincert.
// Same literal lives in the installer's cloudflared migration block.
function legacyCertPath() {
    return join(homedir(), ".cloudflared", "cert.pem");
}
/**
 * Find cert.pem, checking the brand-specific path first, then cloudflared's
 * default location. cloudflared tunnel login ALWAYS writes to
 * ~/.cloudflared/cert.pem regardless of --origincert, so we must check
 * there as a fallback.
 *
 * Move-on-read: when the cert is found at the legacy path, copy it to the
 * brand path AND unlink the legacy file. Without the unlink, the legacy
 * file would survive `tunnel-login force=true` (which only deletes brand
 * paths) and silently re-import on the next read — defeating the recovery
 * path operators rely on after switching Cloudflare accounts.
 */
function findCert() {
    const brandPath = certPath();
    if (existsSync(brandPath))
        return brandPath;
    const defaultPath = legacyCertPath();
    if (existsSync(defaultPath)) {
        const dir = join(homedir(), loadBrand().configDir, "cloudflared");
        mkdirSync(dir, { recursive: true });
        copyFileSync(defaultPath, brandPath);
        try {
            unlinkSync(defaultPath);
            console.error(`[tunnel-login] cert.pem migrated from ${defaultPath} to ${brandPath}`);
        }
        catch (err) {
            console.error(`[tunnel-login] WARNING: cert.pem migrated to ${brandPath} but legacy ${defaultPath} could not be removed: ${err}`);
        }
        return brandPath;
    }
    return null;
}
export function hasCert() {
    return findCert() !== null;
}
/**
 * Parse the ARGO TUNNEL TOKEN PEM block from cert.pem to extract the
 * AccountTag. Only the account ID is returned — nothing else in cert.pem
 * is used by this codebase. The cert itself stays on disk; `cloudflared`
 * shell-outs read it via `--origincert` for tunnel CRUD and DNS routing.
 */
export function parseCertPem() {
    try {
        const cert = findCert();
        if (!cert)
            return null;
        const pem = readFileSync(cert, "utf-8");
        const beginMarker = "-----BEGIN ARGO TUNNEL TOKEN-----";
        const endMarker = "-----END ARGO TUNNEL TOKEN-----";
        const beginIdx = pem.indexOf(beginMarker);
        const endIdx = pem.indexOf(endMarker);
        if (beginIdx === -1 || endIdx === -1)
            return null;
        const b64 = pem
            .slice(beginIdx + beginMarker.length, endIdx)
            .replace(/\s+/g, "");
        const json = Buffer.from(b64, "base64").toString("utf-8");
        const data = JSON.parse(json);
        const accountId = data.AccountTag ?? data.accountTag ?? data.AccountID ?? data.accountID ?? data.accountId;
        if (!accountId)
            return null;
        return { accountId };
    }
    catch {
        return null;
    }
}
/** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
export class CloudflareRefusalError extends Error {
    refusal;
    constructor(detail) {
        super(detail.message);
        this.name = "CloudflareRefusalError";
        this.refusal = detail;
    }
}
export function validateAuth() {
    const creds = parseCertPem();
    const binding = readAccountBinding();
    return {
        hasCert: !!creds,
        hasBinding: !!binding,
        bound: !!(creds && binding && creds.accountId === binding.accountId),
        certAccountId: creds?.accountId ?? null,
        boundAccountId: binding?.accountId ?? null,
    };
}
/**
 * Establish the device-local account binding from cert.pem. Used by:
 *   (1) tunnel-login fresh-success path — first time creds are derived.
 *   (2) migration path — when cert.pem exists from a prior install but no
 *       binding has yet been written (silent materialization, since the
 *       cert was already trust-established by the operator's prior login).
 */
export function materializeBinding(source) {
    const creds = parseCertPem();
    if (!creds) {
        throw new Error("Cannot materialize binding — cert.pem absent or unparseable");
    }
    const binding = writeAccountBinding(creds.accountId);
    console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
    return binding;
}
// Alias-domains read/write lived here historically; the MCP tool that wrote
// it was removed, and the writer moved to
// platform/ui/app/lib/alias-domains.ts so the reader (server/index.ts
// watchFile loop) and the writer share one process. The file on disk
// (~/{configDir}/alias-domains.json) is still the interchange format.
// ---------------------------------------------------------------------------
// CLI-based tunnel operations
//
// `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS routing.
// Shelling out to the CLI is architecturally indistinguishable from the
// operator clicking the same action in the dashboard — both use the
// operator's OAuth-bound cert, nothing more.
// ---------------------------------------------------------------------------
// Name convention: `{product}-{device}` — e.g. "maxy-laptop". Encodes product identity and physical location in one label.
export function createTunnelCli(name) {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    const cert = findCert();
    if (!cert)
        throw new Error("No cert.pem found — run tunnel-login first");
    // Check for existing tunnel with this name first
    try {
        const listOutput = execFileSync(bin, ["tunnel", "--origincert", cert, "list", "-o", "json"], { encoding: "utf-8", timeout: 30000 });
        const tunnels = JSON.parse(listOutput);
        const existing = tunnels.find((t) => t.name === name && !t.deleted_at);
        if (existing) {
            console.error(`[tunnel-create] CLI: tunnel "${name}" already exists (${existing.id})`);
            const brandCredPath = join(homedir(), loadBrand().configDir, "cloudflared", `${existing.id}.json`);
            const defaultCredPath = join(homedir(), ".cloudflared", `${existing.id}.json`);
            if (!existsSync(brandCredPath) && existsSync(defaultCredPath)) {
                const dir = join(homedir(), loadBrand().configDir, "cloudflared");
                mkdirSync(dir, { recursive: true });
                copyFileSync(defaultCredPath, brandCredPath);
                console.error(`[tunnel-create] credentials copied to ${brandCredPath}`);
            }
            return {
                tunnelId: existing.id,
                tunnelName: name,
                credentialsPath: brandCredPath,
            };
        }
    }
    catch {
        // list failed — proceed to create
    }
    const output = execFileSync(bin, ["tunnel", "--origincert", cert, "create", name], { encoding: "utf-8", timeout: 30000 });
    console.error(`[tunnel-create] CLI: ${output.trim()}`);
    // Parse tunnel ID from output: "Created tunnel <name> with id <uuid>"
    const idMatch = output.match(/with id ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
    if (!idMatch) {
        throw new Error(`cloudflared tunnel create succeeded but could not parse tunnel ID from output: ${output.trim()}`);
    }
    const tunnelId = idMatch[1];
    // cloudflared writes credentials to ~/.cloudflared/<id>.json — copy to brand path
    const defaultCredPath = join(homedir(), ".cloudflared", `${tunnelId}.json`);
    const brandDir = join(homedir(), loadBrand().configDir, "cloudflared");
    mkdirSync(brandDir, { recursive: true });
    const brandCredPath = join(brandDir, `${tunnelId}.json`);
    if (existsSync(defaultCredPath)) {
        copyFileSync(defaultCredPath, brandCredPath);
        console.error(`[tunnel-create] credentials copied to ${brandCredPath}`);
    }
    return { tunnelId, tunnelName: name, credentialsPath: brandCredPath };
}
export function getTunnelTokenCli(tunnelId) {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    const cert = findCert();
    if (!cert)
        throw new Error("No cert.pem found — run tunnel-login first");
    const output = execFileSync(bin, ["tunnel", "--origincert", cert, "token", tunnelId], { encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"] });
    const token = output.trim();
    if (!token) {
        throw new Error("cloudflared tunnel token returned empty output");
    }
    console.error(`[tunnel-token] CLI: obtained connector token for tunnel ${tunnelId}`);
    return token;
}
/**
 * Write a local config.yml for cloudflared tunnel run.
 * This is the ONLY tunnel startup configuration — no remote config, no API.
 * hostnames: the actual FQDNs to route (e.g. ["joel.maxy.bot", "shop.maxy.bot"]).
 */
export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
    const dir = join(homedir(), loadBrand().configDir, "cloudflared");
    mkdirSync(dir, { recursive: true });
    const configPath = join(dir, "config.yml");
    const ingressLines = hostnames.flatMap((h) => [
        `  - hostname: ${h}`,
        `    service: http://localhost:${port}`,
    ]);
    const yaml = [
        `tunnel: ${tunnelId}`,
        `credentials-file: ${credentialsPath}`,
        `ingress:`,
        ...ingressLines,
        `  - service: http_status:404`,
        "",
    ].join("\n");
    writeFileSync(configPath, yaml, "utf-8");
    console.error(`[tunnel-create] config written to ${configPath} hostnames=${JSON.stringify(hostnames)}`);
    return configPath;
}
// ---------------------------------------------------------------------------
// Parse configured hostnames from config.yml
//
// `tunnel-status`'s end-to-end probe reads the hostnames from config.yml
// rather than from tunnel.state — config.yml is the authoritative source
// for what `cloudflared` is actually ingressing. State-file hostnames may
// drift from on-disk config; the running tunnel only honours config.yml.
// ---------------------------------------------------------------------------
export function parseConfiguredHostnames(configYmlPath) {
    try {
        if (!existsSync(configYmlPath))
            return [];
        const yaml = readFileSync(configYmlPath, "utf-8");
        const hostnames = [];
        for (const line of yaml.split("\n")) {
            const m = line.match(/^\s*-\s*hostname:\s*(\S+)\s*$/);
            if (m)
                hostnames.push(m[1]);
        }
        return hostnames;
    }
    catch (err) {
        console.error(`[tunnel-status] failed to parse ${configYmlPath}: ${err}`);
        return [];
    }
}
// ---------------------------------------------------------------------------
// Nameserver pre-flight — zone-parent routability without any API
//
// Before shelling `cloudflared tunnel route dns`, confirm the hostname's
// registrable parent has its NS records pointing at Cloudflare. When they
// don't, `cloudflared` silently falls through to whichever zone the cert's
// account does own and creates a malformed CNAME (the historical
// `admin.maxy.bot.joelsmalley.xyz` failure mode). Refusing pre-flight is
// the only way to prevent that drift from inside this codebase.
//
// Heuristic for registrable parent: take the last two labels. This fails
// for multi-label public suffixes (e.g. `.co.uk`, `.com.au`) — not worth
// correctness here because Cloudflare's own sign-up flow rejects those
// at zone-add time, so the NS lookup will also fail for them, giving the
// same "not on Cloudflare yet" message.
// ---------------------------------------------------------------------------
function registrableParent(hostname) {
    const labels = hostname.toLowerCase().split(".");
    if (labels.length <= 2)
        return hostname.toLowerCase();
    return labels.slice(-2).join(".");
}
export async function checkZoneParentOnCloudflare(hostname) {
    const zoneParent = registrableParent(hostname);
    const resolver = new Resolver();
    resolver.setServers(["1.1.1.1", "8.8.8.8"]);
    try {
        const ns = await resolver.resolveNs(zoneParent);
        const onCloudflare = ns.some((n) => /\.ns\.cloudflare\.com\.?$/i.test(n));
        return { zoneParent, nameservers: ns, onCloudflare };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { zoneParent, nameservers: [], onCloudflare: false, error: msg };
    }
}
// ---------------------------------------------------------------------------
// CLI-based DNS routing — shells `cloudflared tunnel route dns` with defence
// in depth against the known malformed-CNAME fall-through.
//
//   (1) Pre-flight: zone-parent NS records must point to Cloudflare.
//       Refusal reason `hostname-zone-not-routable` tells the operator
//       to add the domain in the dashboard before coming back.
//   (2) Post-flight: the FQDN `cloudflared` wrote must equal the requested
//       hostname. A mismatch means `cloudflared` routed to a zone owned by
//       the currently-bound account instead of the intended zone — usually
//       because the device signed into the wrong Cloudflare account.
//       Refusal reason `post-flight-fqdn-mismatch` tells the operator to
//       check the dashboard account name and re-login if wrong.
//
// Authentication pre-condition is just "cert.pem + binding + they agree" —
// enforced by validateAuth().bound. No API client is constructed anywhere.
// ---------------------------------------------------------------------------
export async function routeDnsCli(tunnelId, hostname) {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    const auth = validateAuth();
    if (!auth.bound) {
        const reason = auth.hasCert && auth.hasBinding
            ? "account-drift"
            : "unbound-device";
        const detail = {
            reason,
            message: `This laptop is not signed into Cloudflare yet${auth.hasCert ? " (sign-in has drifted since it was set up)" : ""}. ` +
                `${DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`,
            fields: {
                boundAccountId: auth.boundAccountId,
                certAccountId: auth.certAccountId,
                requestedHostname: hostname,
            },
        };
        logRefuse(detail);
        throw new CloudflareRefusalError(detail);
    }
    // (1) Pre-flight zone-parent routability.
    console.error(`[cloudflare:tunnel-add-hostname:preflight] hostname=${hostname}`);
    const preflight = await checkZoneParentOnCloudflare(hostname);
    if (!preflight.onCloudflare) {
        const detail = {
            reason: "hostname-zone-not-routable",
            message: `The domain ${preflight.zoneParent} is not on Cloudflare yet — this laptop cannot ` +
                `reach it. Open Cloudflare in your browser and add ${preflight.zoneParent} under the ` +
                `Cloudflare account you want to use (Websites → Add a site). When the domain shows as ` +
                `Active, tell me and I will add the address again.`,
            fields: {
                requestedHostname: hostname,
                requestedDomain: preflight.zoneParent,
            },
        };
        logRefuse(detail);
        throw new CloudflareRefusalError(detail);
    }
    const cert = findCert();
    if (!cert)
        throw new Error("No cert.pem found — validateAuth() should have refused first");
    let output;
    try {
        output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
    }
    catch (err) {
        const errObj = err;
        const rawStderr = errObj.stderr;
        const stderrMsg = typeof rawStderr === "string"
            ? rawStderr
            : Buffer.isBuffer(rawStderr) ? rawStderr.toString("utf-8") : "";
        const msg = stderrMsg || errObj.message || String(err);
        if (msg.includes("already exists")) {
            console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
            return { created: false, output: msg, fqdn: hostname };
        }
        throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
    }
    // (2) Post-flight FQDN assertion.
    const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
    if (!fqdnMatch) {
        console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
        return { created: true, output: output.trim(), fqdn: hostname };
    }
    const actualFqdn = fqdnMatch[1];
    if (actualFqdn !== hostname) {
        // cloudflared wrote the address under a different domain than requested.
        // That means the laptop is signed into a Cloudflare account that does
        // not own the target domain, and `cloudflared` fell through to whichever
        // domain the signed-in account does own. The agent cannot clean this up
        // (no API access by design) — the operator cleans it up in the dashboard.
        console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
            requestedHostname: hostname,
            actualFqdn,
            tunnelId,
            boundAccountId: auth.boundAccountId,
        })}`);
        const detail = {
            reason: "post-flight-fqdn-mismatch",
            message: `Cloudflare created the address under ${actualFqdn} instead of the requested ${hostname}. ` +
                `This laptop is signed into a Cloudflare account that does not own ${registrableParent(hostname)}. ` +
                `Two things to do: (a) open Cloudflare in your browser, sign into the account that does own ` +
                `${registrableParent(hostname)}, and tell me so I can re-login; (b) in the same browser, delete the ` +
                `stray record at ${actualFqdn} from the other account so it does not confuse anything.`,
            fields: {
                requestedHostname: hostname,
                actualFqdn,
                tunnelId,
                boundAccountId: auth.boundAccountId,
            },
        };
        logRefuse(detail);
        throw new CloudflareRefusalError(detail);
    }
    console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn}`);
    return { created: true, output: output.trim(), fqdn: actualFqdn };
}
// ---------------------------------------------------------------------------
// tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
// ---------------------------------------------------------------------------
function loginStatePath() {
    return join(homedir(), loadBrand().configDir, "cloudflared/login.state");
}
function readLoginState() {
    try {
        const p = loginStatePath();
        if (!existsSync(p))
            return null;
        return JSON.parse(readFileSync(p, "utf-8"));
    }
    catch {
        return null;
    }
}
function writeLoginState(state) {
    const dir = join(homedir(), loadBrand().configDir, "cloudflared");
    mkdirSync(dir, { recursive: true });
    writeFileSync(loginStatePath(), JSON.stringify(state), "utf-8");
}
function clearLoginState() {
    try {
        const p = loginStatePath();
        if (existsSync(p)) {
            writeFileSync(p, "", "utf-8");
        }
    }
    catch {
        // best-effort
    }
}
/**
 * Fingerprint emitted by `cloudflared tunnel login` into its log when its
 * browser-launch subcommand cannot reach a display. A plain substring — the
 * cloudflared log format is stable and regex gymnastics would hide the
 * failure mode, not detect it.
 *
 * Critical semantic distinction: this marker is a *courtesy*
 * warning — cloudflared attempted `xdg-open` against the argotunnel auth
 * URL, failed, wrote the marker, and then CONTINUED RUNNING. Its local
 * OAuth-callback listener is still alive; a user who opens the URL in any
 * reachable browser can still complete sign-in and cert.pem will still
 * land. Treating the marker as terminal loops the restart path
 * hermetically because the respawn will re-emit the same marker the first
 * time it tries to open a browser. `getLoginStatus` therefore only treats
 * the marker as *advisory* when the PID is alive — liveness + cert are
 * the source of truth for terminal-vs-live, not log content.
 */
const BROWSER_LAUNCH_FAILED_MARKER = "Failed to fetch resource";
function parseLoginLogForBrowserLaunchFailure(logPath, logOffsetBytes) {
    try {
        if (!existsSync(logPath))
            return false;
        // Read as Buffer so the byte offset is honoured exactly — logs are
        // mostly ASCII but using a byte-level slice protects against any
        // future multi-byte content producing a character-vs-byte drift.
        const buf = readFileSync(logPath);
        const sliced = buf.length > logOffsetBytes ? buf.subarray(logOffsetBytes) : Buffer.alloc(0);
        const content = sliced.toString("utf-8");
        return content.includes(BROWSER_LAUNCH_FAILED_MARKER);
    }
    catch (err) {
        console.error(`[cloudflare:tunnel-login:log-parse-error] path=${logPath} err=${err instanceof Error ? err.message : String(err)}`);
        return false;
    }
}
export function getLoginStatus() {
    const loginState = readLoginState();
    if (!loginState || !loginState.pid) {
        return { state: "idle" };
    }
    const alive = isProcessAlive(loginState.pid);
    const browserLaunchFailed = parseLoginLogForBrowserLaunchFailure(loginState.logPath, loginState.logOffset ?? 0);
    if (alive) {
        // The OAuth-callback listener is still running. The marker is advisory
        // only — the login flow can still complete if a human opens the URL.
        return { state: "in-progress", loginState, browserLaunchFailed };
    }
    // PID is dead. Cert presence decides terminal state vs TOCTOU-idle.
    if (findCert() !== null) {
        // Upstream (tunnel-login handler) checks cert before calling this, so
        // this branch is only reachable on a TOCTOU race where cert appeared
        // between the top-level check and here. Treat as idle so the handler
        // re-examines from the top on the next call.
        return { state: "idle" };
    }
    // Terminal failure. Reason encodes whether the courtesy marker was
    // emitted during the process's lifetime — useful for post-mortem grep
    // ("did cloudflared try and fail to open the browser before dying?").
    return {
        state: "failed",
        loginState,
        reason: browserLaunchFailed
            ? "login-process-exited-with-marker"
            : "login-process-exited-without-cert",
    };
}
/**
 * Resolve the spawn env for `cloudflared tunnel login`. `cloudflared`'s
 * first action after printing the auth URL is `xdg-open` against that URL
 * so the operator's browser jumps straight to the Authorize screen. The
 * MCP server's spawn context does not inherit DISPLAY (the chain platform
 * → claude-agent → MCP server drops it along the way), so `xdg-open` has
 * no X display to reach and the courtesy-fetch-resource marker lands in
 * the login log every time.
 *
 * When this brand's Xtigervnc Unix socket is live we plumb DISPLAY
 * explicitly so the browser launch has a chance of succeeding. The display
 * number is per-brand: Maxy=:99, Real Agent=:100. Xtigervnc
 * runs `-SecurityTypes None` locally, so XAUTHORITY is not required to
 * connect. When this brand's VNC socket is absent (headless /
 * not-yet-booted / native-mode device) we return null so the caller
 * spawns with the default inherited env, and the new classification
 * (marker alone is non-terminal) keeps the session alive via the
 * advisory path.
 */
function vncSpawnEnv() {
    const display = `:${loadBrand().vncDisplay}`;
    const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
    if (!existsSync(socket))
        return null;
    return {
        env: { ...process.env, DISPLAY: display },
        display,
    };
}
export function tunnelLogin() {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    const status = getLoginStatus();
    if (status.state === "in-progress") {
        console.error(`[cloudflare:tunnel-login:waiting] pid=${status.loginState.pid} authUrl=${status.loginState.authUrl}`);
        return Promise.resolve({ authUrl: status.loginState.authUrl, alreadyRunning: true });
    }
    const logDir = join(homedir(), loadBrand().configDir, "logs");
    mkdirSync(logDir, { recursive: true });
    const logPath = join(logDir, "cloudflared-login.log");
    const logFd = openSync(logPath, "a");
    // Record the log's byte size before the spawn writes anything. This is
    // the watermark for any marker parsing: every subsequent call slices
    // the log from this offset forward so historical markers from prior
    // attempts cannot be re-attributed to the new process.
    // narrowed the marker's role from terminal signal to advisory, so the
    // watermark is no longer load-bearing for loop prevention — but it is
    // still correct for attribution: a new process's courtesy-marker
    // advisory should not count log content written by its predecessor.
    const logOffset = existsSync(logPath) ? statSync(logPath).size : 0;
    const certDir = join(homedir(), loadBrand().configDir, "cloudflared");
    mkdirSync(certDir, { recursive: true });
    const originCert = join(certDir, "cert.pem");
    const vncEnv = vncSpawnEnv();
    const displayField = vncEnv ? vncEnv.display : "inherit";
    console.error(`[cloudflare:tunnel-login:spawn] log=${logPath} origincert=${originCert} ` +
        `log-offset=${logOffset} display=${displayField}`);
    const child = spawn(bin, ["tunnel", "--origincert", originCert, "login"], {
        stdio: ["ignore", logFd, logFd],
        detached: true,
        env: vncEnv?.env,
    });
    child.unref();
    if (!child.pid) {
        throw new Error("Failed to spawn cloudflared tunnel login — no PID returned");
    }
    console.error(`[cloudflare:tunnel-login:spawn] pid=${child.pid}`);
    const urlPattern = /https:\/\/dash\.cloudflare\.com[^\s]*/;
    return new Promise((resolve, reject) => {
        let elapsed = 0;
        const interval = 500;
        const timeout = 15000;
        const poller = setInterval(() => {
            elapsed += interval;
            if (!isProcessAlive(child.pid)) {
                clearInterval(poller);
                console.error(`[cloudflare:tunnel-login:failed] reason=process-exited-before-auth-url pid=${child.pid}`);
                clearLoginState();
                try {
                    const output = readFileSync(logPath, "utf-8").slice(-500);
                    reject(new Error(`cloudflared exited before producing an auth URL. Last output: ${output}`));
                }
                catch {
                    reject(new Error("cloudflared exited before producing an auth URL"));
                }
                return;
            }
            try {
                const content = readFileSync(logPath, "utf-8");
                const match = content.match(urlPattern);
                if (match) {
                    clearInterval(poller);
                    const authUrl = match[0];
                    console.error(`[cloudflare:tunnel-login:waiting] pid=${child.pid} authUrl=${authUrl}`);
                    writeLoginState({
                        pid: child.pid,
                        startedAt: Date.now(),
                        authUrl,
                        logPath,
                        logOffset,
                    });
                    resolve({ authUrl });
                    return;
                }
            }
            catch {
                // log file may not be flushed yet — retry
            }
            if (elapsed >= timeout) {
                clearInterval(poller);
                console.error(`[cloudflare:tunnel-login:failed] reason=auth-url-timeout pid=${child.pid} timeout-ms=${timeout}`);
                try {
                    process.kill(child.pid, "SIGTERM");
                }
                catch { /* already gone */ }
                clearLoginState();
                try {
                    const output = readFileSync(logPath, "utf-8").slice(-500);
                    reject(new Error(`cloudflared did not produce an auth URL within ${timeout / 1000} seconds. Output: ${output}`));
                }
                catch {
                    reject(new Error(`cloudflared did not produce an auth URL within ${timeout / 1000} seconds`));
                }
            }
        }, interval);
    });
}
// ---------------------------------------------------------------------------
// Process management — detached, survives MCP server exit
// ---------------------------------------------------------------------------
function statePath() {
    return join(homedir(), loadBrand().configDir, "cloudflared/tunnel.state");
}
function readState() {
    try {
        const p = statePath();
        if (!existsSync(p))
            return null;
        return JSON.parse(readFileSync(p, "utf-8"));
    }
    catch {
        return null;
    }
}
function writeState(state) {
    const dir = join(homedir(), loadBrand().configDir, "cloudflared");
    mkdirSync(dir, { recursive: true });
    writeFileSync(statePath(), JSON.stringify(state), "utf-8");
}
function isProcessAlive(pid) {
    if (pid === null || pid <= 0)
        return false;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
export function getPersistedTunnelId() {
    return readState()?.tunnelId ?? null;
}
export function getPersistedDomain() {
    return readState()?.domain ?? null;
}
export function getPersistedState() {
    return readState();
}
export function saveTunnelIdentity(params) {
    writeState({
        tunnelId: params.tunnelId,
        tunnelName: params.tunnelName,
        domain: params.domain,
        configPath: params.configPath,
        credentialsPath: params.credentialsPath,
        adminHostname: params.adminHostname,
        publicHostname: params.publicHostname,
        pid: null,
        startedAt: null,
    });
}
/**
 * Return the actual hostnames from persisted state (or derive from domain
 * for pre-521 state files).
 */
export function getPersistedHostnames() {
    const state = readState();
    if (!state)
        return [];
    if (state.adminHostname) {
        return state.publicHostname
            ? [state.adminHostname, state.publicHostname]
            : [state.adminHostname];
    }
    if (state.domain) {
        console.error(`[tunnel] backward-compat: deriving hostnames from domain=${state.domain} (no adminHostname in state)`);
        return [`admin.${state.domain}`, `public.${state.domain}`];
    }
    return [];
}
export async function probeHostname(hostname, expectedTunnelId) {
    const resolver = new Resolver();
    resolver.setServers(["1.1.1.1", "8.8.8.8"]);
    let cname = null;
    try {
        const cnames = await resolver.resolveCname(hostname);
        cname = cnames[0] ?? null;
    }
    catch (err) {
        const code = err.code;
        if (code === "ENODATA") {
            // Proxied records can flatten — try A records as a secondary signal
            // that the name exists, but without a CNAME we cannot confirm this
            // tunnel. Treat as cname-points-elsewhere (name exists but does not
            // point here) only when an A record is present; otherwise dns-missing.
            try {
                await resolver.resolve4(hostname);
                cname = null; // flattened — still need edge probe to confirm
            }
            catch {
                return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
            }
        }
        else if (code === "ENOTFOUND" || code === "ESERVFAIL") {
            return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
        }
        else {
            console.error(`[cloudflare:tunnel-status:probe] unexpected DNS error for ${hostname}: ${err}`);
            return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
        }
    }
    if (expectedTunnelId && cname) {
        const cnameLc = cname.toLowerCase().replace(/\.$/, "");
        const expected = `${expectedTunnelId}.cfargotunnel.com`.toLowerCase();
        if (cnameLc !== expected) {
            return { hostname, resolves: true, cname, httpStatus: null, failureMode: "cname-points-elsewhere" };
        }
    }
    // HTTPS probe — any response proves edge routing is wired up.
    let httpStatus = null;
    try {
        const controller = new AbortController();
        const to = setTimeout(() => controller.abort(), 10000);
        try {
            const res = await fetch(`https://${hostname}/`, {
                method: "GET",
                redirect: "manual",
                signal: controller.signal,
            });
            httpStatus = res.status;
        }
        finally {
            clearTimeout(to);
        }
    }
    catch (err) {
        console.error(`[cloudflare:tunnel-status:probe] HTTPS probe failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
        return { hostname, resolves: true, cname, httpStatus: null, failureMode: "edge-unreachable" };
    }
    // Cloudflare edge error page status codes that indicate the tunnel is
    // either not connected (530) or the hostname is not served by any tunnel
    // (1033/1016 surface as 530 or 502 at the HTTP layer; Cloudflare sometimes
    // uses 502/503 for unreachable origins). Any 5xx from the edge with
    // missing CNAME match means the tunnel-edge wiring is wrong.
    if (httpStatus >= 500 && httpStatus < 600) {
        return { hostname, resolves: true, cname, httpStatus, failureMode: "tunnel-not-matched" };
    }
    return { hostname, resolves: true, cname, httpStatus, failureMode: "ok" };
}
export async function getStatus(domain) {
    const auth = validateAuth();
    const state = readState();
    const running = state !== null && isProcessAlive(state.pid);
    const effectiveDomain = domain ?? state?.domain ?? null;
    const persistedHostnames = getPersistedHostnames();
    const configuredHostnames = state?.configPath
        ? parseConfiguredHostnames(state.configPath)
        : [];
    // Probe the authoritative set (config.yml). If config.yml is absent, fall
    // back to persisted hostnames so partial-setup states still surface.
    const hostnamesToProbe = configuredHostnames.length > 0
        ? configuredHostnames
        : persistedHostnames;
    let probes = [];
    let boundAccountOwnsHostnames = false;
    let healthy = false;
    let unhealthyReason = null;
    if (!running || !state?.tunnelId || hostnamesToProbe.length === 0) {
        unhealthyReason = !running
            ? "not-running"
            : "no-tunnel-configured";
    }
    else {
        probes = await Promise.all(hostnamesToProbe.map((h) => probeHostname(h, state.tunnelId)));
        // A probe reaching the edge with a non-5xx response proves the signed-in
        // account owns the domain: the edge only routes to this tunnel's origin
        // when the domain's DNS on this account points at this tunnel. The CNAME
        // check inside probeHostname already short-circuits the "points to a
        // different tunnel" case to `cname-points-elsewhere` — so here we trust
        // `ok`. We do not gate on `p.cname !== null` because Cloudflare can flatten
        // CNAMEs (zone-apex records, some proxy configurations) — in that case
        // cname is null but the HTTPS response is still authoritative.
        boundAccountOwnsHostnames = probes.some((p) => p.failureMode === "ok");
        healthy = probes.every((p) => p.failureMode === "ok");
        if (!healthy) {
            // Distinguishing "wrong account" from "right account, tunnel broken":
            // a probe that resolved DNS at all is evidence the domain lives on
            // some Cloudflare account (the NS is Cloudflare's). A probe that
            // resolved AND doesn't point elsewhere is evidence this laptop's
            // account owns it — any other failure mode (edge-unreachable,
            // tunnel-not-matched, or a healthy flattened probe) indicates the
            // DNS points at *something* on the signed-in account, just not
            // working. Only when every probe is `dns-missing` or
            // `cname-points-elsewhere` do we conclude the bound account does
            // not own the hostnames.
            const anyCnamePointsHere = probes.some((p) => p.resolves &&
                p.failureMode !== "cname-points-elsewhere" &&
                p.failureMode !== "dns-missing");
            unhealthyReason = anyCnamePointsHere
                ? "hostname-probes-failed"
                : "bound-account-does-not-own-hostname";
        }
    }
    if (unhealthyReason === "bound-account-does-not-own-hostname") {
        // Surface the structured refusal reason — same channel the
        // agent-facing text uses, but keyed on the enum.
        logRefuse({
            reason: "bound-account-does-not-own-hostname",
            message: "tunnel-status probe: no configured hostname's DNS lands on this tunnel. " +
                `${DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`,
            fields: {
                boundAccountId: auth.boundAccountId,
                certAccountId: auth.certAccountId,
                tunnelId: state?.tunnelId ?? undefined,
            },
        });
    }
    // Single-line branch-selection observability. The handler's trailing text
    // is driven off unhealthyReason + per-hostname failureMode; logging the
    // enum here means the branch taken is post-diagnosable from MCP stderr
    // without having to parse the returned JSON.
    console.error(`[cloudflare:tunnel-status:result] reason=${unhealthyReason ?? "healthy"} ` +
        `boundAccountOwnsHostnames=${boundAccountOwnsHostnames} ` +
        `probes=${probes.map((p) => `${p.hostname}:${p.failureMode}`).join(",") || "none"}`);
    return {
        installed: isInstalled(),
        version: version(),
        hasCert: auth.hasCert,
        hasBinding: auth.hasBinding,
        bound: auth.bound,
        certAccountId: auth.certAccountId,
        boundAccountId: auth.boundAccountId,
        running,
        pid: running ? state.pid : null,
        tunnelId: state?.tunnelId ?? null,
        domain: effectiveDomain,
        configuredHostnames,
        persistedHostnames,
        adminHostname: state?.adminHostname ?? (effectiveDomain ? `admin.${effectiveDomain}` : null),
        publicHostname: state?.publicHostname ?? (effectiveDomain ? `public.${effectiveDomain}` : null),
        upSince: running ? state.startedAt : null,
        probes,
        boundAccountOwnsHostnames,
        healthy,
        unhealthyReason,
    };
}
export function startTunnel(params) {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    const cert = findCert();
    if (!cert)
        throw new Error("No cert.pem found — run tunnel-login first");
    const state = readState();
    if (state && isProcessAlive(state.pid)) {
        throw new Error(`Tunnel is already running (PID ${state.pid})`);
    }
    const logDir = join(homedir(), loadBrand().configDir, "logs");
    mkdirSync(logDir, { recursive: true });
    const logPath = join(logDir, "cloudflared.log");
    const logFd = openSync(logPath, "a");
    const child = spawn(bin, ["--origincert", cert, "--config", params.configPath, "tunnel", "run"], {
        stdio: ["ignore", logFd, logFd],
        detached: true,
    });
    child.unref();
    if (!child.pid)
        throw new Error("Failed to spawn tunnel process");
    console.error(`[tunnel-enable] started PID ${child.pid} with config ${params.configPath}`);
    const existingState = readState();
    writeState({
        pid: child.pid,
        tunnelId: params.tunnelId,
        tunnelName: params.tunnelName,
        domain: params.domain,
        configPath: params.configPath,
        credentialsPath: params.credentialsPath,
        adminHostname: existingState?.adminHostname ?? null,
        publicHostname: existingState?.publicHostname ?? null,
        startedAt: Date.now(),
    });
}
export function stopTunnel() {
    const state = readState();
    if (!state)
        return;
    const pid = state.pid;
    if (pid !== null && isProcessAlive(pid)) {
        try {
            process.kill(pid, "SIGTERM");
        }
        catch {
            // process may have exited between check and kill
        }
        setTimeout(() => {
            if (isProcessAlive(pid)) {
                try {
                    process.kill(pid, "SIGKILL");
                }
                catch {
                    /* already gone */
                }
            }
        }, 5000);
    }
    writeState({ ...state, pid: null, startedAt: null });
}
//# sourceMappingURL=cloudflared.js.map