Files
paseo/packages/cli/src/commands/onboard.ts
T
Mohamed BoudraandGitHub d2de309fc3 Make relay opt-in when pairing devices (#2706)
* feat: make relay opt-in for device pairing

* fix(relay): keep pairing state authoritative

* fix(relay): refresh pairing state across transports

* fix(onboard): honor explicit relay opt-out

* fix(relay): surface transport startup failures

* fix(pairing): gate offers for older daemons

* test(cli): rely on real pairing transport coverage

* fix(cli): report live relay without a pid file

* fix(relay): synchronize config during daemon startup

* fix(relay): preserve mutability for ignored overrides

* fix(cli): use reachable daemon for pairing

* fix(app): redirect invalid pair device routes

* fix(pairing): support remote host device pairing

Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals.

* fix(pairing): validate pairing destinations

Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state.

* fix(pairing): render QR codes across platforms

Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack.

* fix(cli): pair from persisted relay state

Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
2026-08-01 04:35:06 +08:00

507 lines
15 KiB
TypeScript

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<void> {
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<boolean> {
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<ProbeResult> {
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>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "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 <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "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<boolean> {
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<void> {
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<void> {
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!");
}
}