import crypto from 'node:crypto';
import net from 'node:net';
import os from 'node:os';
import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as readline from 'node:readline';
import QRCode from 'qrcode';
import { detectPublicIp, ensureFirewallAllowsPort, normalizeFirewallSource, pickSudoMode, setupNginxProxy, validateDeploymentUrl } from './proxySetup.js';
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested)
// ---------------------------------------------------------------------------
export function generateSessionSecret() {
    return crypto.randomBytes(48).toString('base64url');
}
export function generateAgentToken() {
    return crypto.randomBytes(32).toString('base64url');
}
export function originOf(url) {
    return new URL(url).origin;
}
// First non-internal IPv4 we can find, or 127.0.0.1 if none. Used to pick a
// sensible bind address for main and to default the URL a secondary advertises
// to its main.
export function detectBindHost(interfaces) {
    for (const list of Object.values(interfaces)) {
        for (const entry of list ?? []) {
            // node >=18 reports family as 'IPv4' (string) or 4 (number) on older builds
            const isV4 = entry.family === 'IPv4' || entry.family === 4;
            if (isV4 && !entry.internal) {
                return entry.address;
            }
        }
    }
    return '127.0.0.1';
}
export function detectAgentUrl(interfaces, port, protocol = 'http') {
    return `${protocol}://${detectBindHost(interfaces)}:${port}`;
}
export function defaultAgentUrlForMain(host, port, interfaces = os.networkInterfaces(), protocol = 'http') {
    const normalizedHost = host.trim();
    if (!normalizedHost || normalizedHost === '0.0.0.0' || normalizedHost === '::') {
        return detectAgentUrl(interfaces, Number(port), protocol);
    }
    return defaultPublicUrl(normalizedHost, port, protocol === 'https');
}
function normalizeHostLiteral(host) {
    return host.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1');
}
function hostFromUrl(url) {
    try {
        return normalizeHostLiteral(new URL(url).hostname);
    }
    catch {
        return '';
    }
}
export function isWildcardHost(host) {
    const normalized = normalizeHostLiteral(host);
    return normalized === '' || normalized === '0.0.0.0' || normalized === '::';
}
export function isPrivateOrLoopbackHost(host) {
    const normalized = normalizeHostLiteral(host);
    if (!normalized || normalized === 'localhost' || normalized.endsWith('.local') || isWildcardHost(normalized)) {
        return true;
    }
    if (normalized.includes(':') &&
        (normalized === '::1' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd'))) {
        return true;
    }
    const octets = normalized.split('.').map((part) => Number(part));
    if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
        return false;
    }
    const a = octets[0];
    const b = octets[1];
    return (a === 0 ||
        a === 10 ||
        a === 127 ||
        (a === 100 && b >= 64 && b <= 127) ||
        (a === 169 && b === 254) ||
        (a === 172 && b >= 16 && b <= 31) ||
        (a === 192 && b === 168));
}
export function suggestSecondaryAdvertisedUrl({ bindHost, port, publicIp = null, interfaces = os.networkInterfaces(), protocol = 'http' }) {
    const localUrl = defaultAgentUrlForMain(bindHost, port, interfaces, protocol);
    const localHost = hostFromUrl(localUrl);
    const wildcardBind = isWildcardHost(bindHost);
    const localOnlyDefault = isPrivateOrLoopbackHost(localHost);
    if (publicIp && wildcardBind && localOnlyDefault) {
        return {
            url: defaultPublicUrl(publicIp, port, protocol === 'https'),
            localUrl,
            publicIp,
            source: 'public-ip',
            warning: 'Detected public IP is only a default. NAT gateways, load balancers, Cloudflare tunnels, VPN/private networks, or cloud security groups can make it wrong; enter the URL/IP the main can actually reach if different.'
        };
    }
    return {
        url: localUrl,
        localUrl,
        publicIp,
        source: wildcardBind ? 'interface' : 'bind-host',
        warning: localOnlyDefault
            ? "Auto-detected URL is private/local. This works only if the main shares that VPC/VPN/private route; otherwise enter this server's public IP, domain, or tunnel URL."
            : null
    };
}
// Try to bind a server on `host:port` — resolve true when the bind succeeds
// (port is free) and false when it errors. Used by pickFreePort to decide
// whether the preferred port is usable without asking the operator.
export function isPortAvailable(port, host = '127.0.0.1') {
    return new Promise((resolve) => {
        const server = net.createServer();
        const finish = (free) => {
            server.removeAllListeners();
            server.close(() => resolve(free));
        };
        server.once('error', () => finish(false));
        server.once('listening', () => finish(true));
        try {
            server.listen(port, host);
        }
        catch {
            resolve(false);
        }
    });
}
// Pick a usable port without asking. Prefer the documented default so the
// README's instructions ("open http://...:3107") keep working when nothing
// else is on the host; fall back to a kernel-assigned ephemeral port when
// the preferred one is taken. A non-positive `preferred` skips the bind probe
// (since port 0 means "any" to the OS) and goes straight to ephemeral.
export async function pickFreePort(host = '127.0.0.1', preferred = 3107) {
    if (preferred > 0 && (await isPortAvailable(preferred, host))) {
        return preferred;
    }
    return new Promise((resolve, reject) => {
        const server = net.createServer();
        server.once('error', reject);
        server.listen(0, host, () => {
            const address = server.address();
            const assigned = address && typeof address === 'object' ? address.port : 0;
            server.close((err) => (err ? reject(err) : resolve(assigned)));
        });
    });
}
export function renderEnvFile(map) {
    return `${Object.entries(map)
        .map(([key, value]) => `${key}=${value}`)
        .join('\n')}\n`;
}
export function writeEnvFile(envPath, updates) {
    mkdirSync(path.dirname(envPath), { recursive: true, mode: 0o700 });
    const content = existsSync(envPath) ? mergeEnv(readFileSync(envPath, 'utf8'), updates) : renderEnvFile(updates);
    writeFileSync(envPath, content, { mode: 0o600 });
    chmodSync(envPath, 0o600);
}
export function resolveRuntimeStatePath(appDir, existingEnvPath, runtimeEnvPath) {
    const value = existingEnvPath?.trim() || runtimeEnvPath?.trim() || path.join(os.homedir(), '.nterminal/state.json');
    return path.isAbsolute(value) ? value : path.resolve(appDir, value);
}
export function formatPrompt(question, fallback) {
    const suffix = fallback ? ` [${fallback}] (Enter = default)` : '';
    return `> ${question}${suffix}: `;
}
export function formatStepHeader(current, total, title) {
    const safeTotal = Math.max(1, Math.floor(total));
    const safeCurrent = Math.min(safeTotal, Math.max(1, Math.floor(current)));
    const width = 12;
    const filled = Math.max(1, Math.min(width, Math.round((safeCurrent / safeTotal) * width)));
    return `[${safeCurrent}/${safeTotal}] ${'#'.repeat(filled)}${'-'.repeat(width - filled)} ${title}`;
}
export function formatProgressBar(current, total, width = 24) {
    const safeTotal = Math.max(1, Math.floor(total));
    const safeCurrent = Math.min(safeTotal, Math.max(1, Math.floor(current)));
    const safeWidth = Math.max(8, Math.floor(width));
    const filled = Math.max(1, Math.min(safeWidth, Math.round((safeCurrent / safeTotal) * safeWidth)));
    return `[${'='.repeat(filled)}${'-'.repeat(safeWidth - filled)}]`;
}
export function formatWizardPanel(section, current, total, title, detail, width = 78, color = false) {
    const panelWidth = Math.max(44, Math.floor(width));
    const innerWidth = panelWidth - 4;
    const safeTotal = Math.max(1, Math.floor(total));
    const safeCurrent = Math.min(safeTotal, Math.max(1, Math.floor(current)));
    const percent = Math.round((safeCurrent / safeTotal) * 100);
    const progressWidth = Math.max(8, Math.min(24, innerWidth - 6));
    const detailLines = wrapPanelText(detail ?? '', innerWidth).map((line) => colorImportantPanelLine(line, color));
    const lines = [
        `${colorText(section, '36', color)} - ${colorText(`Step ${safeCurrent} of ${safeTotal}`, '1', color)}`,
        colorText(title, '1', color),
        `${colorText(formatProgressBar(safeCurrent, safeTotal, progressWidth), '32', color)} ${colorText(`${percent}%`, '1', color)}`,
        ...detailLines
    ];
    const label = ' NTerminal Onboarding ';
    const top = `┌─${label}${'─'.repeat(Math.max(0, panelWidth - label.length - 3))}┐`;
    const bottom = `└${'─'.repeat(panelWidth - 2)}┘`;
    return [top, ...lines.map((line) => `│ ${padVisible(line, innerWidth)} │`), bottom].join('\n');
}
function colorImportantPanelLine(line, enabled) {
    if (/\b(required|must|password|2FA|token|firewall|HTTPS|public domain|proxy)\b/i.test(line)) {
        return colorText(line, '33', enabled);
    }
    return line;
}
function colorText(text, code, enabled) {
    return enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
}
function padVisible(text, width) {
    const visible = stripAnsi(text).length;
    return `${text}${' '.repeat(Math.max(0, width - visible))}`;
}
function wrapPanelText(text, width) {
    const trimmed = text.trim();
    if (!trimmed)
        return [];
    const lines = [];
    let current = '';
    for (const word of trimmed.split(/\s+/)) {
        if (word.length > width) {
            if (current) {
                lines.push(current);
                current = '';
            }
            for (let idx = 0; idx < word.length; idx += width) {
                lines.push(word.slice(idx, idx + width));
            }
            continue;
        }
        const next = current ? `${current} ${word}` : word;
        if (next.length > width) {
            lines.push(current);
            current = word;
        }
        else {
            current = next;
        }
    }
    if (current) {
        lines.push(current);
    }
    return lines;
}
/** Replace targeted keys in an existing .env, preserving comments, blanks, and unrelated keys. Appends new keys. */
export function mergeEnv(existing, updates) {
    const applied = new Set();
    // Allow optional whitespace around `=` to match loadDotEnvFile()'s parsing; rewrite canonically as KEY=value.
    const lines = existing.split(/\r?\n/).map((line) => {
        const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
        const key = match?.[1];
        if (key && Object.prototype.hasOwnProperty.call(updates, key)) {
            applied.add(key);
            return `${key}=${updates[key]}`;
        }
        return line;
    });
    for (const [key, value] of Object.entries(updates)) {
        if (!applied.has(key)) {
            lines.push(`${key}=${value}`);
        }
    }
    return `${lines.join('\n').replace(/\n+$/, '')}\n`;
}
export function buildAgentRegisterPayload(name, url, token) {
    return { name: name.trim(), url: new URL(url.trim()).origin, token: token.trim() };
}
export function shouldBlockRootOnboarding(uid, env = process.env) {
    if (uid !== 0) {
        return false;
    }
    // Running as the real root account is a valid deployment choice on some VPS
    // images. The dangerous case is `sudo nterminal onboarding`: it writes
    // runtime files as root while the operator usually expects their normal
    // user's ~/.nterminal to be used.
    return Boolean(env.SUDO_USER || env.SUDO_UID || env.SUDO_GID);
}
export function updateHelperPath() {
    return process.env.NTERMINAL_UPDATE_HELPER_PATH?.trim() || '/usr/local/sbin/nterminal-update';
}
export function updateSudoersPath() {
    return process.env.NTERMINAL_UPDATE_SUDOERS_PATH?.trim() || '/etc/sudoers.d/nterminal-update';
}
export function renderUpdateHelperScript(options = {}) {
    const npmBin = shellSingleQuote(options.npmBin?.trim() || 'npm');
    const npmPrefix = shellSingleQuote(options.npmPrefix?.trim() || '');
    return `#!/usr/bin/env bash
set -Eeuo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

NPM_BIN=${npmBin}
NPM_PREFIX=${npmPrefix}

if [[ "$#" -ne 1 ]]; then
  echo "usage: nterminal-update check|install" >&2
  exit 64
fi

npm_global() {
  if [[ -n "$NPM_PREFIX" ]]; then
    "$NPM_BIN" --prefix "$NPM_PREFIX" "$@"
  else
    "$NPM_BIN" "$@"
  fi
}

case "\${1:-}" in
  check)
    command -v "$NPM_BIN" >/dev/null
    npm_global --version >/dev/null
    npm_global root -g
    ;;
  install)
    npm_global i -g nterminal@latest --prefer-online
    ;;
  *)
    echo "usage: nterminal-update check|install" >&2
    exit 64
    ;;
esac
`;
}
function shellSingleQuote(value) {
    return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function isSafeSudoersUser(user) {
    return /^[A-Za-z0-9_.-]+$/.test(user);
}
export function renderUpdateHelperSudoers(user, helperPath = updateHelperPath()) {
    if (!isSafeSudoersUser(user)) {
        throw new Error(`unsupported sudoers user: ${user}`);
    }
    if (/\s/.test(helperPath)) {
        throw new Error(`unsupported helper path: ${helperPath}`);
    }
    return [
        '# NTerminal UI update helper. Generated by nterminal onboarding.',
        `${user} ALL=(root) NOPASSWD: ${helperPath} check, ${helperPath} install`,
        ''
    ].join('\n');
}
export function validateUpdateHelperSafety(need, options = {}) {
    const canWrite = options.canWritePath ?? canWritePath;
    const pathExists = options.pathExists ?? existsSync;
    const resolveRealPath = options.realpath ?? safeRealpath;
    const npmBin = need.npmBin?.trim();
    if (!npmBin || !path.isAbsolute(npmBin)) {
        return { ok: false, reason: 'npm binary path could not be resolved to an absolute path' };
    }
    const resolvedNpmBin = resolveRealPath(npmBin);
    if (!resolvedNpmBin || !path.isAbsolute(resolvedNpmBin)) {
        return { ok: false, reason: `npm binary path could not be verified: ${npmBin}` };
    }
    const writableNpmPath = firstWritablePathInChain(resolvedNpmBin, canWrite, pathExists);
    if (writableNpmPath) {
        return { ok: false, reason: `npm binary path is writable by this user: ${writableNpmPath}` };
    }
    const npmRoot = need.npmRoot?.trim();
    if (!npmRoot || !path.isAbsolute(npmRoot)) {
        return { ok: false, reason: 'npm global root could not be resolved to an absolute path' };
    }
    const writableRootPath = firstWritablePathInChain(npmRoot, canWrite, pathExists);
    if (writableRootPath) {
        return { ok: false, reason: `npm global root is writable by this user: ${writableRootPath}` };
    }
    const npmPrefix = need.npmPrefix?.trim();
    if (npmPrefix) {
        if (!path.isAbsolute(npmPrefix)) {
            return { ok: false, reason: 'npm global prefix is not an absolute path' };
        }
        const writablePrefixPath = firstWritablePathInChain(npmPrefix, canWrite, pathExists);
        if (writablePrefixPath) {
            return { ok: false, reason: `npm global prefix is writable by this user: ${writablePrefixPath}` };
        }
    }
    return { ok: true, npmBin: resolvedNpmBin, ...(npmPrefix ? { npmPrefix } : {}) };
}
export function parseUpdateHelperScript(content) {
    const assignments = new Map();
    for (const line of content.split(/\r?\n/)) {
        const match = /^(NPM_BIN|NPM_PREFIX)=(.*)$/.exec(line.trim());
        if (!match) {
            continue;
        }
        const value = parseShellSingleQuoted(match[2] ?? '');
        if (value === null) {
            return null;
        }
        assignments.set(match[1], value);
    }
    const npmBin = assignments.get('NPM_BIN')?.trim();
    if (!npmBin) {
        return null;
    }
    const npmPrefix = assignments.get('NPM_PREFIX')?.trim();
    return { npmBin, ...(npmPrefix ? { npmPrefix } : {}) };
}
export function validateConfiguredUpdateHelperSafety(content, npmRoot, options = {}) {
    const info = parseUpdateHelperScript(content);
    if (!info) {
        return { ok: false, reason: 'installed update helper content could not be parsed' };
    }
    return validateUpdateHelperSafety({
        needed: true,
        configured: true,
        reason: 'installed update helper',
        npmRoot,
        npmBin: info.npmBin,
        ...(info.npmPrefix ? { npmPrefix: info.npmPrefix } : {})
    }, options);
}
export function canUseConfiguredUpdateHelper(helperPath = updateHelperPath(), sudo = process.env.NTERMINAL_SUDO_BIN?.trim() || 'sudo', expectedNpmRoot) {
    const resolvedHelperPath = safeRealpath(helperPath);
    if (!resolvedHelperPath || firstWritablePathInChain(resolvedHelperPath, canWritePath, existsSync)) {
        return false;
    }
    const result = spawnSync(sudo, ['-n', helperPath, 'check'], { encoding: 'utf8' });
    if (result.status !== 0) {
        return false;
    }
    const helperRoot = result.stdout.trim().split(/\r?\n/).at(-1)?.trim();
    if (!helperRoot || (expectedNpmRoot && helperRoot !== expectedNpmRoot)) {
        return false;
    }
    try {
        const content = readFileSync(resolvedHelperPath, 'utf8');
        return validateConfiguredUpdateHelperSafety(content, helperRoot).ok;
    }
    catch {
        return false;
    }
}
export function detectUpdateHelperNeed(packageName = 'nterminal') {
    const npmCommand = process.env.NTERMINAL_NPM_BIN || 'npm';
    const npmBin = resolveCommand(npmCommand) ?? npmCommand;
    const npmRoot = spawnSync(npmCommand, ['root', '-g'], { encoding: 'utf8' });
    if (npmRoot.status !== 0) {
        if (canUseConfiguredUpdateHelper()) {
            return { needed: false, configured: true, reason: 'privileged update helper is already configured' };
        }
        return { needed: false, configured: false, reason: 'npm global root could not be inspected' };
    }
    const root = npmRoot.stdout.trim().split(/\r?\n/).at(-1)?.trim();
    if (!root) {
        return { needed: false, configured: false, reason: 'npm global root is empty' };
    }
    const npmPrefixResult = spawnSync(npmCommand, ['prefix', '-g'], { encoding: 'utf8' });
    const npmPrefix = npmPrefixResult.status === 0 ? npmPrefixResult.stdout.trim().split(/\r?\n/).at(-1)?.trim() : undefined;
    const packageDir = path.join(root, packageName);
    const rootWritable = canWritePath(root);
    const packageWritable = !existsSync(packageDir) || canWritePath(packageDir);
    const helperConfigured = canUseConfiguredUpdateHelper(updateHelperPath(), process.env.NTERMINAL_SUDO_BIN?.trim() || 'sudo', root);
    if (helperConfigured) {
        return {
            needed: false,
            configured: true,
            reason: 'privileged update helper is already configured for this npm prefix',
            npmRoot: root,
            ...(npmPrefix ? { npmPrefix } : {}),
            npmBin
        };
    }
    if (rootWritable && packageWritable) {
        return { needed: false, configured: false, reason: 'global npm install is writable', npmRoot: root, ...(npmPrefix ? { npmPrefix } : {}), npmBin };
    }
    return {
        needed: true,
        configured: false,
        reason: packageWritable ? 'global npm root is not writable' : `${packageName} package directory is not writable`,
        npmRoot: root,
        ...(npmPrefix ? { npmPrefix } : {}),
        npmBin
    };
}
function resolveCommand(command) {
    const result = spawnSync('sh', ['-c', 'command -v -- "$1"', 'resolve-command', command], { encoding: 'utf8' });
    if (result.status !== 0) {
        return null;
    }
    return result.stdout.trim().split(/\r?\n/).at(-1)?.trim() || null;
}
function canWritePath(targetPath) {
    try {
        accessSync(targetPath, constants.W_OK);
        return true;
    }
    catch {
        return false;
    }
}
function safeRealpath(targetPath) {
    try {
        return realpathSync(targetPath);
    }
    catch {
        return null;
    }
}
function firstWritablePathInChain(targetPath, canWrite, pathExists) {
    let current = path.resolve(targetPath);
    for (;;) {
        if (pathExists(current) && canWrite(current)) {
            return current;
        }
        const parent = path.dirname(current);
        if (parent === current) {
            return null;
        }
        current = parent;
    }
}
function parseShellSingleQuoted(value) {
    const trimmed = value.trim();
    if (!trimmed.startsWith("'")) {
        return null;
    }
    let out = '';
    let index = 0;
    for (;;) {
        if (trimmed[index] !== "'") {
            return null;
        }
        const end = trimmed.indexOf("'", index + 1);
        if (end === -1) {
            return null;
        }
        out += trimmed.slice(index + 1, end);
        index = end + 1;
        if (index === trimmed.length) {
            return out;
        }
        if (!trimmed.startsWith("\\'", index)) {
            return null;
        }
        out += "'";
        index += 2;
    }
}
// Resolve the bind host: existing .env value wins so a re-run never silently
// overwrites an operator's manual override, otherwise call the detector
// (main: first non-loopback IPv4; secondary: hardcoded 0.0.0.0).
export function resolveBindHost(envValue, detect) {
    const trimmed = envValue?.trim();
    if (trimmed) {
        return { value: trimmed, origin: 'env' };
    }
    return { value: detect(), origin: 'detected' };
}
// Resolve the bind port. Existing .env value wins as long as it parses as
// a positive integer, otherwise call `pickFreePort` to choose without
// asking the operator. Returns the chosen port plus how we got it.
export async function resolveBindPort(envValue, host, preferred = 3107) {
    const trimmed = envValue?.trim();
    if (trimmed && /^[1-9]\d*$/.test(trimmed)) {
        return { value: trimmed, origin: 'env' };
    }
    const port = await pickFreePort(host, preferred);
    return { value: String(port), origin: port === preferred ? 'default' : 'ephemeral' };
}
/** Read a single KEY=value from an .env file's contents (last occurrence wins, quotes stripped). */
export function parseEnvValue(content, key) {
    // loadDotEnvFile() uses the FIRST occurrence and tolerates spaces around `=`; match that here.
    for (const line of content.split(/\r?\n/)) {
        const match = new RegExp(`^\\s*${key}\\s*=(.*)$`).exec(line);
        if (match) {
            let value = (match[1] ?? '').trim();
            if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
                value = value.slice(1, -1);
            }
            return value;
        }
    }
    return undefined;
}
/** Default access URL derived from bind host/port so it stays consistent with the chosen port. */
export function defaultPublicUrl(host, port, https) {
    let reachableHost = host === '0.0.0.0' || host === '::' || host === '' ? '127.0.0.1' : host;
    // Bracket IPv6 literals so the URL stays valid (e.g. http://[::1]:3107).
    if (reachableHost.includes(':') && !reachableHost.startsWith('[')) {
        reachableHost = `[${reachableHost}]`;
    }
    return `${https ? 'https' : 'http'}://${reachableHost}:${port}`;
}
export function groupSecret(secret) {
    return secret.replace(/(.{4})/g, '$1 ').trim();
}
export function buildOtpAuthUri(secret, issuer = 'NTerminal', account = 'nterminal') {
    // Label is "Issuer:Account" with a literal colon (per the otpauth URI convention).
    // Only secret + issuer are emitted — every authenticator app defaults period/digits/algorithm
    // to 30/6/SHA1, so encoding them just inflates the QR for no behavioral change.
    const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
    const params = new URLSearchParams({ secret, issuer });
    return `otpauth://totp/${label}?${params.toString()}`;
}
export async function renderOtpAuthQr(uri) {
    // `small: true` uses half-block characters to halve vertical size — confirmed scannable
    // on a phone camera even when the host terminal renders the wider blank frame.
    return QRCode.toString(uri, { type: 'terminal', small: true });
}
// Print a single-color box around a labeled summary + a numbered "Next steps"
// section. ANSI escapes are stripped before measuring width so the right
// border lines up even when the title carries a green ✓.
export function printSummaryBox(title, rows, nextSteps) {
    const visibleLen = (s) => stripAnsi(s).length;
    const labelWidth = rows.reduce((max, row) => Math.max(max, row.label.length), 0);
    const contentLines = [];
    contentLines.push(title);
    contentLines.push('');
    for (const row of rows) {
        contentLines.push(`  ${row.label.padEnd(labelWidth)}  ${row.value}`);
    }
    if (nextSteps.length > 0) {
        contentLines.push('');
        contentLines.push('Next steps');
        nextSteps.forEach((step, idx) => {
            contentLines.push(`  ${idx + 1}. ${step}`);
        });
    }
    const inner = Math.max(...contentLines.map((line) => visibleLen(line)));
    const width = inner + 4; // 2-space padding on each side
    const top = '┌' + '─'.repeat(width - 2) + '┐';
    const bottom = '└' + '─'.repeat(width - 2) + '┘';
    console.log('');
    console.log(top);
    for (const line of contentLines) {
        const pad = ' '.repeat(inner - visibleLen(line));
        console.log(`│ ${line}${pad}  │`);
    }
    console.log(bottom);
}
function stripAnsi(s) {
    // Minimal CSI stripper, enough for our `\x1b[..m` color codes.
    return s.replace(/\x1b\[[0-9;]*m/g, '');
}
export function isPubliclyBoundHost(host) {
    // Loopback / link-local / "unset" binds never need a firewall hole because
    // the bind itself already excludes external traffic. Anything else
    // (0.0.0.0, ::, a routable address) is the case we have to worry about.
    const normalized = host.trim().toLowerCase();
    if (!normalized || normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost') {
        return false;
    }
    return true;
}
// ---------------------------------------------------------------------------
// Interactive flow
// ---------------------------------------------------------------------------
function resolveAppDir() {
    if (process.env.NTERMINAL_APP_DIR) {
        return path.resolve(process.env.NTERMINAL_APP_DIR);
    }
    const scriptDir = path.dirname(fileURLToPath(import.meta.url));
    const sourceRoot = path.resolve(scriptDir, '..');
    if (existsSync(path.join(sourceRoot, 'package.json'))) {
        return sourceRoot;
    }
    const distRoot = path.resolve(scriptDir, '..', '..');
    if (existsSync(path.join(distRoot, 'package.json'))) {
        return distRoot;
    }
    return sourceRoot;
}
const APP_DIR = resolveAppDir();
const USER_STATE_DIR = path.join(os.homedir(), '.nterminal');
const ENV_PATH = process.env.NTERMINAL_ENV_FILE
    ? path.resolve(process.env.NTERMINAL_ENV_FILE)
    : path.join(USER_STATE_DIR, '.env');
function serverModuleUrl(modulePath) {
    const built = path.join(APP_DIR, 'dist/server', `${modulePath}.js`);
    if (existsSync(built)) {
        return pathToFileURL(built).href;
    }
    const source = path.join(APP_DIR, 'src/server', `${modulePath}.ts`);
    if (existsSync(source)) {
        return pathToFileURL(source).href;
    }
    return pathToFileURL(path.join(APP_DIR, 'src/server', `${modulePath}.js`)).href;
}
// Arrow-key + Enter list selector for a TTY. The caller is responsible for
// falling back to text input when stdin is not a TTY — see chooseRole().
async function pickFromList(prompt, options, initialIndex = 0) {
    const stdout = process.stdout;
    let index = Math.max(0, Math.min(initialIndex, options.length - 1));
    const color = shouldUseColor();
    const lines = options.length + 1;
    let rendered = false;
    const render = () => {
        if (rendered) {
            // Move the cursor back to the prompt line so we can rewrite the block.
            readline.moveCursor(stdout, 0, -lines);
        }
        readline.clearLine(stdout, 0);
        stdout.write(`? ${prompt} ${colorText('(↑/↓ then Enter)', '2', color)}\n`);
        for (const [i, opt] of options.entries()) {
            readline.clearLine(stdout, 0);
            const marker = i === index ? colorText('›', '36', color) : ' ';
            const label = i === index ? colorText(opt.label, '1', color) : opt.label;
            stdout.write(`${marker} ${label}\n`);
        }
        rendered = true;
    };
    readline.emitKeypressEvents(process.stdin);
    const wasRaw = Boolean(process.stdin.isRaw);
    process.stdin.setRawMode(true);
    process.stdin.resume();
    return new Promise((resolve) => {
        const cleanup = () => {
            process.stdin.removeListener('keypress', onKeypress);
            if (!wasRaw) {
                process.stdin.setRawMode(false);
            }
            process.stdin.pause();
        };
        const onKeypress = (_str, key) => {
            if (!key)
                return;
            if (key.ctrl && key.name === 'c') {
                cleanup();
                stdout.write('\n');
                process.exit(130);
            }
            if (key.name === 'up' || key.name === 'k') {
                index = (index - 1 + options.length) % options.length;
                render();
            }
            else if (key.name === 'down' || key.name === 'j') {
                index = (index + 1) % options.length;
                render();
            }
            else if (key.name === 'return' || key.name === 'enter') {
                cleanup();
                resolve(options[index].value);
            }
        };
        process.stdin.on('keypress', onKeypress);
        render();
    });
}
function createPrompter() {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
    const rlAny = rl;
    const originalWrite = rlAny._writeToOutput.bind(rl);
    let muted = false;
    rlAny._writeToOutput = (stringToWrite) => {
        if (muted) {
            return;
        }
        originalWrite(stringToWrite);
    };
    return {
        ask(question, fallback) {
            return new Promise((resolve) => {
                rl.question(formatPrompt(question, fallback), (answer) => {
                    const value = answer.trim();
                    resolve(value || fallback || '');
                });
            });
        },
        askSecret(question) {
            // Masking via output muting only works on a real TTY; in piped/automation contexts fall back to a plain read.
            if (!process.stdin.isTTY) {
                return new Promise((resolve) => rl.question(`${question}: `, (answer) => resolve(answer)));
            }
            return new Promise((resolve) => {
                rl.question(`${question}: `, (answer) => {
                    muted = false;
                    rlAny.output.write('\n');
                    resolve(answer);
                });
                muted = true;
            });
        },
        async confirm(question, defaultYes) {
            const hint = defaultYes ? 'Y/n' : 'y/N';
            const defaultAnswer = defaultYes ? 'yes' : 'no';
            const answer = (await this.ask(`${question} (${hint}, Enter = ${defaultAnswer})`)).toLowerCase();
            if (!answer) {
                return defaultYes;
            }
            return answer === 'y' || answer === 'yes';
        },
        close() {
            rl.close();
        }
    };
}
function writeEnv(updates) {
    writeEnvFile(ENV_PATH, updates);
}
/** Return an existing .env value so re-running onboarding never rotates a secret already in use. */
function existingEnvValue(key) {
    if (!existsSync(ENV_PATH)) {
        return undefined;
    }
    return parseEnvValue(readFileSync(ENV_PATH, 'utf8'), key);
}
function printStep(section, current, total, title, detail) {
    const columns = typeof process.stdout.columns === 'number' && process.stdout.columns > 0 ? process.stdout.columns : 80;
    const width = Math.max(44, Math.min(78, columns - 2));
    console.log(`\n${formatWizardPanel(section, current, total, title, detail, width, shouldUseColor())}`);
}
function shouldUseColor() {
    return Boolean(process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.NTERMINAL_COLOR !== '0');
}
async function askUrl(p, question, fallback, defaultProtocol = 'https') {
    for (;;) {
        const value = normalizeUrlInput(await p.ask(question, fallback), defaultProtocol);
        if (!value) {
            console.log('A URL is required. Try again.');
            continue;
        }
        let parsed;
        try {
            parsed = new URL(value);
        }
        catch {
            console.log('Invalid URL. Try again.');
            continue;
        }
        if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
            console.log('URL must start with http:// or https://. Try again.');
            continue;
        }
        return value;
    }
}
async function askSecondaryAdvertisedUrl(p, bindHost, port) {
    console.log('Checking the public IP that this machine uses for outbound internet access...');
    const publicIp = await detectPublicIp(2500);
    const suggestion = suggestSecondaryAdvertisedUrl({ bindHost, port, publicIp });
    console.log(`Local/interface URL: ${suggestion.localUrl}`);
    if (suggestion.publicIp) {
        console.log(`Detected public IP: ${suggestion.publicIp}`);
    }
    else {
        console.log('Detected public IP: unavailable');
    }
    if (suggestion.warning) {
        console.log(colorText(`Warning: ${suggestion.warning}`, '33', shouldUseColor()));
    }
    console.log('Cloud firewalls/security groups must allow the chosen port from the main server, otherwise registration can succeed but the server will show offline.');
    return askUrl(p, 'URL the main should use to reach this secondary', suggestion.url, 'http');
}
async function askSecondaryFirewallSource(p, port) {
    console.log('\nFor secondary servers, the browser talks to the main and the main talks to this agent. This port should normally accept only the main server.');
    const restrict = await p.confirm(`Restrict ${port}/tcp to the main server IP/CIDR in the host firewall?`, true);
    if (!restrict) {
        console.log(colorText(`Warning: if the firewall opens ${port}/tcp broadly, the agent token still protects the API but the port is visible to the internet.`, '33', shouldUseColor()));
        return undefined;
    }
    for (;;) {
        const raw = await p.ask('Main server public IP/CIDR allowed to connect (blank = skip OS firewall change)');
        if (!raw.trim()) {
            console.log(colorText(`Skipped host firewall restriction. If this server has a cloud/provider firewall, allow ${port}/tcp from the main server IP only.`, '33', shouldUseColor()));
            return null;
        }
        const source = normalizeFirewallSource(raw);
        if (source) {
            return source;
        }
        console.log('Enter one IP or CIDR range. Domains are not accepted here because firewall rules need a stable source address.');
    }
}
// Normalize a typed URL-or-domain. Accepts bare `terminal.example.test` and
// prepends https:// (the only accepted scheme), leaves any explicit scheme
// alone so the validator can reject non-https with a clear reason. Trims
// surrounding whitespace and a trailing slash so neither rejects the input
// for spurious reasons.
export function normalizeDeploymentUrlInput(raw) {
    const trimmed = raw.trim().replace(/\/$/, '');
    if (!trimmed)
        return trimmed;
    return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
export function normalizeUrlInput(raw, defaultProtocol = 'https') {
    const trimmed = raw.trim().replace(/\/$/, '');
    if (!trimmed)
        return trimmed;
    return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `${defaultProtocol}://${trimmed}`;
}
// Stricter version for the main's public URL: forces https:// + a real
// domain because the security model requires Let's Encrypt-issued TLS in
// front (no raw IPs, no localhost, no .local). Reasons are echoed back so
// the operator knows why their value was rejected.
async function askDeploymentUrl(p, question) {
    const suggestion = suggestDeploymentUrl();
    if (suggestion !== 'https://your-domain.example.com') {
        console.log(`Detected hostname candidate: ${suggestion}`);
        console.log('Type it explicitly if this is the public domain you want to use.');
    }
    console.log('Enter without a value will ask again; this prevents accidentally accepting a placeholder domain.');
    for (;;) {
        const value = normalizeDeploymentUrlInput(await p.ask(question));
        if (!value) {
            console.log('A domain is required. Try again.');
            continue;
        }
        const result = validateDeploymentUrl(value);
        if (result.ok) {
            return value;
        }
        console.log(`Rejected: ${result.reason}. Try again.`);
    }
}
// Best-effort default for the deployment URL: if the machine's hostname
// already passes the same validation we apply to the typed answer (FQDN,
// not localhost/.local, not a raw IP, …), suggest it as a starting point —
// otherwise show an obvious placeholder the operator must replace.
// Running it through validateDeploymentUrl rather than re-implementing the
// rules keeps the suggestion in lock-step with what we'd accept.
export function suggestDeploymentUrl(hostname = os.hostname()) {
    const candidate = `https://${hostname.trim().toLowerCase()}`;
    if (validateDeploymentUrl(candidate).ok) {
        return candidate;
    }
    return 'https://your-domain.example.com';
}
function runDeploy() {
    console.log(`\nBuilding and starting (${controlCommand('deploy')})...\n`);
    const result = spawnSync('bash', [path.join(APP_DIR, 'scripts/nterminalctl'), 'deploy'], {
        cwd: APP_DIR,
        stdio: 'inherit'
    });
    return result.status === 0;
}
function controlCommand(command) {
    return `nterminal ${command}`;
}
function onboardingCommand() {
    return 'nterminal onboarding';
}
function currentSudoersUser() {
    const user = os.userInfo().username || process.env.USER || process.env.LOGNAME || '';
    return isSafeSudoersUser(user) ? user : null;
}
function sudoRunInherit(args) {
    const result = spawnSync(process.env.NTERMINAL_SUDO_BIN || 'sudo', args, { stdio: 'inherit' });
    return result.status === 0;
}
function writeTempFile(prefix, content, mode = 0o600) {
    const filePath = path.join(os.tmpdir(), `${prefix}-${crypto.randomUUID()}`);
    writeFileSync(filePath, content, { mode });
    chmodSync(filePath, mode);
    return filePath;
}
function installUpdateHelperFiles(need) {
    const helperPath = updateHelperPath();
    const sudoersPath = updateSudoersPath();
    const user = currentSudoersUser();
    if (!user) {
        console.log('Could not determine a sudoers-safe username; skipping UI update helper setup.');
        return false;
    }
    const safety = validateUpdateHelperSafety(need);
    if (!safety.ok) {
        console.log(`Skipping UI update helper setup: ${safety.reason}. Manual update fallback remains available.`);
        return false;
    }
    let helperTemp = '';
    let sudoersTemp = '';
    try {
        helperTemp = writeTempFile('nterminal-update-helper', renderUpdateHelperScript({
            npmBin: safety.npmBin,
            ...(safety.npmPrefix ? { npmPrefix: safety.npmPrefix } : {})
        }));
        sudoersTemp = writeTempFile('nterminal-update-sudoers', renderUpdateHelperSudoers(user, helperPath));
        console.log(`Installing ${helperPath} via sudo...`);
        if (!sudoRunInherit(['install', '-o', 'root', '-g', 'root', '-m', '0755', helperTemp, helperPath])) {
            console.log('Update helper install failed; UI updates will fall back to manual instructions.');
            return false;
        }
        console.log('Validating sudoers rule via visudo...');
        if (!sudoRunInherit(['visudo', '-cf', sudoersTemp])) {
            console.log('sudoers validation failed; leaving UI update helper disabled.');
            return false;
        }
        console.log(`Installing ${sudoersPath} via sudo...`);
        if (!sudoRunInherit(['install', '-o', 'root', '-g', 'root', '-m', '0440', sudoersTemp, sudoersPath])) {
            console.log('sudoers install failed; UI updates will fall back to manual instructions.');
            return false;
        }
        if (!canUseConfiguredUpdateHelper(helperPath, process.env.NTERMINAL_SUDO_BIN?.trim() || 'sudo', need.npmRoot)) {
            console.log('Update helper was installed, but sudo -n verification failed. Manual update fallback remains available.');
            return false;
        }
        console.log('UI update helper is ready.');
        return true;
    }
    catch (error) {
        console.log(`Update helper setup failed: ${errorText(error)}.`);
        return false;
    }
    finally {
        if (helperTemp)
            rmSync(helperTemp, { force: true });
        if (sudoersTemp)
            rmSync(sudoersTemp, { force: true });
    }
}
async function maybeConfigureUpdateHelper(p, need) {
    if (!need.needed) {
        return;
    }
    console.log(`Global npm package updates need elevated write access (${need.reason}${need.npmRoot ? `: ${need.npmRoot}` : ''}).`);
    console.log('NTerminal can install a restricted root-owned helper so the Servers > Update button can update this host.');
    console.log(`The helper only updates this npm prefix${need.npmPrefix ? ` (${need.npmPrefix})` : ''}: npm i -g nterminal@latest --prefer-online. Restarts still run as this user.`);
    const enable = await p.confirm('Enable UI updates with the restricted sudo helper?', true);
    if (!enable) {
        console.log('Skipped UI update helper setup. This server will show manual update instructions if npm permissions block updates.');
        return;
    }
    installUpdateHelperFiles(need);
}
function printResolvedBindHost(resolved, detectedNote) {
    const note = resolved.origin === 'env' ? 'preserved from .env' : detectedNote;
    console.log(`Bind host: ${resolved.value} ${colorText(`(${note})`, '2', shouldUseColor())}`);
}
function printResolvedBindPort(resolved) {
    const note = resolved.origin === 'env'
        ? 'preserved from .env'
        : resolved.origin === 'default'
            ? 'default 3107 is free'
            : 'default 3107 was busy; picked an ephemeral port';
    console.log(`Port: ${resolved.value} ${colorText(`(${note})`, '2', shouldUseColor())}`);
}
async function onboardMain(p) {
    const updateHelperNeed = detectUpdateHelperNeed();
    const totalSteps = 7 + (updateHelperNeed.needed ? 1 : 0);
    let currentStep = 0;
    const section = 'Main server setup';
    const step = (title, detail) => printStep(section, ++currentStep, totalSteps, title, detail);
    console.log(`\n=== ${section} ===`);
    // Main MUST bind to 127.0.0.1 — the nginx reverse proxy set up in
    // `setupNginxProxy` below hard-codes 127.0.0.1 as its upstream, and
    // binding wider would let clients bypass TLS by hitting the server's
    // LAN/public IP directly. We do NOT preserve a previous NTERMINAL_HOST
    // here even when one exists in .env: pre-loopback onboarding versions
    // wrote the detected LAN IP, and silently honoring that stale value
    // would make the new nginx proxy 502 with a confusing "config loaded but
    // upstream unreachable" error. Tell the operator we're overriding so the
    // change isn't a surprise, and remind them to restart NTerminal after
    // since the running process is still on the old bind until then. Port
    // still preserves any explicit value, otherwise picks the first free
    // port (preferring 3107) — the port itself has no security implication
    // once the bind is loopback.
    step('Bind local server', 'NTerminal binds to loopback; nginx handles public HTTPS traffic.');
    const existingHost = existingEnvValue('NTERMINAL_HOST');
    if (existingHost && existingHost !== '127.0.0.1') {
        console.log(`Overriding NTERMINAL_HOST=${existingHost} from .env → 127.0.0.1 (loopback is required behind the reverse proxy). The running process keeps the old bind until you redeploy below.`);
    }
    const host = { value: '127.0.0.1', origin: existingHost === '127.0.0.1' ? 'env' : 'detected' };
    printResolvedBindHost(host, 'loopback only — public traffic is fronted by the nginx proxy below');
    const port = await resolveBindPort(existingEnvValue('NTERMINAL_PORT'), host.value);
    printResolvedBindPort(port);
    // One deployment-shape question: the public https://<domain> the main
    // will live at. Forced to https + a real domain because the security
    // model is "TLS in front via Let's Encrypt + reverse proxy" — raw IPs
    // can't get a public cert, and unencrypted HTTP exposes the login
    // password + session cookies on whatever network the browser uses.
    // NTERMINAL_COOKIE_SECURE and NTERMINAL_TRUST_PROXY are therefore both
    // set to true unconditionally.
    step('Choose public domain', 'Required. Type the real DNS name for this main server.');
    const publicUrl = await askDeploymentUrl(p, 'Public domain or URL for this main (required, https:// is added if you omit it)');
    // Preserve an existing session secret: the password hash is peppered with it, so rotating
    // it on a re-run would lock out the already-stored password.
    step('Write runtime config', `Writing ${ENV_PATH}.`);
    const sessionSecret = existingEnvValue('NTERMINAL_SESSION_SECRET') ?? generateSessionSecret();
    const reusedSecret = Boolean(existingEnvValue('NTERMINAL_SESSION_SECRET'));
    const statePath = resolveRuntimeStatePath(APP_DIR, existingEnvValue('NTERMINAL_STATE_PATH'), process.env.NTERMINAL_RUNTIME_STATE_PATH);
    writeEnv({
        NTERMINAL_ROLE: 'main',
        NTERMINAL_SESSION_SECRET: sessionSecret,
        NTERMINAL_STATE_PATH: statePath,
        NTERMINAL_HOST: host.value,
        NTERMINAL_PORT: port.value,
        NTERMINAL_ALLOWED_ORIGINS: originOf(publicUrl),
        NTERMINAL_COOKIE_SECURE: 'true',
        NTERMINAL_TRUST_PROXY: 'true'
    });
    console.log(`\nWrote ${ENV_PATH} (session secret ${reusedSecret ? 'preserved' : 'generated'}).`);
    // Seed password + TOTP directly into the state file (in-process; no server needed).
    const { loadConfig, loadDotEnvFile } = await import(serverModuleUrl('config'));
    const { FileStore } = await import(serverModuleUrl('storage/fileStore'));
    const { AuthService } = await import(serverModuleUrl('auth/authService'));
    const { TotpService } = await import(serverModuleUrl('auth/totpService'));
    // File-only: ignore the surrounding shell env so a stray NTERMINAL_SESSION_SECRET /
    // NTERMINAL_STATE_PATH can't make us seed into the wrong secret or state file.
    const config = loadConfig(loadDotEnvFile(ENV_PATH, {}));
    const store = new FileStore(config.statePath);
    await store.init();
    const auth = new AuthService(config, store, { totpService: new TotpService('NTerminal') });
    step('Set password and 2FA', 'Existing credentials are preserved; new installs create password + authenticator setup.');
    const existing = await store.read();
    if (existing.passwordCredential) {
        console.log('\nA password is already set for this server — skipping password/2FA setup.');
    }
    else {
        let sessionId = '';
        for (;;) {
            const pw = await p.askSecret('Set login password (min 8 chars)');
            const confirmPw = await p.askSecret('Confirm password');
            if (pw !== confirmPw) {
                console.log('Passwords do not match. Try again.');
                continue;
            }
            try {
                const result = await auth.setupPassword(pw);
                sessionId = result.session.sessionId;
                break;
            }
            catch (error) {
                console.log(`Password rejected: ${errorText(error)}`);
            }
        }
        const setup = await auth.setupTotp(sessionId);
        console.log('\nAdd NTerminal to your authenticator app (Google Authenticator, Authy, 1Password, ...).');
        console.log('Scan this QR with the app, or type the secret below by hand:\n');
        process.stdout.write(await renderOtpAuthQr(buildOtpAuthUri(setup.secret)));
        console.log(`\n  Secret : ${groupSecret(setup.secret)}`);
        console.log('  NTerminal · nterminal · 6 digits · 30s\n');
        for (;;) {
            const code = (await p.ask('Enter the 6-digit code from your app')).replace(/\D/g, '');
            try {
                await auth.activateTotp(sessionId, setup.secret, code);
                console.log('Two-factor authentication enabled.');
                break;
            }
            catch (error) {
                console.log(`Code rejected (${errorText(error)}). Try again.`);
            }
        }
        // setupPassword issued an in-process trusted-device token that nobody holds.
        // Clear it so the operator's first real browser login establishes the trusted device.
        await store.update((current) => ({ ...current, trustedDevices: [] }), { flush: 'immediate' });
    }
    await store.flush();
    if (updateHelperNeed.needed) {
        step('Enable UI updates', 'Optional. Adds a restricted sudo helper only for npm package updates.');
        await maybeConfigureUpdateHelper(p, updateHelperNeed);
    }
    step('Build and start', 'Restarts the installed npm package using the bundled build.');
    const deploy = await p.confirm('\nBuild and start the server now?', true);
    const deployed = deploy ? runDeploy() : false;
    if (deploy && !deployed) {
        p.close();
        console.error(`\nDeploy failed. Fix the issue and run: ${controlCommand('deploy')}`);
        process.exitCode = 1;
        return;
    }
    // Proxy setup is independent of the deploy step — on a re-run where NTerminal
    // is already running, the operator typically wants to skip the redeploy but
    // still finish (or verify) the nginx + TLS half. If NTerminal happens to be
    // stopped, the curl verification inside setupNginxProxy will surface a 502
    // and tell them to run nterminalctl start.
    step('Configure HTTPS proxy', 'nginx and TLS are checked against the public domain.');
    const proxyResult = await setupNginxProxy({
        domain: new URL(publicUrl).hostname,
        port: Number(port.value),
        prompter: p
    });
    p.close();
    // Order matters: httpsReady is the strongest signal — when nginx + cert
    // verify passed end-to-end, NTerminal must be running (otherwise the proxy
    // would have 502'd), so the "did the operator say yes to deploy this time"
    // distinction stops mattering. Previously we checked `!deploy` first and
    // printed "Config written but not deployed" alongside a successful
    // ✓ TLS-ready line, which was contradictory on re-runs where NTerminal was
    // already up from an earlier session.
    if (proxyResult.httpsReady) {
        step('Finish', 'Public HTTPS is verified.');
        printSummaryBox(`${colorText('✓', '32', shouldUseColor())} Main is ready`, [
            { label: 'Public URL', value: publicUrl },
            { label: 'Internal', value: `${host.value}:${port.value}` }
        ], [
            `Open ${publicUrl} in your browser`,
            'Log in with your password + 6-digit authenticator code',
            'iPad Safari: Share → Add to Home Screen (optional)'
        ]);
        return;
    }
    if (!deploy) {
        step('Finish', 'Config is written, but public HTTPS is not verified yet.');
        console.log(`\nConfig written but not deployed, and the proxy step couldn't verify NTerminal is reachable on 127.0.0.1:${port.value}. Run \`${controlCommand('deploy')}\` (or restart if already running) and re-run onboarding.`);
        return;
    }
    // deploy succeeded but httpsReady is false — proxy or cert step was
    // skipped or failed verification.
    step('Finish', 'NTerminal is running locally; finish proxy/TLS before using the public URL.');
    console.log(`\n${colorText('⚠', '33', shouldUseColor())} NTerminal itself is running on 127.0.0.1:${port.value}, but the public URL is not verified yet — the reverse-proxy or TLS step above was skipped or did not pass its check.`);
    console.log(`Finish the remaining commands shown above, then re-run \`${onboardingCommand()}\` to re-verify ${publicUrl}.`);
}
async function onboardSecondary(p) {
    const updateHelperNeed = detectUpdateHelperNeed();
    const totalSteps = 7 + (updateHelperNeed.needed ? 1 : 0);
    let currentStep = 0;
    const section = 'Secondary server setup';
    const step = (title, detail) => printStep(section, ++currentStep, totalSteps, title, detail);
    console.log(`\n=== ${section} ===`);
    // Secondary listens on all interfaces by default so the main can reach it
    // across hosts. Existing .env value wins — same preservation rule as main.
    step('Bind local agent', 'The main server must be able to reach this host and port.');
    const host = resolveBindHost(existingEnvValue('NTERMINAL_HOST'), () => '0.0.0.0');
    printResolvedBindHost(host, 'listens on all interfaces so the main can reach this server');
    const port = await resolveBindPort(existingEnvValue('NTERMINAL_PORT'), host.value);
    printResolvedBindPort(port);
    step('Name and advertise this server', 'Choose the URL the main can reach. Public IP detection is only a suggested default.');
    const selfUrl = await askSecondaryAdvertisedUrl(p, host.value, port.value);
    const name = await p.ask('Name shown in the main', os.hostname());
    // Preserve existing secret/token on re-run so a re-registration keeps the same agent identity.
    step('Write runtime config', `Writing ${ENV_PATH}.`);
    const sessionSecret = existingEnvValue('NTERMINAL_SESSION_SECRET') ?? generateSessionSecret();
    const agentToken = existingEnvValue('NTERMINAL_AGENT_TOKEN') ?? generateAgentToken();
    const reusedSessionSecret = Boolean(existingEnvValue('NTERMINAL_SESSION_SECRET'));
    const reusedAgentToken = Boolean(existingEnvValue('NTERMINAL_AGENT_TOKEN'));
    const statePath = resolveRuntimeStatePath(APP_DIR, existingEnvValue('NTERMINAL_STATE_PATH'), process.env.NTERMINAL_RUNTIME_STATE_PATH);
    writeEnv({
        NTERMINAL_ROLE: 'secondary',
        NTERMINAL_SESSION_SECRET: sessionSecret,
        NTERMINAL_STATE_PATH: statePath,
        NTERMINAL_AGENT_TOKEN: agentToken,
        NTERMINAL_HOST: host.value,
        NTERMINAL_PORT: port.value
    });
    console.log(`\nWrote ${ENV_PATH} (session secret ${reusedSessionSecret ? 'preserved' : 'generated'}, agent token ${reusedAgentToken ? 'preserved' : 'generated'}).`);
    {
        const { loadConfig, loadDotEnvFile } = await import(serverModuleUrl('config'));
        const { FileStore } = await import(serverModuleUrl('storage/fileStore'));
        const config = loadConfig(loadDotEnvFile(ENV_PATH, {}));
        const store = new FileStore(config.statePath);
        await store.init();
        await store.flush();
    }
    // Firewall: when the agent is publicly bound, the main can only reach it
    // if the host firewall lets the bind port in. Loopback binds never need
    // this so we skip the prompt entirely there. Onboarding used
    // to leave this manual and the symptom was always the same — main says
    // "unreachable", the operator forgets ufw is even on, and debugging
    // burns a half hour before someone runs `ufw status`.
    step('Check firewall access', 'Skipped automatically only for loopback binds.');
    if (isPubliclyBoundHost(host.value)) {
        const portNumber = Number(port.value);
        if (Number.isFinite(portNumber)) {
            const source = await askSecondaryFirewallSource(p, port.value);
            if (source !== null) {
                const sudoMode = await pickSudoMode(p);
                await ensureFirewallAllowsPort(portNumber, p, sudoMode, source ? { source } : {});
            }
        }
    }
    step('Register with main', 'Optional, but registration is the supported way to add this server to the main.');
    const wantRegister = await p.confirm('\nRegister this server with a main now?', true);
    let registeredWithMain = false;
    if (wantRegister) {
        registeredWithMain = await registerWithMain(p, { name, selfUrl, agentToken });
    }
    else {
        console.log('Skipped registration. Re-run onboarding on this secondary when you are ready to register it with the main.');
    }
    if (updateHelperNeed.needed) {
        step('Enable UI updates', 'Optional. Adds a restricted sudo helper only for npm package updates.');
        await maybeConfigureUpdateHelper(p, updateHelperNeed);
    }
    step('Build and start', 'Restarts the installed npm package using the bundled build.');
    const deploy = await p.confirm('\nBuild and start this server now?', true);
    p.close();
    if (deploy && !runDeploy()) {
        console.error(`\nDeploy failed. Fix the issue and run: ${controlCommand('deploy')}`);
        process.exitCode = 1;
        return;
    }
    step('Finish', registeredWithMain ? 'Registered secondary is ready.' : 'Secondary is running; registration can be retried later.');
    printSummaryBox(`${colorText('✓', '32', shouldUseColor())} Secondary "${name}" is ready`, [
        { label: 'Reachable at', value: selfUrl },
        { label: 'Bind', value: `${host.value}:${port.value}` },
        {
            label: 'Registered',
            value: registeredWithMain
                ? 'yes (logged into the main)'
                : wantRegister
                    ? 'no — registration did not complete'
                    : 'no — re-run onboarding to register later'
        }
    ], registeredWithMain
        ? [
            'On the main, the Servers list should now show this agent',
            'Re-login on the main in your browser (CLI login revoked any active session)'
        ]
        : [
            `This secondary is listening at ${selfUrl}`,
            ...(wantRegister
                ? ['Fix the registration error above and re-run onboarding on this secondary']
                : ['Re-run onboarding on this secondary and choose registration when ready'])
        ]);
}
async function registerWithMain(p, agent) {
    let mainUrl = '';
    let mainOrigin = '';
    for (;;) {
        mainUrl = await askUrl(p, 'Main server URL (required, e.g. https://terminal.example.test)');
        mainOrigin = originOf(mainUrl);
        try {
            const res = await fetch(`${mainUrl}/api/auth/session`, { signal: AbortSignal.timeout(8000) });
            const status = (await res.json());
            if (!status.passwordSet) {
                console.log('That main has no password set yet. Onboard the MAIN first, then re-run this on the secondary.');
                return false;
            }
            break;
        }
        catch {
            console.log(`Could not reach ${mainUrl}. Check the URL/network and try again.`);
        }
    }
    let cookie = '';
    for (;;) {
        const password = await p.askSecret('Main login password');
        const otp = (await p.ask('Main authenticator code')).replace(/\D/g, '');
        try {
            const res = await fetch(`${mainUrl}/api/auth/login`, {
                method: 'POST',
                headers: { 'content-type': 'application/json', origin: mainOrigin },
                body: JSON.stringify({ password, otp, rememberNetwork: false }),
                signal: AbortSignal.timeout(8000)
            });
            if (!res.ok) {
                const body = (await res.json().catch(() => ({})));
                console.log(`Login failed (${body.error ?? res.status}). Try again.`);
                continue;
            }
            cookie = collectCookies(res.headers);
            break;
        }
        catch (error) {
            console.log(`Login error: ${errorText(error)}. Try again.`);
        }
    }
    const payload = buildAgentRegisterPayload(agent.name, agent.selfUrl, agent.agentToken);
    let registered = false;
    try {
        const regRes = await fetch(`${mainUrl}/api/agents`, {
            method: 'POST',
            headers: { 'content-type': 'application/json', origin: mainOrigin, cookie },
            body: JSON.stringify(payload),
            signal: AbortSignal.timeout(8000)
        });
        if (regRes.status === 403) {
            console.log(`Registration blocked by origin policy. Add "${mainOrigin}" to the main's NTERMINAL_ALLOWED_ORIGINS.`);
        }
        else if (!regRes.ok) {
            const body = (await regRes.json().catch(() => ({})));
            console.log(`Registration failed (${body.error ?? regRes.status}).`);
        }
        else {
            registered = true;
            console.log(`Registered with main as "${payload.name}".`);
        }
    }
    catch (error) {
        console.log(`Registration error: ${errorText(error)}.`);
    }
    finally {
        // Always clean up the CLI session + the trusted-device record it created on the main,
        // even if registration threw.
        await fetch(`${mainUrl}/api/auth/logout`, {
            method: 'POST',
            headers: { 'content-type': 'application/json', origin: mainOrigin, cookie },
            body: JSON.stringify({ closeTerminals: false }),
            signal: AbortSignal.timeout(8000)
        }).catch(() => undefined);
    }
    console.log('\nNote: logging into the main from this CLI may revoke the active browser session on the main');
    console.log('      (single active session policy), but existing terminal/tmux sessions are left running.');
    return registered;
}
function collectCookies(headers) {
    // Node fetch exposes combined set-cookie via getSetCookie()
    const setCookies = headers.getSetCookie?.() ?? [];
    return setCookies.map((c) => c.split(';')[0]).join('; ');
}
function errorText(error) {
    return error instanceof Error ? error.message : String(error);
}
function printWelcomeArt() {
    const lines = [
        ' _   _ _____                    _             _',
        '| \\ | |_   _|__ _ __ _ __ ___ (_)_ __   __ _| |',
        "|  \\| | | |/ _ \\ '__| '_ ` _ \\| | '_ \\ / _` | |",
        '| |\\  | | |  __/ |  | | | | | | | | | | (_| | |',
        '|_| \\_| |_|\\___|_|  |_| |_| |_|_|_| |_|\\__,_|_|',
        '',
        'Personal terminals, files, and agents in one browser workspace.'
    ];
    console.log(lines.join('\n'));
    console.log('');
}
function printTopology() {
    // Lays out the relationship between the two roles before we ask which one
    // the operator is installing — first-time users often don't know whether
    // they want main or secondary, and a name + one-line description fits in
    // the picker but doesn't show how the pieces connect.
    const color = shouldUseColor();
    const dim = (text) => colorText(text, '2', color);
    const lines = [
        dim('  browser'),
        dim('     │  https'),
        dim('     ▼'),
        '  ┌───────────┐',
        `  │   main    │${dim('   ← password + TOTP, fronts everything')}`,
        '  └─────┬─────┘',
        dim('        │  agent token over LAN/VPN'),
        dim('        ▼'),
        '  ┌───────────┐',
        `  │ secondary │${dim('   ← additional shell hosts, registered with the main')}`,
        `  └───────────┘${dim('     (zero or more; optional)')}`
    ];
    console.log('Topology:');
    for (const line of lines) {
        console.log(line);
    }
    console.log('');
}
async function chooseRole() {
    if (process.stdin.isTTY) {
        const color = shouldUseColor();
        return pickFromList('Install as', [
            { label: `main      ${colorText('— orchestrates secondaries; password + 2FA gate', '2', color)}`, value: 'main' },
            { label: `secondary ${colorText('— agent that registers with a main', '2', color)}`, value: 'secondary' }
        ]);
    }
    // Non-TTY (piped/automation): keep a one-line text prompt so scripted runs
    // and the pre-existing test harness continue to work.
    return new Promise((resolve) => {
        const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
        rl.question(formatPrompt('Install as (main / secondary)', 'main'), (answer) => {
            rl.close();
            resolve(answer.trim().toLowerCase().startsWith('s') ? 'secondary' : 'main');
        });
    });
}
async function main() {
    if (shouldBlockRootOnboarding(typeof process.getuid === 'function' ? process.getuid() : undefined)) {
        console.error('Do not run `nterminal onboarding` with sudo. Run it as the NTerminal runtime user; onboarding will ask for sudo only for privileged setup steps.');
        process.exitCode = 1;
        return;
    }
    printWelcomeArt();
    printTopology();
    let p;
    try {
        const role = await chooseRole();
        p = createPrompter();
        if (role === 'secondary') {
            await onboardSecondary(p);
        }
        else {
            await onboardMain(p);
        }
    }
    catch (error) {
        p?.close();
        console.error(`\nOnboarding aborted: ${errorText(error)}`);
        process.exitCode = 1;
    }
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) {
    void main();
}
//# sourceMappingURL=onboarding.js.map