import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; import { Command, Option } from "commander"; import { writeFileSync } from "node:fs"; import path from "node:path"; import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server"; import { resolveLocalPaseoHome, resolveLocalDaemonState, resolveTcpHostFromListen, startLocalDaemonDetached, tailDaemonLog, type DaemonStartOptions, } from "./daemon/local-daemon.js"; import { tryConnectToDaemon } from "../utils/client.js"; import { formatPairingInstructions } from "../output/pairing.js"; import { confirmRelayPairing, printDirectConnectionGuidance, resolveLocalPairingOffer, } from "./daemon/pair.js"; interface OnboardOptions extends DaemonStartOptions { timeout?: string; voice?: "ask" | "enable" | "disable"; } type RawOnboardOptions = OnboardOptions & { allowedHosts?: string; }; type OnboardPersistedConfig = PersistedConfig & { features?: PersistedConfig["features"] & { dictation?: PersistedConfig["features"] extends { dictation?: infer T } ? T & { enabled?: boolean } : { enabled?: boolean }; voiceMode?: PersistedConfig["features"] extends { voiceMode?: infer T } ? T & { enabled?: boolean } : { enabled?: boolean }; }; }; const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000; const READY_PROBE_TIMEOUT_MS = 1200; class OnboardCancelledError extends Error {} const plainNoteFormat = (line: string): string => line; function renderNote(message: string, title: string): void { note(message, title, { format: plainNoteFormat }); } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function parseTimeoutMs(raw: string | undefined): number { if (!raw || raw.trim().length === 0) { return DEFAULT_READY_TIMEOUT_MS; } const seconds = Number(raw); if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`Invalid timeout value: ${raw}`); } return Math.ceil(seconds * 1000); } function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void { const configPath = path.join(paseoHome, "config.json"); writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); } function applyVoiceSelection( config: OnboardPersistedConfig, enabled: boolean, ): OnboardPersistedConfig { return { ...config, features: { ...config.features, dictation: { ...config.features?.dictation, enabled, }, voiceMode: { ...config.features?.voiceMode, enabled, }, }, }; } function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null { const voiceModeEnabled = config.features?.voiceMode?.enabled; if (typeof voiceModeEnabled === "boolean") { return voiceModeEnabled; } const dictationEnabled = config.features?.dictation?.enabled; if (typeof dictationEnabled === "boolean") { return dictationEnabled; } return null; } async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise { if (mode === "enable") { return true; } if (mode === "disable") { return false; } if (!process.stdin.isTTY || !process.stdout.isTTY) { log.message("Non-interactive terminal detected; voice setup defaults to disabled."); return false; } const answer = await confirm({ message: "Enable voice features? (downloads local STT/TTS models in background)", active: "Yes", inactive: "No", initialValue: false, }); if (isCancel(answer)) { throw new OnboardCancelledError("Onboarding cancelled by user."); } return answer; } interface DownloadProgress { modelId: string | null; pct: number | null; } function parseDownloadProgress(logTail: string): DownloadProgress | null { const lines = logTail.split("\n").filter(Boolean); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]; if (!line || !line.includes("Downloading model artifact")) { continue; } const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/); const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/); return { modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null, pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null, }; } return null; } function renderProgressLine(progress: DownloadProgress): string { const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ""; if (progress.pct === null) { return `Downloading speech model${modelSuffix}...`; } return `Downloading speech model${modelSuffix}: ${progress.pct}%`; } type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" }; async function probeDaemonReady(home: string, timeoutMs: number): Promise { const state = resolveLocalDaemonState({ home }); const host = resolveTcpHostFromListen(state.listen); const deadline = Date.now() + timeoutMs; const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); if (state.running && host) { const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), }); if (client) { try { await client.fetchAgents({ timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), }); return { kind: "ready", listen: state.listen, host }; } catch { // Daemon process is alive but not API-ready yet. } finally { await client.close().catch(() => {}); } } } else if (state.running && !host) { return { kind: "ready", listen: state.listen, host: null }; } return { kind: "pending" }; } interface ProgressState { lastStatus: string; lastPrintedAt: number; } function announceProgress( home: string, state: ProgressState, onStatus: ((message: string) => void) | undefined, ): ProgressState { const progress = parseDownloadProgress(tailDaemonLog(home, 120) ?? ""); const progressLine = progress ? renderProgressLine(progress) : null; const statusMessage = progressLine ?? "Waiting for daemon to become ready..."; if (statusMessage !== state.lastStatus) { onStatus?.(statusMessage); return { lastStatus: statusMessage, lastPrintedAt: Date.now() }; } if (!onStatus && Date.now() - state.lastPrintedAt >= 3000) { console.log(statusMessage); return { lastStatus: state.lastStatus, lastPrintedAt: Date.now() }; } return state; } async function waitForDaemonReady(args: { home: string; timeoutMs: number; onStatus?: (message: string) => void; }): Promise<{ listen: string; host: string | null }> { const deadline = Date.now() + args.timeoutMs; const createTimeoutError = () => { const recentLogs = tailDaemonLog(args.home, 60); return new Error( [ `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, ] .filter(Boolean) .join("\n\n"), ); }; async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> { if (Date.now() >= deadline) { throw createTimeoutError(); } const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now())); if (probe.kind === "ready") { return { listen: probe.listen, host: probe.host }; } const nextState = announceProgress(args.home, state, args.onStatus); if (Date.now() >= deadline) { throw createTimeoutError(); } await sleep(200); return poll(nextState); } return poll({ lastStatus: "", lastPrintedAt: 0 }); } function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void { const daemonLogPath = path.join(paseoHome, "daemon.log"); const nextStepsLines = [ pairingUrl ? "1. Open Paseo and scan the QR code above, or paste the pairing link." : "1. Open Paseo and connect to your daemon.", "2. Web app: https://app.paseo.sh", "3. Desktop app: https://github.com/getpaseo/paseo/releases/latest", "4. Docs: https://paseo.sh/docs", '5. Example: paseo run --output-schema schema.json "extract fields"', ]; const quickReferenceLines = [ "1. paseo --help", "2. paseo ls", '3. paseo run "your prompt"', "4. paseo status", `5. Daemon logs: ${daemonLogPath}`, ]; if (!richUi) { console.log(""); console.log("Next steps:"); for (const line of nextStepsLines) { console.log(line); } console.log(""); console.log("CLI quick reference:"); for (const line of quickReferenceLines) { console.log(line); } return; } renderNote(nextStepsLines.join("\n"), "Next steps"); renderNote(quickReferenceLines.join("\n"), "CLI quick reference"); } export function onboardCommand(): Command { return new Command("onboard") .description("Run first-time setup, start daemon, and print pairing instructions") .option("--listen ", "Listen target (host:port, port, or unix socket path)") .option("--port ", "Port to listen on (default: 6767)") .option("--home ", "Paseo home directory (default: ~/.paseo)") .option("--relay", "Enable relay connection without prompting") .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option( "--hostnames ", 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "Voice setup mode: ask, enable, disable", "ask") .action(async (options: RawOnboardOptions) => { await runOnboard({ ...options, hostnames: options.hostnames ?? options.allowedHosts, }); }); } async function resolveAndPersistVoice( paseoHome: string, options: OnboardOptions, ): Promise { let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig; const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted); const shouldPrompt = options.voice === "ask" || options.voice === undefined; let voiceEnabled: boolean; try { voiceEnabled = shouldPrompt && persistedVoiceSelection !== null ? persistedVoiceSelection : await resolveVoiceSelection(options.voice); } catch (error) { if (error instanceof OnboardCancelledError) { cancel("Onboarding cancelled."); process.exit(0); } throw error; } if (shouldPrompt && persistedVoiceSelection !== null) { log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`); } persisted = applyVoiceSelection(persisted, voiceEnabled); savePersistedConfig(paseoHome, persisted); return voiceEnabled; } async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise { const stateBeforeStart = resolveLocalDaemonState({ home: options.home }); if (stateBeforeStart.running) { log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`); return; } const startSpinner = richUi ? spinner() : null; try { if (startSpinner) { startSpinner.start("Starting daemon..."); } else { log.message("Starting daemon..."); } const startup = await startLocalDaemonDetached(options); if (startSpinner) { startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`); } else { log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`); } log.message(`Logs: ${startup.logPath}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (startSpinner) { startSpinner.error(message); } else { log.error(message); } process.exit(1); } } async function waitForDaemonReadyWithUi(args: { home: string; timeoutMs: number; richUi: boolean; }): Promise<{ listen: string; host: string | null }> { const readySpinner = args.richUi ? spinner() : null; try { if (readySpinner) { readySpinner.start("Waiting for daemon to become ready..."); } else { log.message("Waiting for daemon to become ready..."); } const readyState = await waitForDaemonReady({ home: args.home, timeoutMs: args.timeoutMs, onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, }); if (readySpinner) { readySpinner.stop(`Daemon ready on ${readyState.listen}`); } else { log.message(`Daemon ready on ${readyState.listen}`); } return readyState; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (readySpinner) { readySpinner.error(message); } else { log.error(message); } return process.exit(1); } } export async function runOnboard(options: OnboardOptions): Promise { const richUi = process.stdin.isTTY && process.stdout.isTTY; if (richUi) { intro("Welcome to Paseo"); } if (options.listen && options.port) { cancel("Cannot use --listen and --port together"); process.exit(1); } let timeoutMs = DEFAULT_READY_TIMEOUT_MS; try { timeoutMs = parseTimeoutMs(options.timeout); } catch (error) { const message = error instanceof Error ? error.message : String(error); cancel(message); process.exit(1); } const paseoHome = resolveLocalPaseoHome(options.home); if (richUi) { renderNote(paseoHome, "Paseo home"); } const voiceEnabled = await resolveAndPersistVoice(paseoHome, options); log.message( voiceEnabled ? "Voice features enabled. Local speech models will be downloaded automatically if missing." : "Voice features disabled. Local speech models will not be downloaded.", ); await ensureDaemonStarted(options, richUi); await waitForDaemonReadyWithUi({ home: options.home ?? paseoHome, timeoutMs, richUi, }); if (options.relay === false) { log.message("Relay pairing skipped because --no-relay was provided."); printNextSteps(null, paseoHome, richUi); if (richUi) outro("Paseo daemon is running."); return; } let pairing = await resolveLocalPairingOffer({ paseoHome, enableRelay: options.relay === true, }); if (!pairing.relayEnabled) { const shouldEnable = richUi ? await confirmRelayPairing() : false; if (!shouldEnable) { printDirectConnectionGuidance(); printNextSteps(null, paseoHome, richUi); if (richUi) outro("Paseo daemon is running."); return; } pairing = await resolveLocalPairingOffer({ paseoHome, enableRelay: true }); log.success("Relay enabled"); } if (!pairing.url) { log.warn("Relay pairing URL is unavailable for this daemon configuration."); printNextSteps(null, paseoHome, richUi); if (richUi) { outro("Paseo daemon is running."); } return; } process.stdout.write( formatPairingInstructions({ url: pairing.url, qr: pairing.qr, columns: process.stdout.columns, }), ); printNextSteps(pairing.url, paseoHome, richUi); if (richUi) { outro("Paseo is ready!"); } }