* feat: add independent TLS control for relay public endpoint Add PASEO_RELAY_PUBLIC_USE_TLS env var and publicUseTls persisted config to separately control TLS for the client→relay pairing offer, falling back to relayUseTls when unset. The daemon→relay connection continues to use PASEO_RELAY_USE_TLS alone. Enables self-hosting behind a TLS-terminating reverse proxy where the internal path is plain ws:// but public clients need wss://. * fix: use public relay TLS in daemon status --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
519 lines
15 KiB
TypeScript
519 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 {
|
|
generateLocalPairingOffer,
|
|
loadConfig,
|
|
loadPersistedConfig,
|
|
type CliConfigOverrides,
|
|
type PersistedConfig,
|
|
} from "@getpaseo/server";
|
|
import {
|
|
resolveLocalPaseoHome,
|
|
resolveLocalDaemonState,
|
|
resolveTcpHostFromListen,
|
|
startLocalDaemonDetached,
|
|
tailDaemonLog,
|
|
type DaemonStartOptions,
|
|
} from "./daemon/local-daemon.js";
|
|
import { tryConnectToDaemon } from "../utils/client.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;
|
|
|
|
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 toCliOverrides(options: OnboardOptions): CliConfigOverrides {
|
|
const cliOverrides: CliConfigOverrides = {};
|
|
|
|
if (options.listen) {
|
|
cliOverrides.listen = options.listen;
|
|
} else if (options.port) {
|
|
cliOverrides.listen = `127.0.0.1:${options.port}`;
|
|
}
|
|
|
|
if (options.relay === false) {
|
|
cliOverrides.relayEnabled = false;
|
|
}
|
|
|
|
if (options.hostnames) {
|
|
const raw = options.hostnames.trim();
|
|
cliOverrides.hostnames =
|
|
raw.toLowerCase() === "true"
|
|
? true
|
|
: raw
|
|
.split(",")
|
|
.map((host) => host.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
if (options.mcp === false) {
|
|
cliOverrides.mcpEnabled = false;
|
|
}
|
|
|
|
return cliOverrides;
|
|
}
|
|
|
|
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): Promise<ProbeResult> {
|
|
const state = resolveLocalDaemonState({ home });
|
|
const host = resolveTcpHostFromListen(state.listen);
|
|
|
|
if (state.running && host) {
|
|
const client = await tryConnectToDaemon({ host, timeout: 1200 });
|
|
if (client) {
|
|
try {
|
|
await client.fetchAgents();
|
|
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;
|
|
|
|
async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> {
|
|
const probe = await probeDaemonReady(args.home);
|
|
if (probe.kind === "ready") {
|
|
return { listen: probe.listen, host: probe.host };
|
|
}
|
|
const nextState = announceProgress(args.home, state, args.onStatus);
|
|
if (Date.now() >= deadline) {
|
|
const recentLogs = tailDaemonLog(args.home, 60);
|
|
throw 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"),
|
|
);
|
|
}
|
|
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("--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);
|
|
const config = loadConfig(paseoHome, { cli: toCliOverrides(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 (config.relayEnabled === false) {
|
|
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
|
|
printNextSteps(null, paseoHome, richUi);
|
|
if (richUi) {
|
|
outro("Paseo daemon is running.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pairing = await generateLocalPairingOffer({
|
|
paseoHome,
|
|
relayEnabled: config.relayEnabled,
|
|
relayEndpoint: config.relayEndpoint,
|
|
relayPublicEndpoint: config.relayPublicEndpoint,
|
|
relayUseTls: config.relayUseTls,
|
|
relayPublicUseTls: config.relayPublicUseTls,
|
|
appBaseUrl: config.appBaseUrl,
|
|
includeQr: true,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
renderNote(
|
|
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
|
|
"Scan to pair",
|
|
);
|
|
renderNote(pairing.url, "Pairing link");
|
|
printNextSteps(pairing.url, paseoHome, richUi);
|
|
if (richUi) {
|
|
outro("Paseo is ready!");
|
|
}
|
|
}
|