fix(daemon): keep lifecycle operations on the selected instance

Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop.
This commit is contained in:
Mohamed Boudra
2026-09-09 14:09:53 +02:00
parent fdf3b4b47f
commit 264276880e
166 changed files with 3824 additions and 4467 deletions
+23 -55
View File
@@ -1,3 +1,4 @@
import { pairCommand } from "./commands/daemon/pair.js";
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
@@ -15,9 +16,9 @@ import { createHeartbeatCommand } from "./commands/heartbeat/index.js";
import { createHubCommand } from "./commands/hub/index.js";
import { createHooksCommand } from "./commands/hooks.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { runDaemonReloadCommand } from "./commands/daemon/reload.js";
import { daemonStatusCommand } from "./commands/daemon/status.js";
import { daemonRestartCommand } from "./commands/daemon/restart.js";
import { daemonReloadCommand } from "./commands/daemon/reload.js";
import { addLsOptions, runLsCommand } from "./commands/agent/ls.js";
import { addRunOptions, runRunCommand } from "./commands/agent/run.js";
import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js";
@@ -35,19 +36,12 @@ import { onboardCommand } from "./commands/onboard.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
addJsonOption,
withGlobalOptions,
} from "./utils/command-options.js";
import { resolveCliVersion } from "./version.js";
const VERSION = resolveCliVersion();
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
if (typeof hostnames === "string") return hostnames;
if (typeof allowedHosts === "string") return allowedHosts;
return undefined;
}
export function createCli(): Command {
const program = new Command();
@@ -125,51 +119,10 @@ export function createCli(): Command {
program.addCommand(daemonStartCommand());
program.addCommand(createHooksCommand());
addJsonOption(
program
.command("status")
.description('Show local daemon status (alias for "paseo daemon status")'),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runDaemonStatusCommand));
addJsonAndDaemonHostOptions(
program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(
program
.command("restart")
.description('Restart local daemon (alias for "paseo daemon restart")'),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(
withOutput((...args) => {
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
return runDaemonRestartCommand(
{
...options,
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
},
command,
);
}),
);
program.addCommand(daemonStatusCommand());
program.addCommand(daemonRestartCommand());
program.addCommand(daemonReloadCommand());
program.addCommand(pairCommand());
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand());
@@ -207,5 +160,20 @@ export function createCli(): Command {
// Added in v0.2.0; remove after 2027-01-17.
program.addCommand(createWorktreeCommand(), { hidden: true });
const enforceSelectorDuplicates = (command: Command) => {
for (const option of command.options) {
if (option.long !== "--home" && option.long !== "--host") continue;
option.argParser((value: string, previous: string | undefined) => {
if (previous !== undefined && previous !== value)
throw {
code: "TARGET_AMBIGUOUS",
message: `Conflicting duplicate ${option.long} selectors.`,
};
return value;
});
}
command.commands.forEach(enforceSelectorDuplicates);
};
enforceSelectorDuplicates(program);
return program;
}
+2 -16
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -44,8 +43,6 @@ export async function runArchiveCommand(
options: AgentArchiveOptions,
_command: Command,
): Promise<AgentArchiveCommandResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -56,18 +53,7 @@ export async function runArchiveCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+3 -13
View File
@@ -5,17 +5,17 @@ export function addAttachOptions(cmd: Command): Command {
.description("Attach to a running agent's output stream")
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
} from "../../utils/timeline.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
export interface AgentAttachOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
[key: string]: unknown;
}
@@ -105,23 +105,13 @@ export async function runAttachCommand(
options: AgentAttachOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host });
if (!id) {
console.error("Error: Agent ID required");
console.error("Usage: paseo attach <id>");
process.exit(1);
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: id });
+3 -16
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
@@ -39,8 +38,6 @@ export async function runDeleteCommand(
options: AgentDeleteOptions,
_command: Command,
): Promise<AgentDeleteResult> {
const host = getDaemonHost({ host: options.host });
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: "MISSING_ARGUMENT",
@@ -50,18 +47,7 @@ export async function runDeleteCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
@@ -97,6 +83,7 @@ export async function runDeleteCommand(
await client.deleteAgent(agent.id);
return { ok: true as const, id: agent.id };
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
return { ok: false as const, id: agent.id, message };
}
+2 -13
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
@@ -26,17 +25,7 @@ export async function runDetachCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<AgentDetachResult>> {
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
} satisfies CommandError;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
+2 -19
View File
@@ -1,5 +1,5 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { collectMultiple } from "../../utils/command-options.js";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { agentRunSchema, type AgentRunResult } from "./run.js";
@@ -94,28 +94,11 @@ export function resolveImportCwd(explicitCwd: string | undefined, defaultCwd: st
return cwd;
}
async function connectToDaemonOrThrow(
hostOption: string | undefined,
host: string,
): Promise<Awaited<ReturnType<typeof connectToDaemon>>> {
try {
return await connectToDaemon({ host: hostOption });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
export async function runImportCommand(
sessionIdArg: string,
options: AgentImportOptions,
_command: Command,
): Promise<AgentImportCommandResult> {
const host = getDaemonHost({ host: options.host });
const sessionId = sessionIdArg.trim();
if (!sessionId) {
throw {
@@ -129,7 +112,7 @@ export async function runImportCommand(
const cwd = resolveImportCwd(options.cwd, process.cwd());
const labels = parseImportLabels(options.label);
const client = await connectToDaemonOrThrow(options.host, host);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agent = await client.importAgent({
+2 -15
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
export function addInspectOptions(cmd: Command): Command {
@@ -217,8 +217,6 @@ export async function runInspectCommand(
options: AgentInspectOptions,
_command: Command,
): Promise<AgentInspectResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -229,18 +227,7 @@ export async function runInspectCommand(
throw error;
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
+3 -12
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
@@ -91,23 +91,13 @@ export async function runLogsCommand(
options: AgentLogsOptions,
_command: Command,
): Promise<AgentLogsResult> {
const host = getDaemonHost({ host: options.host });
if (!id) {
console.error("Error: Agent ID required");
console.error("Usage: paseo agent logs <id>");
process.exit(1);
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: id });
@@ -157,6 +147,7 @@ export async function runLogsCommand(
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
console.log(transcript);
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Failed to get logs: ${message}`);
await client.close().catch(() => {});
+2 -24
View File
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
@@ -104,21 +104,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
export type AgentLsResult = ListResult<AgentListItem>;
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
const reason = cause instanceof Error ? cause.message : String(cause);
const isSsh = host.trim().startsWith("ssh://");
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot reach the daemon at ${host}: ${reason}`,
details: isSsh
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: [
"Start a local daemon with: paseo daemon start",
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
].join("\n"),
};
}
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean;
@@ -185,14 +170,7 @@ export async function runLsCommand(
options: AgentLsOptions,
_command: Command,
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
throw daemonConnectionFailure(host, err);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const normalizedThinkingOptionId = options.thinking?.trim();
+2 -2
View File
@@ -55,7 +55,7 @@ export async function runModeCommand(
_command: Command,
): Promise<AgentModeResult> {
const normalizedMode = mode?.trim();
const host = getDaemonHost({ host: options.host });
const host = getDaemonHost({ target: options.daemonTarget });
// Validate arguments
if (!options.list && !normalizedMode) {
@@ -64,7 +64,7 @@ export async function runModeCommand(
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ host: options.host });
client = await connectToDaemon({ target: options.daemonTarget });
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
+2 -7
View File
@@ -1,5 +1,5 @@
import type { Command } from "commander";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { openDesktopWithAgent } from "../open.js";
import type {
CommandError,
@@ -36,12 +36,7 @@ async function resolveServerId(options: CommandOptions): Promise<string> {
return explicitServerId;
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (error) {
throw buildDaemonConnectionCommandError({ host: options.host, error });
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const serverId = client.getLastServerInfoMessage()?.serverId.trim();
if (!serverId) {
+2 -16
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -40,8 +39,6 @@ export async function runReloadCommand(
options: AgentReloadOptions,
_command: Command,
): Promise<AgentReloadCommandResult> {
const host = getDaemonHost({ host: options.host });
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: "MISSING_AGENT_ID",
@@ -51,18 +48,7 @@ export async function runReloadCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+7 -19
View File
@@ -1,7 +1,7 @@
import { Command, Option } from "commander";
import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -140,6 +140,7 @@ function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined {
function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) {
const newWorkspace = resolveNewWorkspaceKind(options) ?? "local";
return buildWorkspaceSource({
daemonTarget: options.daemonTarget,
isolation: newWorkspace,
path: cwd,
mode: options.worktreeMode,
@@ -181,6 +182,7 @@ function loadOutputSchema(value: string): Record<string, unknown> {
try {
source = readFileSync(resolve(trimmed), "utf8");
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_OUTPUT_SCHEMA",
@@ -195,6 +197,7 @@ function loadOutputSchema(value: string): Record<string, unknown> {
try {
parsed = JSON.parse(source);
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_OUTPUT_SCHEMA",
@@ -406,6 +409,7 @@ function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
}
return ms;
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw {
code: "INVALID_TIMEOUT",
@@ -432,6 +436,7 @@ function loadRunImages(
mimeType,
};
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err });
}
@@ -482,22 +487,6 @@ function parseKeyValueFlags(
return labels;
}
async function connectToDaemonOrThrow(
hostOption: string | undefined,
host: string,
): Promise<ConnectedDaemonClient> {
try {
return await connectToDaemon({ host: hostOption });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
// A workspace is the explicit home of a run: it owns the directory the agent
// runs in. The CLI resolves one before creating any agent, so no run leans on
// createAgent's legacy cwd->workspace fallback.
@@ -587,7 +576,6 @@ export async function runRunCommand(
options: AgentRunOptions,
_command: Command,
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host });
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
validateRunOptions(prompt, options, outputSchema);
@@ -596,7 +584,7 @@ export async function runRunCommand(
const resolvedProviderModel = resolveProviderAndModel(options);
const resolvedTitle = options.title ?? options.name;
const client = await connectToDaemonOrThrow(options.host, host);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
// Resolve working directory
+4 -15
View File
@@ -1,6 +1,6 @@
import { type Command } from "commander";
import { collectMultiple } from "../../utils/command-options.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -78,6 +78,7 @@ async function readImageFiles(
return { data: buffer.toString("base64"), mimeType };
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw {
code: "IMAGE_READ_ERROR",
@@ -128,6 +129,7 @@ async function resolvePromptInput(options: {
try {
return await readFile(resolve(promptFilePath), "utf8");
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PROMPT_FILE_READ_ERROR",
@@ -166,8 +168,6 @@ export async function runSendCommand(
options: AgentSendOptions,
_command: Command,
): Promise<SingleResult<AgentSendResult>> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -184,18 +184,7 @@ export async function runSendCommand(
promptFile: options.promptFile,
});
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
// Read image files if provided
+2 -16
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
@@ -42,8 +41,6 @@ export async function runStopCommand(
options: AgentStopOptions,
_command: Command,
): Promise<AgentStopResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments - need either an id, --all, or --cwd
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
@@ -54,18 +51,7 @@ export async function runStopCommand(
throw error;
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+2 -15
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -203,8 +203,6 @@ export async function runUpdateCommand(
options: AgentUpdateOptions,
_command: Command,
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
@@ -217,18 +215,7 @@ export async function runUpdateCommand(
const changes = parseAgentChanges(options);
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
+3 -15
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
@@ -72,6 +72,7 @@ function parseWaitTimeout(timeout: string | undefined): {
timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`,
};
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
throw {
code: "INVALID_TIMEOUT",
@@ -142,8 +143,6 @@ export async function runWaitCommand(
options: AgentWaitOptions,
_command: Command,
): Promise<SingleResult<AgentWaitResult>> {
const host = getDaemonHost({ host: options.host });
if (!agentIdArg || agentIdArg.trim().length === 0) {
throw {
code: "MISSING_AGENT_ID",
@@ -154,18 +153,7 @@ export async function runWaitCommand(
const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout);
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
try {
+2 -8
View File
@@ -1,7 +1,6 @@
import type { Command } from "commander";
import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js";
import { connectToDaemon } from "../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../output/index.js";
import type { CommandOptions } from "../output/with-output.js";
@@ -45,12 +44,7 @@ export async function runCloneCommand(
throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names");
}
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
throw buildDaemonConnectionCommandError({ host: options.host, error: err });
}
const client = await connectToDaemon({ target: options.daemonTarget });
if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) {
await client.close().catch(() => {});
+116
View File
@@ -0,0 +1,116 @@
import { addLocalDaemonOptions } from "../../utils/command-options.js";
import { Command } from "commander";
import {
readDaemonInstance,
readPersistedConfig,
getPersistedConfigValue,
editPersistedConfig,
} from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
/password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(key)
? "[redacted]"
: redact(item),
]),
);
}
function result(data: Record<string, unknown>) {
return {
type: "single" as const,
data,
schema: {
idField: () => "configuration",
columns: [],
renderHuman: () => JSON.stringify(data, null, 2),
},
};
}
function homeOf(options: CommandOptions) {
if (options.daemonTarget.kind !== "instance")
throw new Error("Configuration edits require --home");
return options.daemonTarget.home;
}
async function applySaved(home: string, options: CommandOptions) {
const nextCommand = `paseo daemon start --home ${JSON.stringify(home)}`;
const instance = await readDaemonInstance(home);
if (!instance?.listen)
return result({
action: "saved",
applied: false,
message: "Saved; not applied to a running daemon",
nextCommand,
});
let client;
try {
client = await connectToDaemon({ target: options.daemonTarget, timeout: 1_500 });
} catch {
return result({
action: "saved",
applied: false,
message: "Saved; not applied to a running daemon",
nextCommand: `paseo daemon reload --home ${JSON.stringify(home)}`,
});
}
try {
const reload = await client.reloadDaemonConfig();
return result({ action: "saved", ...reload });
} catch (error) {
throw { code: "CONFIG_SAVED_RELOAD_FAILED", message: `Saved; reload failed: ${String(error)}` };
} finally {
await client.close();
}
}
export function daemonConfigCommand(): Command {
const config = new Command("config").description(
"Read or edit persistent local configuration (local operation)",
);
addLocalDaemonOptions(config.command("get [path]")).action(
withOutput(async (field: string | undefined, options: CommandOptions, _command: Command) => {
const persisted = readPersistedConfig(homeOf(options));
const value = field ? getPersistedConfigValue(persisted, field) : persisted;
return result({
source: "configured",
path: field ?? null,
set: value !== undefined,
value:
field && /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(field)
? "[redacted]"
: redact(value),
});
}),
);
addLocalDaemonOptions(config.command("set <path> <value>"))
.option("--string", "Interpret value literally as a string")
.action(
withOutput(async (field: string, raw: string, options: CommandOptions, _command: Command) => {
let value: unknown = raw;
if (!options.string) {
try {
value = JSON.parse(raw);
} catch {
/* Unquoted input is a string. */
}
}
editPersistedConfig(homeOf(options), field, { value });
return applySaved(homeOf(options), options);
}),
);
addLocalDaemonOptions(config.command("unset <path>")).action(
withOutput(async (field: string, options: CommandOptions, _command: Command) => {
editPersistedConfig(homeOf(options), field, { unset: true });
return applySaved(homeOf(options), options);
}),
);
return config;
}
+23 -72
View File
@@ -1,81 +1,32 @@
import { Command, Option } from "commander";
import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js";
import { Command } from "commander";
import { startCommand, daemonRunCommand } from "./start.js";
import { daemonStatusCommand } from "./status.js";
import { daemonStopCommand } from "./stop.js";
import { daemonRestartCommand } from "./restart.js";
import { runSetPasswordCommand } from "./set-password.js";
import { pairCommand } from "./pair.js";
import { runDaemonReloadCommand } from "./reload.js";
import { daemonReloadCommand } from "./reload.js";
import { daemonConfigCommand } from "./config.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
if (typeof hostnames === "string") return hostnames;
if (typeof allowedHosts === "string") return allowedHosts;
return undefined;
}
import { addJsonOption, addLocalDaemonOptions } from "../../utils/command-options.js";
export function createDaemonCommand(): Command {
const daemon = new Command("daemon").description("Manage the Paseo daemon");
daemon.addCommand(startCommand());
daemon.addCommand(pairCommand());
addJsonAndDaemonHostOptions(
daemon.command("reload").description("Reload config.json without restarting the daemon"),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(daemon.command("status").description("Show local daemon status"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runStatusCommand));
addJsonOption(daemon.command("stop").description("Stop the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option("--kill-timeout <seconds>", "Wait after SIGKILL before failing (default: 3)")
.action(withOutput(runStopCommand));
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option("--web-ui", "Enable the bundled daemon web UI on restarted daemon")
.option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon")
.option(
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(
withOutput((...args) => {
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
return runRestartCommand(
{
...options,
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
},
command,
);
}),
);
for (const command of [
startCommand(),
daemonRunCommand(),
daemonStatusCommand(),
daemonStopCommand(),
daemonRestartCommand(),
daemonReloadCommand(),
pairCommand(),
daemonConfigCommand(),
])
daemon.addCommand(command);
addJsonOption(
daemon
.command("set-password")
.description("Prompt for and save a hashed daemon password to config.json"),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runSetPasswordCommand));
addLocalDaemonOptions(
daemon.command("set-password").description("Save a hashed daemon password (local operation)"),
),
).action(withOutput(runSetPasswordCommand));
return daemon;
}
@@ -0,0 +1,609 @@
import { spawn } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server";
import { expect, test } from "vitest";
import { connectToDaemon } from "../../utils/client.js";
const repo = fileURLToPath(new URL("../../../../..", import.meta.url));
const cli = path.join(repo, "packages/cli/dist/index.js");
async function port() {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const value = (server.address() as net.AddressInfo).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
if (value === 6767 || value === 6768) throw new Error("Unsafe test port");
return value;
}
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
const env = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
HOME: root,
USERPROFILE: root,
};
const homes = [path.join(root, "a"), path.join(root, "b")];
const owned = new Map<string, { pid: number; startedAt: string }>();
async function run(args: string[], overrides: NodeJS.ProcessEnv = {}) {
const child = spawn(process.execPath, [cli, ...args], {
cwd: root,
env: { ...env, ...overrides },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "",
stderr = "";
child.stdout.on("data", (data) => {
stdout += data;
});
child.stderr.on("data", (data) => {
stderr += data;
});
const code = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`));
}, 45_000);
child.once("error", reject);
child.once("close", (exitCode) => {
clearTimeout(timer);
resolve(exitCode);
});
});
for (const home of homes) {
try {
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
owned.set(home, lock);
} catch {
/* No acquired launch. */
}
}
return { code, stdout, stderr, json: () => JSON.parse(stdout) };
}
async function ok(args: string[], overrides?: NodeJS.ProcessEnv) {
const result = await run(["--json", ...args], overrides);
expect(result.code, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`).toBe(0);
return result.json();
}
async function liveStatus(home: string, overrides?: NodeJS.ProcessEnv) {
let status;
await expect
.poll(
async () => {
status = await ok(["status", "--home", home], overrides);
return status.connectedDaemon;
},
{ timeout: 30_000 },
)
.toBe("reachable");
return status;
}
async function configure(home: string, listen: string) {
for (const [field, value] of [
["daemon.listen", listen],
["daemon.relay.enabled", "false"],
["features.webUi.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
]) {
await ok(["daemon", "config", "set", field!, value!, "--home", home]);
}
}
async function close() {
for (const [home, captured] of owned) {
try {
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
if (lock.pid === captured.pid && lock.startedAt === captured.startedAt) {
await run(["daemon", "stop", "--home", home, "--force", "--timeout", "2"]);
}
} catch {
/* Already exited. */
}
}
await rm(root, { recursive: true, force: true });
}
return { root, homes, env, run, ok, liveStatus, configure, close };
}
test("managed two-home restart retains its supervisor and never routes ordinary commands or stop to the other home", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
const portA = await port(),
portB = await port(),
movedPort = await port();
try {
await f.configure(a, `127.0.0.1:${portA}`);
await f.configure(b, `127.0.0.1:${portB}`);
const launchA = await f.ok(["start", "--home", a, "--timeout", "30"]);
const poisoned = {
PASEO_HOME: a,
PASEO_HOST: `127.0.0.1:${portA}`,
PASEO_LISTEN: `127.0.0.1:${portA}`,
PORT: String(portA),
PASEO_WEB_UI_ENABLED: "true",
PASEO_RELAY_ENABLED: "true",
};
const launchB = await f.ok(["daemon", "start", "--home", b, "--timeout", "30"], poisoned);
expect(launchA.listen).toBe(`127.0.0.1:${portA}`);
expect(launchB.listen).toBe(`127.0.0.1:${portB}`);
const beforeA = await f.liveStatus(a);
const beforeB = await f.ok(["--home", b, "daemon", "status"], poisoned);
if (process.platform !== "win32") {
for (const home of [a, b, path.join(f.root, ".paseo")])
expect((await stat(home)).mode & 0o777).toBe(0o700);
}
const repoB = path.join(f.root, "project-b");
await mkdir(repoB);
await f.ok(["project", "create", repoB, "--home", b], poisoned);
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
expect(stream.stderr).toContain("Agent not found: missing-agent");
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
expect(restart.supervisorPid).toBe(launchB.pid);
expect(restart.workerPid).not.toBe(beforeB.workerPid);
const saved = await f.ok(
["daemon", "config", "set", "daemon.listen", `127.0.0.1:${movedPort}`, "--home", b],
poisoned,
);
expect(saved.restartRequiredPaths).toContain("daemon.listen");
await f.ok(["daemon", "restart", "--home", b, "--timeout", "30"], poisoned);
const moved = await f.liveStatus(b);
expect(moved.listen).toBe(`127.0.0.1:${movedPort}`);
expect(moved.pid).toBe(launchB.pid);
await f.ok(["daemon", "stop", "--home", b], poisoned);
const stoppedQuery = await f.run(["--json", "project", "ls", "--home", b], poisoned);
expect(stoppedQuery.code).toBe(1);
expect(stoppedQuery.stderr).toContain("DAEMON_NOT_RUNNING");
const afterA = await f.liveStatus(a);
expect(afterA.workerPid).toBe(beforeA.workerPid);
expect(afterA.pid).toBe(beforeA.pid);
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
} finally {
await f.close();
}
}, 120_000);
test("removed flags and ambiguous targets fail before side effects; observation does not create a home", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.ok(["status", "--home", home]);
await f.ok(["daemon", "config", "get", "--home", home]);
expect(existsSync(home)).toBe(false);
for (const args of [
["start", "--port", "12345"],
["start", "--foreground"],
["daemon", "restart", "--no-relay"],
]) {
const result = await f.run(["--json", ...args, "--home", home]);
expect(result.code).toBe(1);
expect(result.stderr).toContain("REMOVED_LAUNCH_OPTION");
expect(existsSync(home)).toBe(false);
}
const ambiguous = await f.run(["--home", home, "project", "ls", "--host", "unused:12345"]);
expect(ambiguous.stderr).toContain("Choose either --home or --host");
expect(existsSync(home)).toBe(false);
} finally {
await f.close();
}
}, 30_000);
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
const portA = await port(),
portB = await port();
try {
await f.configure(a, `127.0.0.1:${portA}`);
await f.configure(b, `127.0.0.1:${portA}`);
await f.ok(["start", "--home", a, "--timeout", "30"]);
const before = await f.liveStatus(a);
const failed = await f.run(["--json", "start", "--home", b, "--timeout", "30"]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain("DAEMON_START_FAILED");
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portB}`, "--home", b]);
await f.ok(["start", "--home", b, "--timeout", "30"]);
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portA}`, "--home", b]);
const restart = await f.run(["--json", "restart", "--home", b, "--timeout", "30"]);
expect(restart.code).toBe(1);
expect(restart.stderr).toContain("RESTART_NOT_CONFIRMED");
await f.ok(["daemon", "stop", "--home", b]);
expect((await f.liveStatus(a)).workerPid).toBe(before.workerPid);
} finally {
await f.close();
}
}, 120_000);
test("a slow worker outlives the caller's readiness deadline and remains stoppable", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 5000));',
);
const launch = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
});
expect(launch.code).toBe(1);
expect(launch.stderr).toContain("DAEMON_NOT_READY");
expect(launch.stderr).toContain("remains running");
expect(launch.stderr).toContain("Logs:");
expect(launch.stderr).toContain("Status:");
expect(launch.stderr).toContain("Stop:");
const pending = await f.ok(["status", "--home", home]);
expect(pending.localDaemon).toBe("not_ready");
expect(pending.listen).toBeNull();
process.kill(pending.pid, 0);
const already = await f.ok(["start", "--home", home]);
expect(already.action).toBe("already_running");
expect(already.pid).toBe(pending.pid);
await f.ok(["project", "ls", "--home", home]);
expect((await f.ok(["status", "--home", home])).pid).toBe(pending.pid);
await f.ok(["daemon", "stop", "--home", home]);
} finally {
await f.close();
}
}, 60_000);
test("worker restart preserves an already-running legacy supervisor's launch flags", async () => {
const f = await fixture();
const home = f.homes[0]!;
const filePort = await port(),
launchPort = await port();
try {
await f.configure(home, `127.0.0.1:${filePort}`);
const launch = await startDaemonInstance({
home,
command: process.execPath,
args: [
path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js"),
"--no-relay",
"--no-web-ui",
],
// The legacy CLI translated --port into PASEO_LISTEN before spawning.
env: { ...f.env, PASEO_LISTEN: `127.0.0.1:${launchPort}` },
mode: "deployment",
timeoutMs: 30_000,
});
const before = await f.liveStatus(home);
expect(before.listen).toBe(`127.0.0.1:${launchPort}`);
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"], {
PASEO_LISTEN: `127.0.0.1:${filePort}`,
});
expect(restarted.supervisorPid).toBe(launch.instance.pid);
expect(restarted.workerPid).not.toBe(before.workerPid);
expect((await f.ok(["status", "--home", home])).listen).toBe(before.listen);
} finally {
await f.close();
}
}, 60_000);
test.skipIf(process.platform === "win32")(
"POSIX stop ignores a wrong endpoint even with copied identities; supported live identity overrides still restart",
async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
try {
await f.configure(a, `127.0.0.1:${await port()}`);
await f.configure(b, `127.0.0.1:${await port()}`);
await writeFile(path.join(b, "server-id"), "saved-b");
await f.ok(["start", "--home", a, "--timeout", "30"]);
await f.ok(["start", "--home", b, "--timeout", "30"], { PASEO_SERVER_ID: "live-b" });
const beforeA = await f.liveStatus(a);
expect((await f.liveStatus(b)).serverId).toBe("live-b");
await f.ok(["restart", "--home", b, "--timeout", "30"]);
const bLock = JSON.parse(await readFile(path.join(b, "paseo.pid"), "utf8"));
await writeFile(
path.join(b, "paseo.pid"),
JSON.stringify({ ...bLock, listen: beforeA.listen }),
);
await writeFile(path.join(b, "server-id"), beforeA.serverId);
await f.ok(["daemon", "stop", "--home", b]);
expect((await f.liveStatus(a)).workerPid).toBe(beforeA.workerPid);
} finally {
await f.close();
}
},
60_000,
);
test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
"explicit cancellation before readiness cleans up only this launch: %j",
async (...launchArgs) => {
const f = await fixture();
const home = f.homes[0]!;
let child: ReturnType<typeof spawn> | undefined;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
);
child = spawn(process.execPath, [cli, ...launchArgs, "--home", home], {
cwd: repo,
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
await f.close();
}
},
60_000,
);
test.runIf(process.platform === "win32")(
"Windows unbound stop requires explicit force",
async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
);
const pending = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
});
expect(pending.stderr).toContain("DAEMON_NOT_READY");
const refused = await f.run(["--json", "daemon", "stop", "--home", home]);
expect(refused.code).toBe(1);
expect(refused.stderr).toContain("STOP_NO_GRACEFUL_CHANNEL");
const stopped = await f.ok(["daemon", "stop", "--home", home, "--force"]);
expect(stopped.forced).toBe(true);
expect(stopped.usedLifecycleRpc).toBe(false);
} finally {
await f.close();
}
},
60_000,
);
test("empty explicit selectors never select the ambient daemon or create local state", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
await f.ok(["start", "--home", home]);
const before = await f.liveStatus(home);
for (const args of [
["daemon", "stop", "--home", ""],
["daemon", "stop", "--host", ""],
["start", "--home", ""],
["daemon", "config", "set", "daemon.relay.enabled", "true", "--home", ""],
]) {
const refused = await f.run(["--json", ...args], { PASEO_HOME: home });
expect(refused.code).toBe(1);
expect(refused.stderr).toContain("TARGET_INVALID");
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
expect(existsSync(path.join(f.root, "paseo.pid"))).toBe(false);
expect(existsSync(path.join(f.root, "config.json"))).toBe(false);
}
} finally {
await f.close();
}
}, 60_000);
test("IPv6 publication supports home-selected query and worker restart", async () => {
const f = await fixture();
const home = f.homes[0]!;
const endpoint = `[::1]:${await port()}`;
try {
await f.configure(home, endpoint);
const launch = await f.ok(["start", "--home", home, "--timeout", "30"]);
expect(launch.listen).toBe(endpoint);
expect(await f.ok(["project", "ls", "--home", home])).toEqual([]);
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]);
expect(restarted.supervisorPid).toBe(launch.pid);
expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid);
} finally {
await f.close();
}
}, 60_000);
test("raw log following subscribes to a stored agent that exists only in B", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
let follower: ReturnType<typeof spawn> | undefined;
try {
await f.configure(a, `127.0.0.1:${await port()}`);
await f.configure(b, `127.0.0.1:${await port()}`);
const agentId = "4de8b157-7614-4c38-b189-e50e57fb9498";
const now = new Date().toISOString();
await mkdir(path.join(b, "agents"));
await writeFile(
path.join(b, "agents", `${agentId}.json`),
JSON.stringify({
id: agentId,
provider: "codex",
cwd: f.root,
title: "B-only stored agent",
createdAt: now,
updatedAt: now,
lastStatus: "closed",
persistence: null,
}),
);
const launchA = await f.ok(["start", "--home", a]);
await f.ok(["start", "--home", b]);
const absent = await f.run(["logs", agentId, "--home", a]);
expect(absent.stderr).toContain("Agent not found");
follower = spawn(
process.execPath,
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
{
cwd: f.root,
env: { ...f.env, PASEO_HOME: a, PASEO_HOST: launchA.listen },
stdio: ["ignore", "pipe", "pipe"],
},
);
let output = "",
errors = "";
follower.stdout!.on("data", (data) => {
output += data;
});
follower.stderr!.on("data", (data) => {
errors += data;
});
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
expect(errors).not.toContain("Agent not found");
} finally {
if (follower && follower.exitCode === null) {
const exited = new Promise((resolve) => follower!.once("exit", resolve));
follower.kill("SIGINT");
await exited;
}
await f.close();
}
}, 60_000);
test("a connection pinned to an earlier supervisor refuses its replacement", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
await f.ok(["start", "--home", home]);
const captured = (await readDaemonInstance(home))!;
await f.ok(["daemon", "stop", "--home", home]);
await f.ok(["start", "--home", home]);
const before = await f.liveStatus(home);
await expect(
connectToDaemon({ target: { kind: "instance", home }, instance: captured, timeout: 1000 }),
).rejects.toMatchObject({ code: "DAEMON_REPLACED" });
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
} finally {
await f.close();
}
}, 60_000);
test("foreground deployment retains environment until its owner ends the launch; endpoint stop reports acknowledgment", async () => {
const f = await fixture();
const home = f.homes[0]!;
const fileEndpoint = `127.0.0.1:${await port()}`,
deploymentEndpoint = `127.0.0.1:${await port()}`,
changedEndpoint = `127.0.0.1:${await port()}`;
let deployment: ReturnType<typeof spawn> | undefined;
try {
await f.configure(home, fileEndpoint);
deployment = spawn(process.execPath, [cli, "daemon", "run", "--home", home], {
cwd: f.root,
env: { ...f.env, PASEO_LISTEN: deploymentEndpoint, PASEO_RELAY_ENABLED: "false" },
stdio: "ignore",
});
await expect
.poll(async () => (await readDaemonInstance(home))?.listen, { timeout: 30_000 })
.toBe(deploymentEndpoint);
const before = await f.liveStatus(home);
expect(before.configuredListen).toBe(fileEndpoint);
expect(await f.ok(["daemon", "run", "--home", home])).toMatchObject({
action: "already_running",
pid: before.pid,
});
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
const changed = await f.ok([
"daemon",
"config",
"set",
"daemon.listen",
changedEndpoint,
"--home",
home,
]);
expect(changed.overrideControlledPaths).toContain("daemon.listen");
const restarted = await f.ok(["restart", "--home", home], { PASEO_LISTEN: fileEndpoint });
expect(restarted.supervisorPid).toBe(before.pid);
expect((await f.ok(["status", "--home", home])).listen).toBe(deploymentEndpoint);
const exited = new Promise((resolve) => deployment!.once("exit", resolve));
const remote = await f.ok(["daemon", "stop", "--host", deploymentEndpoint]);
expect(remote.action).toBe("shutdown_requested");
await exited;
const managed = await f.ok(["start", "--home", home]);
expect(managed.listen).toBe(changedEndpoint);
expect(managed.pid).not.toBe(before.pid);
} finally {
if (deployment?.exitCode === null) deployment.kill("SIGTERM");
await f.close();
}
}, 90_000);
test("malformed configuration cannot turn a readiness timeout into launch cancellation", async () => {
const f = await fixture();
const home = f.homes[0]!;
let starter: ReturnType<typeof spawn> | undefined;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const configPath = path.join(home, "config.json");
const saved = await readFile(configPath, "utf8");
const preload = path.join(f.root, "slow-worker.mjs");
await writeFile(
preload,
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
);
starter = spawn(process.execPath, [cli, "--json", "start", "--home", home, "--timeout", "2"], {
cwd: f.root,
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
stdio: ["ignore", "pipe", "pipe"],
});
let errors = "";
starter.stderr!.on("data", (data) => {
errors += data;
});
const exited = new Promise((resolve) => starter!.once("exit", resolve));
await expect
.poll(async () => (await readDaemonInstance(home))?.pid, { timeout: 10_000 })
.toBeTruthy();
const captured = (await readDaemonInstance(home))!;
await f.ok(["status", "--home", home]);
await writeFile(configPath, "{");
await exited;
expect(errors).toContain("DAEMON_NOT_READY");
expect(errors).toContain(path.join(home, "daemon.log"));
process.kill(captured.pid, 0);
await writeFile(configPath, saved);
await f.ok(["status", "--home", home]);
} finally {
if (starter?.exitCode === null) starter.kill("SIGTERM");
await f.close();
}
}, 60_000);
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
await f.ok(["daemon", "config", "set", "daemon.mcp.enabled", "false", "--home", home]);
for (const flags of [[], ["--no-mcp"]]) {
const onboard = await f.run(["onboard", "--home", home, "--voice", "disable", ...flags]);
expect(onboard.code, onboard.stderr).toBe(0);
expect(
(await f.ok(["daemon", "config", "get", "daemon.mcp.enabled", "--home", home])).value,
).toBe(false);
}
} finally {
await f.close();
}
}, 60_000);
@@ -1,214 +0,0 @@
import { EventEmitter } from "node:events";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
resolveLocalDaemonState,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
type RecordedDaemonLaunch =
| {
mode: "detached";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
}
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
pid = 4242;
wasUnreferenced = false;
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
}
const tempRoots: string[] = [];
async function createPaseoHome(config: unknown): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-"));
tempRoots.push(root);
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
return paseoHome;
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
vi.restoreAllMocks();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const runtime = new FakeDaemonRuntime();
const resultPromise = startLocalDaemonDetached(
{ home: "/tmp/paseo-test", mcp: false },
runtime,
);
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("detached");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-mcp");
});
test("relay TLS flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
relayUseTls: true,
},
runtime,
);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
test("web UI flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
webUi: true,
},
runtime,
);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--web-ui");
expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true");
});
test("no-web UI flag is passed to the supervised daemon", async () => {
const runtime = new FakeDaemonRuntime();
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
webUi: false,
},
runtime,
);
expect(status).toBe(0);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--no-web-ui");
expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false");
});
test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
relay: {
endpoint: "10.0.0.5:51185",
publicEndpoint: "paseo.example.com",
useTls: false,
publicUseTls: true,
},
},
});
const state = resolveLocalDaemonState({ home });
expect(state.relayEndpoint).toBe("paseo.example.com");
expect(state.relayUseTls).toBe(false);
expect(state.relayPublicUseTls).toBe(true);
});
});
+62 -715
View File
@@ -1,184 +1,9 @@
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { Command, Option } from "commander";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
import treeKill from "tree-kill";
import { tryConnectToDaemon } from "../../utils/client.js";
export interface DaemonStartOptions {
port?: string;
listen?: string;
home?: string;
foreground?: boolean;
relay?: boolean;
relayUseTls?: boolean;
mcp?: boolean;
injectMcp?: boolean;
webUi?: boolean;
hostnames?: string;
}
export interface LocalDaemonPidInfo {
pid: number;
startedAt?: string;
hostname?: string;
uid?: number;
listen?: string;
desktopManaged?: boolean;
}
export interface LocalDaemonState {
home: string;
listen: string;
relayEnabled: boolean;
relayEndpoint: string;
relayUseTls: boolean;
relayPublicUseTls: boolean;
logPath: string;
pidPath: string;
pidInfo: LocalDaemonPidInfo | null;
running: boolean;
stalePidFile: boolean;
}
export interface DetachedStartResult {
pid: number | null;
logPath: string;
}
export interface StopLocalDaemonOptions {
home?: string;
timeoutMs?: number;
killTimeoutMs?: number;
force?: boolean;
}
export interface StopLocalDaemonResult {
action: "stopped" | "not_running";
home: string;
pid: number | null;
forced: boolean;
usedLifecycleRpc: boolean;
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
message: string;
}
interface ProcessExitDetails {
code: number | null;
signal: NodeJS.Signals | null;
error?: Error;
}
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
import { startDaemonInstance, resolvePaseoHome } from "@getpaseo/server";
const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
exitedEarly: true,
...details,
});
function envWithHome(home?: string): NodeJS.ProcessEnv {
if (!home) {
return process.env;
}
return { ...process.env, PASEO_HOME: home };
}
function buildRunnerArgs(options: DaemonStartOptions): string[] {
const args: string[] = [];
if (options.relay === true) {
args.push("--relay");
}
if (options.relay === false) {
args.push("--no-relay");
}
if (options.relayUseTls === true) {
args.push("--relay-use-tls");
}
if (options.mcp === false) {
args.push("--no-mcp");
}
if (options.injectMcp === false) {
args.push("--no-inject-mcp");
}
if (options.webUi === true) {
args.push("--web-ui");
}
if (options.webUi === false) {
args.push("--no-web-ui");
}
return args;
}
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = { ...process.env };
if (options.home) {
childEnv.PASEO_HOME = options.home;
}
if (options.listen) {
childEnv.PASEO_LISTEN = options.listen;
} else if (options.port) {
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
}
if (options.hostnames) {
childEnv.PASEO_HOSTNAMES = options.hostnames;
}
if (options.relayUseTls === true) {
childEnv.PASEO_RELAY_USE_TLS = "true";
}
if (options.webUi === true) {
childEnv.PASEO_WEB_UI_ENABLED = "true";
}
if (options.webUi === false) {
childEnv.PASEO_WEB_UI_ENABLED = "false";
}
return childEnv;
}
function resolveServerRunnerFromDir(currentDir: string): string | null {
const packageJsonPath = path.join(currentDir, "package.json");
if (!existsSync(packageJsonPath)) return null;
@@ -215,550 +40,72 @@ function resolveDaemonRunnerEntry(): string {
throw new Error("Unable to resolve @getpaseo/server package root for daemon runner");
}
function pidFilePath(paseoHome: string): string {
return path.join(paseoHome, DAEMON_PID_FILENAME);
}
function resolveListenField(listen: unknown, sockPath: unknown): string | undefined {
if (typeof listen === "string") return listen;
if (typeof sockPath === "string") return sockPath;
return undefined;
}
function resolveStopMessage(
forced: boolean,
lifecycleRequested: boolean,
fallbackMessage: string | null | undefined,
): string {
if (forced) return "Daemon owner process was force-stopped";
if (lifecycleRequested) return "Daemon stopped gracefully";
return fallbackMessage ?? "Daemon stopped via owner PID signal";
}
function resolveStopReason(
forced: boolean,
lifecycleRequested: boolean,
): StopLocalDaemonResult["reason"] {
if (forced) return "owner_pid_sigkill";
if (lifecycleRequested) return "lifecycle_shutdown_rpc";
return "owner_pid_signal";
}
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
export async function launchLocalDaemon(options: {
home: string;
timeoutMs?: number;
foreground?: boolean;
}) {
const abort = new AbortController();
const cancel = () => abort.abort();
process.once("SIGINT", cancel);
process.once("SIGTERM", cancel);
try {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
const pidValue = parsed.pid;
if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) {
return null;
}
return {
pid: pidValue,
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen: resolveListenField(parsed.listen, parsed.sockPath),
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {
return null;
}
}
function tailFile(filePath: string, lines = 30): string | null {
try {
const content = readFileSync(filePath, "utf-8");
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
} catch {
return null;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
return typeof error.code === "string" ? error.code : undefined;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
return false;
}
}
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch (err) {
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
return false;
}
throw err;
}
}
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false;
}
try {
return signalProcess(pid, signal);
} catch (err) {
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
throw err;
}
}
async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise<boolean> {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false;
}
return new Promise((resolve, reject) => {
treeKill(pid, signal, (err) => {
if (!err) {
resolve(true);
return;
}
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
resolve(false);
return;
}
if (code === "EPERM") {
resolve(true);
return;
}
reject(err);
const entry = resolveDaemonRunnerEntry();
return await startDaemonInstance({
home: resolvePaseoHome({ PASEO_HOME: options.home }),
command: process.execPath,
args: [...(entry.endsWith(".ts") ? ["--import", "tsx"] : []), entry],
env: process.env,
mode: options.foreground ? "deployment" : "managed",
foreground: options.foreground,
timeoutMs: options.timeoutMs,
signal: abort.signal,
onReady: options.foreground
? (instance) =>
process.stdout.write(`Listening on ${instance.listen} (PID ${instance.pid})\n`)
: undefined,
});
});
}
async function signalProcessTreeOrOwnerSafely(
pid: number,
signal: NodeJS.Signals,
): Promise<boolean> {
try {
return await signalProcessTreeSafely(pid, signal);
} catch {
return signalProcessSafely(pid, signal);
}
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
if (!isProcessRunning(pid)) return true;
if (Date.now() >= deadline) return !isProcessRunning(pid);
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
async function waitForDaemonUnreachable(
state: LocalDaemonState,
timeoutMs: number,
): Promise<boolean> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return true;
}
const reachableHost = host;
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
if (!client) {
return true;
}
await client.close().catch(() => undefined);
if (Date.now() >= deadline) {
const finalClient = await tryConnectToDaemon({
host: reachableHost,
timeout: PID_POLL_INTERVAL_MS,
});
if (!finalClient) {
return true;
}
await finalClient.close().catch(() => undefined);
return false;
}
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
function removeStalePidFile(state: LocalDaemonState): void {
if (!state.stalePidFile) {
return;
}
try {
unlinkSync(state.pidPath);
} catch {
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
}
}
function createNotRunningStopResult(
state: LocalDaemonState,
pid: number | null,
message: string,
): StopLocalDaemonResult {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
usedLifecycleRpc: false,
reason: "not_running",
message,
};
}
function createStopTimeoutError(
state: LocalDaemonState,
pid: number | null,
timeoutMs: number,
): Error {
if (!state.running) {
const host = resolveTcpHostFromListen(state.listen);
return new Error(
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
timeoutMs / 1000,
)}s`,
);
}
return new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
async function signalDaemonOwnerForStop(
state: LocalDaemonState,
pid: number | null,
): Promise<StopLocalDaemonResult | null> {
if (pid === null) {
return createNotRunningStopResult(state, null, "Daemon is not running");
}
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (signaled) {
return null;
}
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
}
async function waitForStopAfterRequest(args: {
state: LocalDaemonState;
pid: number | null;
timeoutMs: number;
killTimeoutMs: number;
force?: boolean;
}): Promise<{ stopped: boolean; forced: boolean }> {
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
let stopped =
state.running && pid !== null
? await waitForPidExit(pid, timeoutMs)
: await waitForDaemonUnreachable(state, timeoutMs);
if (!stopped && force && state.running && pid !== null) {
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
return { stopped, forced: true };
}
return { stopped, forced: false };
}
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home));
}
export function resolveTcpHostFromListen(listen: string): string | null {
const normalized = listen.trim();
if (!normalized) {
return null;
}
if (
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
if (/^\d+$/.test(normalized)) {
return `127.0.0.1:${normalized}`;
}
if (normalized.includes(":")) {
return normalized;
}
return null;
}
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
const env: NodeJS.ProcessEnv = {
...envWithHome(options.home),
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
// This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available.
PASEO_LISTEN: undefined,
PASEO_HOSTNAMES: undefined,
PASEO_ALLOWED_HOSTS: undefined,
PASEO_RELAY_ENABLED: undefined,
PASEO_RELAY_ENDPOINT: undefined,
PASEO_RELAY_PUBLIC_ENDPOINT: undefined,
PASEO_RELAY_USE_TLS: undefined,
PASEO_RELAY_PUBLIC_USE_TLS: undefined,
};
const home = resolvePaseoHome(env);
const config = loadConfig(home, { env });
const pidPath = pidFilePath(home);
const logPath = path.join(home, DAEMON_LOG_FILENAME);
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
const listen = pidInfo?.listen ?? config.listen;
return {
home,
listen,
relayEnabled: config.relayEnabled ?? true,
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: config.relayUseTls ?? false,
relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false,
logPath,
pidPath,
pidInfo,
running,
stalePidFile: Boolean(pidInfo) && !running,
};
}
export function tailDaemonLog(home?: string, lines = 30): string | null {
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
return tailFile(logPath, lines);
}
export async function startLocalDaemonDetached(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const paseoHome = runtime.resolveHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = runtime.spawnDetached(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
detached: true,
envMode: "internal",
env: childEnv,
stdio: ["ignore", "ignore", "ignore"],
},
);
child.unref();
const startup = await new Promise<DetachedStartupResult>((resolve) => {
let settled = false;
const finish = (value: DetachedStartupResult) => {
if (settled) return;
settled = true;
resolve(value);
};
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
child.once("error", (error) => {
clearTimeout(timer);
finish(startupExited({ code: null, signal: null, error }));
});
child.once("exit", (code, signal) => {
clearTimeout(timer);
finish(startupExited({ code, signal }));
});
});
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
: `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`;
const recentLogs = tailFile(logPath);
throw new Error(
[
`Daemon failed to start in background (${reason}).`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
}
return {
pid: child.pid ?? null,
logPath,
};
}
export function startLocalDaemonForeground(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const result = runtime.spawnForeground(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
env: childEnv,
stdio: "inherit",
},
);
if (result.error) {
throw result.error;
}
return result.status ?? 1;
}
async function requestLifecycleShutdown(
state: LocalDaemonState,
timeoutMs: number,
): Promise<LifecycleShutdownAttempt> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return {
requested: false,
reason: "daemon listen target is not TCP, falling back to owner PID signal",
};
}
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) });
if (!client) {
return {
requested: false,
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
};
}
try {
await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) });
return { requested: true };
} catch (error) {
return {
requested: false,
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
error,
)}), falling back to owner PID signal`,
};
} finally {
await client.close().catch(() => undefined);
process.removeListener("SIGINT", cancel);
process.removeListener("SIGTERM", cancel);
}
}
export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {},
): Promise<StopLocalDaemonResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
export function parseTimeoutMs(raw: unknown, fallback = 600_000): number {
if (raw === undefined) return fallback;
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0)
throw { code: "INVALID_TIMEOUT", message: "Timeout must be a positive number of seconds." };
return Math.ceil(seconds * 1000);
}
const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs());
const lifecycleRequested = shutdownAttempt.requested;
const REMOVED_LAUNCH_FLAGS: Record<string, string> = {
"--port <port>": "daemon.listen",
"--listen <listen>": "daemon.listen",
"--relay": "daemon.relay.enabled",
"--no-relay": "daemon.relay.enabled",
"--relay-use-tls": "daemon.relay.useTls",
"--no-mcp": "daemon.mcp.enabled",
"--no-inject-mcp": "daemon.mcp.injectIntoAgents",
"--web-ui": "features.webUi.enabled",
"--no-web-ui": "features.webUi.enabled",
"--hostnames <hosts>": "daemon.hostnames",
"--allowed-hosts <hosts>": "daemon.hostnames",
"--foreground": "",
};
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return createNotRunningStopResult(
state,
state.pidInfo?.pid ?? null,
`Daemon is not running${staleSuffix}`,
);
}
const pid = state.pidInfo?.pid ?? null;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
if (!lifecycleRequested) {
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
if (notRunningResult) return notRunningResult;
}
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs: remainingTimeoutMs(),
killTimeoutMs,
force: options.force,
export function rejectRemovedLaunchFlags(command: Command): Command {
for (const flag of Object.keys(REMOVED_LAUNCH_FLAGS))
command.addOption(new Option(flag).hideHelp());
command.hook("preAction", () => {
for (const [flag, configPath] of Object.entries(REMOVED_LAUNCH_FLAGS)) {
const name = new Option(flag).attributeName();
if (command.getOptionValueSource(name) !== "cli") continue;
throw {
code: "REMOVED_LAUNCH_OPTION",
message: `${flag.split(" ")[0]} was removed. ${configPath ? `Use paseo daemon config set ${configPath} <value> --home <path>, then start or restart.` : "Use paseo daemon run --home <path> for foreground deployment."} Deployment environment overrides belong to paseo daemon run.`,
};
}
});
if (!stopped) {
throw createStopTimeoutError(state, pid, timeoutMs);
}
if (lifecycleRequested) {
removeStalePidFile(state);
}
return {
action: "stopped",
home: state.home,
pid,
forced,
usedLifecycleRpc: lifecycleRequested,
reason: resolveStopReason(forced, lifecycleRequested),
message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage),
};
return command;
}
+26 -137
View File
@@ -1,139 +1,28 @@
import { describe, expect, test, vi } from "vitest";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "vitest";
import { resolveLocalPairingOffer } from "./pair.js";
import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js";
const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null };
const enabledOffer: PairingOffer = {
relayEnabled: true,
url: "https://app.paseo.sh/#offer=test",
qr: null,
};
interface RecordedPairCommandOutput extends PairCommandOutput {
stdout: string[];
stderr: string[];
successes: string[];
exitCode: number | undefined;
}
function createRecordedOutput(): RecordedPairCommandOutput {
return {
columns: 80,
stdout: [],
stderr: [],
successes: [],
exitCode: undefined,
writeStdout(message) {
this.stdout.push(message);
},
writeStderr(message) {
this.stderr.push(message);
},
setExitCode(code) {
this.exitCode = code;
},
success(message) {
this.successes.push(message);
},
};
}
describe("daemon pair workflow", () => {
test("interactive decline prints direct guidance and creates no pairing output", async () => {
const resolveOffer = vi.fn(async () => disabledOffer);
const confirmRelay = vi.fn(async () => false);
const printDirectGuidance = vi.fn();
const output = createRecordedOutput();
await runPairCommand(
{},
{ resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output },
);
expect(confirmRelay).toHaveBeenCalledOnce();
expect(printDirectGuidance).toHaveBeenCalledOnce();
expect(output.stderr.join("")).toContain("No pairing QR was created");
expect(output.exitCode).toBe(1);
});
test("interactive consent enables relay and prints the refreshed offer", async () => {
const resolveOffer = vi
.fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>()
.mockResolvedValueOnce(disabledOffer)
.mockResolvedValueOnce(enabledOffer);
const output = createRecordedOutput();
await runPairCommand(
{},
{
resolveOffer,
confirmRelay: async () => true,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true }));
expect(output.stdout.join("")).toContain(enabledOffer.url ?? "");
expect(output.successes).toEqual(["Relay enabled"]);
});
test("JSON mode never prompts and returns a structured relay-disabled error", async () => {
const confirmRelay = vi.fn(async () => true);
const output = createRecordedOutput();
await runPairCommand(
{ json: true },
{
resolveOffer: async () => disabledOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"');
expect(output.exitCode).toBe(1);
});
test("explicit relay opts in without prompting", async () => {
const resolveOffer = vi.fn(async () => enabledOffer);
const confirmRelay = vi.fn(async () => false);
const output = createRecordedOutput();
await runPairCommand(
{ relay: true, json: true },
{
resolveOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output,
},
);
expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true }));
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.exitCode).toBeUndefined();
});
test("surfaces launch-override rejection", async () => {
await expect(
runPairCommand(
{ relay: true },
{
resolveOffer: async () => {
throw new Error("Relay is controlled by a daemon launch override");
},
confirmRelay: vi.fn(),
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output: createRecordedOutput(),
},
),
).rejects.toThrow("launch override");
});
test("offline pairing requires relay consent and saves it in the selected home", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-offline-pair-"));
const home = path.join(root, "home");
try {
expect(await resolveLocalPairingOffer({ paseoHome: home })).toMatchObject({
relayEnabled: false,
url: null,
});
expect(existsSync(home)).toBe(false);
const offer = await resolveLocalPairingOffer({ paseoHome: home, enableRelay: true });
expect(offer.relayEnabled).toBe(true);
expect(offer.url).toContain("offer=");
expect(
JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).daemon.relay.enabled,
).toBe(true);
expect(existsSync(path.join(home, "server-id"))).toBe(true);
expect(existsSync(path.join(home, "daemon-keypair.json"))).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+52 -79
View File
@@ -3,29 +3,23 @@ import { Command } from "commander";
import chalk from "chalk";
import {
generateLocalPairingOffer,
getOrCreateServerId,
loadConfig,
resolvePaseoHome,
readDaemonInstance,
readPersistedConfig,
editPersistedConfig,
resolveConfigFromPersisted,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { addJsonOption } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
interface PairOptions {
daemonTarget: DaemonTarget;
home?: string;
json?: boolean;
relay?: boolean;
}
export interface PairCommandDependencies {
resolveOffer: typeof resolveLocalPairingOffer;
confirmRelay: typeof confirmRelayPairing;
printDirectGuidance: typeof printDirectConnectionGuidance;
isInteractive: () => boolean;
output: PairCommandOutput;
}
export interface PairCommandOutput {
columns: number | undefined;
writeStdout(message: string): void;
@@ -62,33 +56,32 @@ function createProcessOutput(): PairCommandOutput {
}
export function pairCommand(): Command {
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
return addJsonAndDaemonHostOptions(
new Command("pair").description("Print the daemon pairing QR code and link"),
)
.option("--relay", "Enable relay without prompting")
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals());
});
.action(
withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)),
);
}
export async function resolveLocalPairingOffer(options: {
paseoHome: string;
enableRelay?: boolean;
}): Promise<PairingOffer> {
const state = resolveLocalDaemonState({ home: options.paseoHome });
const serverId = getOrCreateServerId(state.home);
const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay);
if (daemonOffer) return daemonOffer;
if (state.running) {
throw new Error(
"The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.",
const instance = await readDaemonInstance(options.paseoHome);
if (instance)
return resolveDaemonPairingOffer(
{ kind: "instance", home: options.paseoHome },
options.enableRelay,
);
}
const config = loadConfig(options.paseoHome);
if (options.enableRelay && !config.relayEnabled) {
throw new Error("Start the daemon before enabling relay for pairing.");
}
if (options.enableRelay)
editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true });
const config = resolveConfigFromPersisted(
options.paseoHome,
readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }),
{ env: {} },
);
return generateLocalPairingOffer({
paseoHome: options.paseoHome,
@@ -103,23 +96,16 @@ export async function resolveLocalPairingOffer(options: {
}
async function resolveDaemonPairingOffer(
listen: string,
expectedServerId: string,
target: DaemonTarget,
enableRelay: boolean | undefined,
): Promise<PairingOffer | null> {
const client = await tryConnectToDaemon({
host: listen,
): Promise<PairingOffer> {
const client = await connectToDaemon({
target,
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
if (!client) return null;
try {
const serverInfo = client.getLastServerInfoMessage();
if (serverInfo?.serverId.trim() !== expectedServerId) {
throw new Error(
"The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.",
);
}
if (serverInfo?.features?.daemonStatusRpc !== true) {
throw new Error("Update the Paseo daemon before pairing from this command.");
}
@@ -132,9 +118,14 @@ async function resolveDaemonPairingOffer(
throw new Error("Update the Paseo daemon before enabling relay from this command.");
}
await client.patchDaemonConfig({ relay: { enabled: true } });
offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
try {
offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS });
} catch (error) {
throw new Error(
`Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`,
{ cause: error },
);
}
}
return {
relayEnabled: offer.relayEnabled,
@@ -164,40 +155,22 @@ export function printDirectConnectionGuidance(): void {
console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`);
}
export async function runPairCommand(
options: PairOptions,
dependencyOverrides: Partial<PairCommandDependencies> = {},
): Promise<void> {
if (options.home) process.env.PASEO_HOME = options.home;
const dependencies: PairCommandDependencies = {
resolveOffer: resolveLocalPairingOffer,
confirmRelay: confirmRelayPairing,
printDirectGuidance: printDirectConnectionGuidance,
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
output: createProcessOutput(),
...dependencyOverrides,
};
export async function runPairCommand(options: PairOptions): Promise<void> {
const output = createProcessOutput();
const target = options.daemonTarget;
const resolveOffer = (enableRelay: boolean) =>
target.kind === "instance"
? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay })
: resolveDaemonPairingOffer(target, enableRelay);
const offline = target.kind === "instance" && !(await readDaemonInstance(target.home));
const pairing = await resolveOffer(options.relay === true);
const paseoHome = resolvePaseoHome();
let pairing = await dependencies.resolveOffer({
paseoHome,
enableRelay: options.relay === true,
});
if (offline)
output.writeStderr(
`Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`,
);
const canPrompt = dependencies.isInteractive() && options.json !== true;
if (!pairing.relayEnabled && canPrompt) {
const shouldEnable = await dependencies.confirmRelay();
if (!shouldEnable) {
dependencies.printDirectGuidance();
dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`);
dependencies.output.setExitCode(1);
return;
}
pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true });
dependencies.output.success("Relay enabled");
}
outputPairingResult(pairing, options, dependencies.output);
outputPairingResult(pairing, options, output);
}
function outputPairingResult(
+14 -3
View File
@@ -1,8 +1,12 @@
import type { Command } from "commander";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { withOutput } from "../../output/index.js";
import { Command } from "commander";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
export interface DaemonReloadResult {
restartCommand: string;
appliedPaths: string[];
restartRequiredPaths: string[];
overrideControlledPaths: string[];
@@ -20,7 +24,7 @@ export const daemonReloadSchema: OutputSchema<DaemonReloadResult> = {
"Warning: These changes require a daemon restart:",
...result.data.restartRequiredPaths.map((path) => ` ${path}`),
"",
"Run: paseo daemon restart",
`Run: ${result.data.restartCommand}`,
);
}
if (result.data.overrideControlledPaths.length > 0) {
@@ -38,12 +42,13 @@ export async function runDaemonReloadCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<DaemonReloadResult>> {
const client = await connectToDaemon({ host: options.host });
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const payload = await client.reloadDaemonConfig();
return {
type: "single",
data: {
restartCommand: `paseo daemon restart ${options.daemonTarget.kind === "instance" ? `--home ${JSON.stringify(options.daemonTarget.home)}` : `--host ${JSON.stringify(describeDaemonTarget(options.daemonTarget))}`}`,
appliedPaths: payload.appliedPaths,
restartRequiredPaths: payload.restartRequiredPaths,
overrideControlledPaths: payload.overrideControlledPaths,
@@ -54,3 +59,9 @@ export async function runDaemonReloadCommand(
await client.close();
}
}
export function daemonReloadCommand(): Command {
return addJsonAndDaemonHostOptions(
new Command("reload").description("Reload config.json without restarting"),
).action(withOutput(runDaemonReloadCommand));
}
+113 -122
View File
@@ -1,131 +1,122 @@
import type { Command } from "commander";
import {
startLocalDaemonDetached,
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
type DaemonStartOptions,
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { Command } from "commander";
import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError } from "@getpaseo/server";
import { setTimeout as delay } from "node:timers/promises";
import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
import { parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js";
interface RestartResult {
action: "restarted";
home: string;
pid: string;
message: string;
export function daemonRestartCommand(): Command {
return rejectRemovedLaunchFlags(
addJsonAndDaemonHostOptions(
new Command("restart").description(
"Restart the selected daemon worker, retaining its supervisor launch",
),
),
)
.option("--timeout <seconds>", "Replacement readiness deadline (default: 600)")
.action(withOutput(runRestartCommand));
}
const restartResultSchema: OutputSchema<RestartResult> = {
idField: "action",
columns: [
{
header: "STATUS",
field: "action",
color: () => "green",
},
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
};
export type RestartCommandResult = SingleResult<RestartResult>;
function parseTimeoutMs(raw: unknown): number {
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
export async function runRestartCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget;
const deadline = Date.now() + parseTimeoutMs(options.timeout);
const remaining = () => Math.max(1, deadline - Date.now());
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
async function checkSupervisor() {
if (target.kind !== "instance") return;
const current = await readDaemonInstance(target.home);
if (!current || !instance || !isSameDaemonInstance(instance, current))
throw new DaemonInstanceError(
"DAEMON_REPLACED",
`Supervisor exited or was replaced for ${target.home}.`,
);
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: "Timeout must be a positive number of seconds",
};
throw error;
}
return Math.ceil(seconds * 1000);
}
function toStartOptions(options: CommandOptions): DaemonStartOptions {
const startOptions: DaemonStartOptions = {
home: typeof options.home === "string" ? options.home : undefined,
listen: typeof options.listen === "string" ? options.listen : undefined,
port: typeof options.port === "string" ? options.port : undefined,
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
webUi: typeof options.webUi === "boolean" ? options.webUi : undefined,
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
};
if (startOptions.listen && startOptions.port) {
const error: CommandError = {
code: "INVALID_OPTIONS",
message: "Cannot use --listen and --port together",
};
throw error;
}
return startOptions;
}
export async function runRestartCommand(
options: CommandOptions,
_command: Command,
): Promise<RestartCommandResult> {
const timeoutMs = parseTimeoutMs(options.timeout);
const force = options.force === true;
const startOptions = toStartOptions(options);
if (target.kind === "instance" && !instance)
throw new DaemonInstanceError(
"DAEMON_NOT_RUNNING",
`Daemon is not running for ${target.home}.`,
);
const client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: remaining(),
});
let workerPid: number;
const serverId = client.getLastServerInfoMessage()?.serverId;
let acknowledged = false;
try {
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>;
workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid;
await checkSupervisor();
try {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force,
});
} catch (err) {
const isTimeout =
err instanceof Error && err.message.includes("Timed out waiting for daemon PID");
if (!force && isTimeout) {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force: true,
});
} else {
throw err;
}
await client.restartServer("cli_restart", undefined, { timeout: remaining() });
acknowledged = true;
} catch (error) {
if (!isReconnectFailure(error)) throw error;
}
const startup = await startLocalDaemonDetached(startOptions);
const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`;
const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`;
return {
type: "single",
data: {
action: "restarted",
home: stopResult.home,
pid: startup.pid === null ? "-" : String(startup.pid),
message: `Local daemon restarted (${before} -> ${after})`,
},
schema: restartResultSchema,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "RESTART_FAILED",
message: `Failed to restart local daemon: ${message}`,
};
throw error;
} finally {
await client.close();
}
let lastError: unknown = "No replacement worker observed";
while (Date.now() < deadline) {
try {
const replacement = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: Math.min(1_000, remaining()),
});
try {
if (replacement.getLastServerInfoMessage()?.serverId !== serverId)
throw new Error("Connected peer identity changed");
const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) });
if (status.pid !== workerPid) {
await checkSupervisor();
return {
type: "single" as const,
data: {
action: "restarted",
target: describeDaemonTarget(target),
supervisorPid: instance?.pid ?? null,
previousWorkerPid: workerPid,
workerPid: status.pid,
acknowledged,
},
schema: {
idField: "action" as const,
columns: [],
renderHuman: () =>
`Restarted worker ${workerPid} → ${status.pid} at ${describeDaemonTarget(target)}. Supervisor launch retained.`,
},
};
}
} finally {
await replacement.close();
}
} catch (error) {
lastError = error;
const code = (error as { code?: string } | null)?.code;
if (code === "DAEMON_REPLACED" || code === "DAEMON_NOT_RUNNING") break;
if (!isReconnectFailure(error)) throw error;
}
await delay(Math.min(100, remaining()));
}
throw {
code: "RESTART_NOT_CONFIRMED",
message: `Replacement was not confirmed for ${describeDaemonTarget(target)}. Restart acknowledged: ${acknowledged}. Last observation: ${String(lastError)}`,
};
}
function isReconnectFailure(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
[
"DAEMON_CONNECTION_LOST",
"DAEMON_REQUEST_TIMEOUT",
"DAEMON_UNREACHABLE",
"DAEMON_NOT_READY",
].includes(String(error.code)),
);
}
@@ -3,7 +3,8 @@ import type { Command } from "commander";
import { isCancel, password as passwordPrompt } from "@clack/prompts";
import {
hashDaemonPassword,
loadPersistedConfig,
readPersistedConfig,
resolvePaseoHome,
savePersistedConfig,
type PersistedConfig,
} from "@getpaseo/server";
@@ -14,7 +15,6 @@ import type {
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { resolveLocalPaseoHome } from "./local-daemon.js";
const CONFIG_FILENAME = "config.json";
@@ -81,9 +81,9 @@ export async function setDaemonPasswordInConfig(
newPassword: string,
options: SetPasswordOptions = {},
): Promise<SetPasswordResult> {
const paseoHome = resolveLocalPaseoHome(options.home);
const paseoHome = resolvePaseoHome({ PASEO_HOME: options.home });
const configPath = path.join(paseoHome, CONFIG_FILENAME);
const persisted = loadPersistedConfig(paseoHome);
const persisted = readPersistedConfig(paseoHome);
const nextConfig: PersistedConfig = {
...persisted,
daemon: {
@@ -100,8 +100,8 @@ export async function setDaemonPasswordInConfig(
return {
action: "password_set",
configPath,
restartCommand: "paseo daemon restart",
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`,
restartCommand: `paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
};
}
@@ -115,7 +115,7 @@ export async function runSetPasswordCommand(
: (message: string) => passwordPrompt({ message });
const newPassword = await promptForPassword(promptPassword);
const result = await setDaemonPasswordInConfig(newPassword, {
home: typeof options.home === "string" ? options.home : undefined,
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
});
return {
+48 -64
View File
@@ -1,71 +1,55 @@
import { Command, Option } from "commander";
import chalk from "chalk";
import {
startLocalDaemonForeground,
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from "./local-daemon.js";
import { getErrorMessage } from "../../utils/errors.js";
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
type RawStartCommandOptions = StartOptions & {
allowedHosts?: string;
};
import { addLocalDaemonOptions } from "../../utils/command-options.js";
import { Command } from "commander";
import { daemonLogPath } from "@getpaseo/server";
import { launchLocalDaemon, parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
export function startCommand(): Command {
return new Command("start")
.description("Start the local Paseo daemon")
.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("--foreground", "Run in foreground (don't daemonize)")
.option("--relay", "Enable relay connection")
.option("--no-relay", "Disable relay connection")
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option("--web-ui", "Enable the bundled daemon web UI")
.option("--no-web-ui", "Disable the bundled daemon web UI")
.option(
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(async (options: RawStartCommandOptions) => {
await runStart({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("start")))
.description("Start the local daemon from persistent configuration (local operation)")
.option("--timeout <seconds>", "Readiness deadline (default: 600)")
.action(withOutput(runStart));
}
export async function runStart(options: StartOptions): Promise<void> {
if (options.listen && options.port) {
console.error(chalk.red("Cannot use --listen and --port together"));
process.exit(1);
}
if (!options.foreground) {
try {
const startup = await startLocalDaemonDetached(options);
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`));
console.log(chalk.dim(`Logs: ${startup.logPath}`));
} catch (err) {
exitWithError(getErrorMessage(err));
}
return;
}
try {
const status = startLocalDaemonForeground(options);
process.exit(status);
} catch (err) {
const message = getErrorMessage(err);
exitWithError(`Failed to start daemon: ${message}`);
}
export async function runStart(options: CommandOptions, _command: Command) {
if (options.daemonTarget.kind !== "instance") throw new Error("Start requires a local home");
const home = options.daemonTarget.home;
const result = await launchLocalDaemon({ home, timeoutMs: parseTimeoutMs(options.timeout) });
const data = {
action: result.spawned ? "started" : "already_running",
home,
pid: result.instance.pid,
listen: result.instance.listen,
logPath: daemonLogPath(home),
};
return {
type: "single" as const,
data,
schema: {
idField: "pid" as const,
columns: [],
renderHuman: () =>
`${result.spawned ? "Started" : "Already running"}: PID ${data.pid}${data.listen ? `, listening on ${data.listen}` : ", not ready"}\nLogs: ${data.logPath}`,
},
};
}
function exitWithError(message: string): never {
console.error(chalk.red(message));
process.exit(1);
export function daemonRunCommand(): Command {
return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("run")))
.description("Run a local daemon in the foreground with deployment environment overrides")
.action(
withOutput(async (options: CommandOptions, _command: Command) => {
if (options.daemonTarget.kind !== "instance") throw new Error("Run requires a local home");
const result = await launchLocalDaemon({
home: options.daemonTarget.home,
foreground: true,
});
process.exitCode = result.exitCode ?? 0;
return {
type: "single" as const,
data: { pid: result.instance.pid, action: result.spawned ? "exited" : "already_running" },
schema: { idField: "pid" as const, columns: [] },
};
}),
);
}
@@ -1,31 +0,0 @@
import { describe, expect, test } from "vitest";
import { selectRelayStatus } from "./status.js";
describe("selectRelayStatus", () => {
const persisted = {
enabled: false,
endpoint: "persisted.internal:443",
publicEndpoint: "persisted.example.com:443",
useTls: true,
publicUseTls: true,
};
test("uses the running daemon relay state over persisted config", () => {
expect(
selectRelayStatus({
persisted,
live: {
enabled: true,
endpoint: "live.internal:443",
publicEndpoint: "live.example.com:443",
useTls: true,
publicUseTls: true,
},
}),
).toBe("wss://live.example.com:443");
});
test("falls back to persisted config when the daemon cannot report live state", () => {
expect(selectRelayStatus({ persisted })).toBe("disabled");
});
});
+97 -456
View File
@@ -1,464 +1,105 @@
import type { Command } from "commander";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
import { Command } from "commander";
import {
readDaemonInstance,
readPersistedConfig,
resolveConfigFromPersisted,
daemonLogPath,
isSameDaemonInstance,
DaemonInstanceError,
} from "@getpaseo/server";
import { connectToDaemon, buildDaemonConnectionCommandError } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
interface ProviderBinaryStatus {
label: string;
path: string | null;
version: string | null;
source?: "daemon" | "local";
export function daemonStatusCommand(): Command {
return addJsonAndDaemonHostOptions(
new Command("status").description("Observe the selected daemon and its published endpoint"),
).action(withOutput(runStatusCommand));
}
interface DaemonStatus {
serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed";
home: string;
listen: string;
relay: string;
hostname: string | null;
pid: number | null;
startedAt: string | null;
owner: string | null;
logPath: string;
daemonNode: string;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
desktopManaged: boolean;
providers: ProviderBinaryStatus[];
note?: string;
}
interface StatusRow {
key: string;
value: string;
}
interface CliPackageJson {
version?: unknown;
}
const require = createRequire(import.meta.url);
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function shortenMessage(message: string, max = 120): string {
const normalized = message.replace(/\s+/g, " ").trim();
if (normalized.length <= max) {
return normalized;
}
return `${normalized.slice(0, max - 3)}...`;
}
function appendNote(current: string | undefined, next: string | undefined): string | undefined {
if (!next) return current;
if (!current) return next;
return `${current}; ${next}`;
}
function resolveCliVersion(): string {
try {
const packageJson = require("../../../package.json") as CliPackageJson;
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
return packageJson.version.trim();
}
} catch {
// Fall through.
}
return "unknown";
}
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key" },
{
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === "Local Daemon") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green";
if (item.value === "not_probed" || item.value === "auth_required") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
if (item.value === "not found" || item.value === "not found (daemon)") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
return "green";
}
return undefined;
},
},
],
serialize: () => status,
};
}
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Local Daemon", value: status.localDaemon },
{ key: "Connected Daemon", value: status.connectedDaemon },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Relay", value: status.relay },
{ key: "Hostname", value: status.hostname ?? "-" },
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
{ key: "Started", value: status.startedAt ?? "-" },
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
];
if (status.note) {
rows.push({ key: "Note", value: status.note });
}
rows.push({ key: "", value: "" });
rows.push({ key: "Providers", value: "" });
for (const provider of status.providers) {
if (provider.source === "daemon") {
if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" });
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` });
}
} else if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: "not found" });
} else if (!provider.version) {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` });
}
}
return rows;
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "Claude", binary: "claude" },
{ label: "Codex", binary: "codex" },
{ label: "OpenCode", binary: "opencode" },
];
async function checkProviderBinary(
binary: string,
): Promise<{ path: string | null; version: string | null }> {
const binaryPath = await findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null };
}
try {
const { stdout } = await execCommand(binaryPath, ["--version"], {
timeout: 5000,
});
return { path: binaryPath, version: stdout.trim() || null };
} catch {
return { path: binaryPath, version: null };
}
}
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
const results = await Promise.all(
PROVIDER_BINARIES.map(async ({ label, binary }) => {
const result = await checkProviderBinary(binary);
return Object.assign({ label }, result);
}),
);
return results;
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
if (uid === undefined && !hostname) {
return null;
}
const uidPart = uid === undefined ? "?" : String(uid);
const hostPart = hostname ?? "unknown-host";
return `${uidPart}@${hostPart}`;
}
interface DaemonProbeResult {
connectedDaemon: DaemonStatus["connectedDaemon"];
localDaemonOverride?: DaemonStatus["localDaemon"];
daemonVersion?: string | null;
daemonNodeOverride?: string;
daemonProviders?: ProviderBinaryStatus[];
relayStatus?: string;
note?: string;
}
type DaemonAuthProbeFailure = "auth_required" | "auth_failed";
function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null {
if (!(error instanceof Error)) return null;
if (error.message === "Password required") return "auth_required";
if (error.message === "Incorrect password") return "auth_failed";
return null;
}
function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string {
if (failure === "auth_required") {
return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`;
}
return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`;
}
async function probeDaemonOverWebsocket(args: {
host: string;
state: ReturnType<typeof resolveLocalDaemonState>;
}): Promise<DaemonProbeResult> {
const { host, state } = args;
let client: Awaited<ReturnType<typeof connectToDaemon>>;
try {
client = await connectToDaemon({ host, timeout: 1500 });
} catch (error) {
const authFailure = classifyDaemonAuthProbeFailure(error);
if (authFailure) {
return {
connectedDaemon: authFailure,
note: describeDaemonAuthProbeFailure(host, authFailure),
};
}
if (state.running) {
return {
connectedDaemon: "unreachable",
localDaemonOverride: "unresponsive",
note: `Local daemon PID is running but websocket at ${host} is not reachable`,
};
}
return { connectedDaemon: "unreachable" };
}
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
try {
const statusPayload = await client.getDaemonStatus({
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
});
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
const daemonProviders = statusPayload.providers.map((p) => ({
label: labelMap.get(p.provider) ?? p.provider,
path: p.available ? "available" : null,
version: p.available ? null : (p.error ?? null),
source: "daemon" as const,
}));
const relayStatus =
statusPayload.relay == null
? undefined
: selectRelayStatus({
persisted: relayConfigFromLocalState(state),
live: statusPayload.relay,
});
if (!state.running) {
return {
connectedDaemon: "reachable",
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
note: state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
};
}
return {
connectedDaemon: "reachable",
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
};
} catch {
return {
connectedDaemon: "reachable",
daemonVersion,
note: state.running
? `Local daemon PID is running but daemon detail request to ${host} failed`
: `Connected daemon websocket is reachable at ${host} but daemon status request failed`,
};
} finally {
await client.close().catch(() => {});
}
}
interface ProbeMergeState {
probe: DaemonProbeResult;
connectedDaemon: DaemonStatus["connectedDaemon"];
localDaemon: DaemonStatus["localDaemon"];
daemonNode: string;
daemonVersion: string | null;
daemonProviders: ProviderBinaryStatus[] | undefined;
relayStatus: string;
note: string | undefined;
}
function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "probe"> {
const { probe } = input;
return {
connectedDaemon: probe.connectedDaemon,
localDaemon: probe.localDaemonOverride ?? input.localDaemon,
daemonNode: probe.daemonNodeOverride ?? input.daemonNode,
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
relayStatus: probe.relayStatus ?? input.relayStatus,
note: probe.note ? appendNote(input.note, probe.note) : input.note,
};
}
function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } {
try {
return { serverId: getOrCreateServerId(home), error: null };
} catch (error) {
return {
serverId: null,
error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`,
};
}
}
async function resolveDaemonNodeLabel(
state: ReturnType<typeof resolveLocalDaemonState>,
): Promise<string> {
if (!state.running) return "-";
if (!state.pidInfo?.pid) return "unknown (no PID available)";
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
}
interface RelayStatusConfig {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
function relayConfigFromLocalState(
state: ReturnType<typeof resolveLocalDaemonState>,
): RelayStatusConfig {
return {
enabled: state.relayEnabled,
endpoint: state.relayEndpoint,
publicEndpoint: state.relayEndpoint,
useTls: state.relayUseTls,
publicUseTls: state.relayPublicUseTls,
};
}
export function selectRelayStatus(input: {
persisted: RelayStatusConfig;
live?: RelayStatusConfig;
}): string {
const relay = input.live ?? input.persisted;
if (!relay.enabled) return "disabled";
const scheme = relay.publicUseTls ? "wss" : "ws";
return `${scheme}://${relay.publicEndpoint}`;
}
export type StatusResult = ListResult<StatusRow>;
export async function runStatusCommand(
options: CommandOptions,
_command: Command,
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const daemonTarget = state.listen.trim();
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode = await resolveDaemonNodeLabel(state);
const cliNode = process.execPath;
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined;
let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) });
export async function runStatusCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget;
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
const local =
target.kind === "instance"
? localStatus(target.home, instance)
: { host: describeDaemonTarget(target) };
let connectedDaemon = "not_probed";
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
localDaemon = "stale_pid";
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
let live: Record<string, unknown> = {};
if (target.kind === "endpoint" || instance?.listen) {
try {
const client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: 1_500,
});
try {
const status = await client.getDaemonStatus({ timeout: 1_500 });
const current = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
if (instance && (!current || !isSameDaemonInstance(instance, current)))
throw new DaemonInstanceError(
"DAEMON_REPLACED",
"Supervisor exited or was replaced during status observation.",
);
live = {
serverId: client.getLastServerInfoMessage()?.serverId ?? null,
daemonVersion: status.version,
workerPid: status.pid,
daemonNode: status.nodePath,
providers: status.providers,
relay: status.relay,
};
connectedDaemon = "reachable";
} finally {
await client.close();
}
} catch (error) {
const failure = buildDaemonConnectionCommandError({ target, error });
if (target.kind === "endpoint") throw failure;
connectedDaemon = "unreachable";
if (failure.code === "AUTH_REQUIRED") connectedDaemon = "auth_required";
if (failure.code === "AUTH_FAILED") connectedDaemon = "auth_failed";
note = failure.message;
}
}
if (daemonTarget) {
const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state });
({
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
} = applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
}));
}
const cliVersion = resolveCliVersion();
const serverIdResult = resolveServerIdSafely(state.home);
const serverId = serverIdResult.serverId;
if (serverIdResult.error) {
note = appendNote(note, serverIdResult.error);
}
const providers = daemonProviders ?? (await checkProviderBinaries());
const daemonStatus: DaemonStatus = {
serverId,
localDaemon,
connectedDaemon,
home: state.home,
listen: state.listen,
relay: relayStatus,
hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null,
owner,
logPath: state.logPath,
daemonNode,
cliNode,
cliVersion,
daemonVersion,
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,
};
const data: Record<string, unknown> = { ...local, ...live, connectedDaemon, note };
return {
type: "list",
data: toStatusRows(daemonStatus),
schema: createStatusSchema(daemonStatus),
type: "single" as const,
data,
schema: {
idField: () => "daemon",
columns: [],
renderHuman: () =>
Object.entries(data)
.filter(([, value]) => value !== undefined)
.map(
([key, value]) =>
`${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`,
)
.join("\n"),
},
};
}
function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemonInstance>>) {
const config = resolveConfigFromPersisted(
home,
readPersistedConfig(home, { defaultsIfMissing: true }),
{ env: {} },
);
let localDaemon = "stopped";
if (instance) localDaemon = instance.listen ? "running" : "not_ready";
return {
home,
pid: instance?.pid ?? null,
startedAt: instance?.startedAt ?? null,
listen: instance?.listen ?? null,
hostname: instance?.hostname ?? null,
configuredListen: config.listen,
localDaemon,
desktopManaged: instance?.desktopManaged === true,
logPath: daemonLogPath(home),
};
}
+62 -89
View File
@@ -1,94 +1,67 @@
import type { Command } from "commander";
import {
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
DEFAULT_KILL_TIMEOUT_MS,
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { Command } from "commander";
import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js";
import { parseTimeoutMs } from "./local-daemon.js";
interface StopResult {
action: "stopped" | "not_running";
home: string;
pid: string;
forced: boolean;
usedLifecycleRpc: boolean;
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
message: string;
export function daemonStopCommand(): Command {
return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon"))
.option("--timeout <seconds>", "Graceful exit deadline (default: 15)")
.option("--force", "Permit forced local process cleanup")
.option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)")
.action(withOutput(runStopCommand));
}
const stopResultSchema: OutputSchema<StopResult> = {
idField: "action",
columns: [
{
header: "STATUS",
field: "action",
color: (value) => (value === "stopped" ? "green" : "yellow"),
export async function runStopCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget;
const timeoutMs = parseTimeoutMs(options.timeout, 15_000);
const deadline = Date.now() + timeoutMs;
const remaining = () => Math.max(1, deadline - Date.now());
const requestShutdown = async (instance?: DaemonInstance) => {
const client = await connectToDaemon({ target, timeout: remaining(), instance });
try {
await client.shutdownServer({ timeout: remaining() });
} finally {
await client.close();
}
};
if (target.kind === "endpoint" && options.force)
throw {
code: "INVALID_OPTIONS",
message: "--force requires a local --home; an endpoint gives no remote process authority.",
};
const result: {
action: string;
home?: string;
host?: string;
pid?: number | null;
forced?: boolean;
usedLifecycleRpc?: boolean;
} =
target.kind === "instance"
? {
...(await stopDaemonInstance(target.home, {
force: options.force === true,
timeoutMs,
killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000),
requestShutdown,
})),
home: target.home,
}
: (await requestShutdown(),
{ action: "shutdown_requested", host: describeDaemonTarget(target) });
return {
type: "single" as const,
data: result,
schema: {
idField: "action" as const,
columns: [],
renderHuman: () =>
target.kind === "endpoint"
? "Shutdown requested; remote process exit was not verified."
: `${result.action.replaceAll("_", " ")}: ${target.home}`,
},
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
};
export type StopCommandResult = SingleResult<StopResult>;
function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number {
if (typeof raw !== "string" || raw.trim().length === 0) {
return fallbackMs;
}
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: `Invalid ${label} value: ${raw}`,
details: `${label} must be a positive number of seconds`,
};
throw error;
}
return Math.ceil(seconds * 1000);
}
export async function runStopCommand(
options: CommandOptions,
_command: Command,
): Promise<StopCommandResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const force = options.force === true;
const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout");
const killTimeoutMs = parseSecondsOption(
options.killTimeout,
DEFAULT_KILL_TIMEOUT_MS,
"kill-timeout",
);
try {
const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs });
return {
type: "single",
data: {
action: result.action,
home: result.home,
pid: result.pid === null ? "-" : String(result.pid),
forced: result.forced,
usedLifecycleRpc: result.usedLifecycleRpc,
reason: result.reason,
message: result.message,
},
schema: stopResultSchema,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "STOP_FAILED",
message: `Failed to stop local daemon: ${message}`,
};
throw error;
}
};
}
+3 -3
View File
@@ -64,7 +64,7 @@ async function runCreateHeartbeat(
if (!cron) {
throw new Error("--cron is required");
}
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
@@ -105,7 +105,7 @@ async function runUpdateHeartbeat(
if (!cron) {
throw new Error("--cron is required");
}
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireOwnedHeartbeat(client, id, agentId);
const payload = await client.scheduleUpdate({
@@ -133,7 +133,7 @@ async function runDeleteHeartbeat(
_command: Command,
): Promise<SingleResult<HeartbeatDeleteRow>> {
const agentId = requireCallerAgentId();
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireOwnedHeartbeat(client, id, agentId);
const payload = await client.scheduleDelete({ id });
+2 -1
View File
@@ -13,6 +13,7 @@ import { addHubResolutionHelp } from "./help.js";
interface HubConnectOptions {
apiKey?: string;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
permission?: readonly string[];
permissions?: readonly string[];
@@ -41,7 +42,7 @@ export async function runHubConnect(
const credential = resolveHubCredential({ ...resolution, origin });
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
const permissions = options.permissions ?? options.permission ?? [];
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => {
const response = await daemon.connectHub(origin, token, permissions);
if (
response.status.hubOrigin !== null &&
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon } from "../../utils/client.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
@@ -33,19 +34,19 @@ export interface HubDaemonClient {
}
export interface HubDaemonConnection {
connect(host: string | undefined): Promise<HubDaemonClient>;
connect(target: DaemonTarget): Promise<HubDaemonClient>;
}
export const productionHubDaemonConnection: HubDaemonConnection = {
connect: (host) => connectToDaemon({ host }),
connect: (target) => connectToDaemon({ target }),
};
export async function withHubDaemon<T>(
connection: HubDaemonConnection,
host: string | undefined,
target: DaemonTarget,
action: (client: HubDaemonClient) => Promise<T>,
): Promise<T> {
const client = await connection.connect(host);
const client = await connection.connect(target);
try {
return await action(client);
} finally {
+2 -1
View File
@@ -9,6 +9,7 @@ import { hubStatusResult } from "./status-output.js";
interface HubDisconnectOptions {
force?: boolean;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
}
@@ -21,7 +22,7 @@ export function runHubDisconnect(
options: HubDisconnectOptions,
dependencies: HubDisconnectDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
const current = (await client.getHubStatus()).status;
if (current.hubOrigin !== null) {
reportHubProgress(
+7 -3
View File
@@ -59,7 +59,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
flow: environment.login,
reporter: environment.reporter,
isInteractive: environment.isInteractive,
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
continueGuidedSetup: (origin, daemonTarget) =>
continueHubGuidedSetup(origin, { ...environment, daemonTarget }),
});
addHubInitCommand(hub, environment);
addHubConnectCommand(hub, {
@@ -71,8 +72,11 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
});
addJsonAndDaemonHostOptions(hub.command("status")).action(
withOutput(async (...args) => {
const options = args.at(-2) as { host?: string };
return withHubDaemon(environment.daemon, options.host, async (client) =>
const options = args.at(-2) as {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
};
return withHubDaemon(environment.daemon, options.daemonTarget, async (client) =>
hubStatusResult((await client.getHubStatus()).status),
);
}),
+36 -23
View File
@@ -1,3 +1,6 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { withGlobalOptions } from "../../utils/command-options.js";
import type { CommandOptions } from "../../output/index.js";
import {
cancel,
confirm,
@@ -49,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250;
const PROVIDER_READY_TIMEOUT_MS = 60_000;
export interface HubGuidedSetupEnvironment {
daemonTarget: DaemonTarget;
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
@@ -80,22 +84,27 @@ function initErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void {
export function addHubInitCommand(
parent: Command,
environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">,
): void {
parent
.command("init")
.description("Create and optionally deploy a safe starter Hub trigger")
.action(async () => {
try {
await runHubInit(environment);
} catch (error) {
if (error instanceof HubInitCancelledError) {
cancel(error.message);
return;
.action(
withGlobalOptions(async (options: CommandOptions) => {
try {
await runHubInit({ ...environment, daemonTarget: options.daemonTarget });
} catch (error) {
if (error instanceof HubInitCancelledError) {
cancel(error.message);
return;
}
cancel(initErrorMessage(error));
process.exitCode = 1;
}
cancel(initErrorMessage(error));
process.exitCode = 1;
}
});
}),
);
}
export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> {
@@ -175,8 +184,10 @@ export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
const currentStatus = await withHubDaemon(
environment.daemon,
environment.daemonTarget,
async (daemon) => daemon.getHubStatus().then((response) => response.status),
);
const current = resolveHubInitConnection(currentStatus, origin);
if (current.kind === "connected") {
@@ -187,7 +198,7 @@ export async function continueHubGuidedSetup(
}.`,
);
} else if (current.kind === "pending") {
await waitForDaemonReady(origin, environment.daemon);
await waitForDaemonReady(origin, environment);
} else if (current.kind === "conflict") {
reportMessage(
environment,
@@ -273,7 +284,7 @@ async function ensureDaemonConnection(
confirmed = false,
permissions: readonly string[] = ["hub.execute"],
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
const status = await withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const connection = resolveHubInitConnection(status, origin);
@@ -287,7 +298,7 @@ async function ensureDaemonConnection(
return connection.daemonId;
}
if (connection.kind === "pending") {
return waitForDaemonReady(origin, environment.daemon);
return waitForDaemonReady(origin, environment);
}
if (connection.kind === "conflict") {
throw new HubCommandError(
@@ -315,7 +326,7 @@ async function connectDaemon(
): Promise<string> {
await runHubConnect(
origin,
{ permissions },
{ permissions, daemonTarget: environment.daemonTarget },
{
env: environment.env,
credentials: environment.credentials,
@@ -324,15 +335,15 @@ async function connectDaemon(
reporter: environment.reporter,
},
);
return waitForDaemonReady(origin, environment.daemon);
return waitForDaemonReady(origin, environment);
}
async function waitForDaemonReady(
origin: string,
connection: HubDaemonConnection,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
return withSpinner("Waiting for the daemon to connect", async (reporter) =>
withHubDaemon(connection, undefined, async (daemon) => {
withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => {
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
while (true) {
const status = (await daemon.getHubStatus()).status;
@@ -436,7 +447,7 @@ async function waitForStarterAgentProviders(
cwd: string,
): Promise<readonly HubStarterAgentProvider[]> {
return withSpinner("Discovering agent runtimes", async (reporter) =>
withHubDaemon(environment.daemon, undefined, async (daemon) => {
withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => {
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
while (true) {
const snapshot = await daemon.getProvidersSnapshot({ cwd });
@@ -750,7 +761,9 @@ async function requiredSelect<T extends string>(
return answer;
}
function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
function requireInteractiveTerminal(
environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">,
): void {
if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
}
+6 -4
View File
@@ -1,3 +1,5 @@
import type { CommandOptions } from "../../output/index.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
@@ -26,12 +28,12 @@ interface HubLoginDependencies {
flow: Pick<CliLoginFlow, "authorize">;
reporter: HubReporter;
isInteractive?(): boolean;
continueGuidedSetup?(origin: string): Promise<void>;
continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise<void>;
}
export async function runHubLogin(
originInput: string | undefined,
options: { json?: boolean },
options: Pick<CommandOptions, "json" | "daemonTarget">,
dependencies: HubLoginDependencies,
): Promise<SingleResult<HubLoginResult>> {
const origin = resolveHubOrigin({
@@ -48,7 +50,7 @@ export async function runHubLogin(
dependencies.isInteractive?.() &&
dependencies.continueGuidedSetup !== undefined
) {
await dependencies.continueGuidedSetup(origin);
await dependencies.continueGuidedSetup(origin, options.daemonTarget);
}
return { type: "single", data: { origin, status: "logged_in" }, schema };
}
@@ -64,7 +66,7 @@ export function addHubLoginCommand(parent: Command, dependencies: HubLoginDepend
).action(
withOutput(async (...args) => {
const origin = args[0] as string | undefined;
const options = args.at(-2) as { json?: boolean };
const options = args.at(-2) as CommandOptions;
return runHubLogin(origin, options, dependencies);
}),
);
+9 -4
View File
@@ -28,6 +28,7 @@ interface HubLogoutOptions {
disconnectDaemon?: boolean;
force?: boolean;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
}
@@ -53,7 +54,7 @@ export async function runHubLogout(
let daemonStatus: HubStatus | null = null;
const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt;
if (shouldInspectDaemon) {
daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) =>
daemonStatus = await withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
}
@@ -72,9 +73,13 @@ export async function runHubLogout(
options,
`Disconnecting this daemon from ${active.origin}`,
);
const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
return daemon.disconnectHub(options.force ?? false);
});
const disconnect = await withHubDaemon(
dependencies.daemon,
options.daemonTarget,
async (daemon) => {
return daemon.disconnectHub(options.force ?? false);
},
);
dependencies.credentials.logoutActive();
return logoutResult({
origin: active.origin,
+3 -2
View File
@@ -8,6 +8,7 @@ import { hubStatusResult } from "./status-output.js";
interface HubPermissionsOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
json?: boolean;
}
@@ -33,7 +34,7 @@ export function runHubPermissionsList(
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
): Promise<ListResult<PermissionRow>> {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
const status = (await client.getHubStatus()).status;
requireConnectedHub(status);
return {
@@ -53,7 +54,7 @@ export function runHubPermissionChange(
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
const current = (await client.getHubStatus()).status;
requireConnectedHub(current);
const response = await client.updateHubPermissions(
+73 -259
View File
@@ -1,17 +1,18 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
import { addLocalDaemonOptions } from "../utils/command-options.js";
import { cancel, confirm, intro, isCancel, log, note, outro } 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";
readPersistedConfig as loadPersistedConfig,
savePersistedConfig,
readDaemonInstance,
waitForDaemonReady,
type PersistedConfig,
} from "@getpaseo/server";
import { withGlobalOptions } from "../utils/command-options.js";
import type { CommandOptions } from "../output/index.js";
import { launchLocalDaemon, parseTimeoutMs } from "./daemon/local-daemon.js";
import { connectToDaemon } from "../utils/client.js";
import { formatPairingInstructions } from "../output/pairing.js";
import {
confirmRelayPairing,
@@ -19,7 +20,12 @@ import {
resolveLocalPairingOffer,
} from "./daemon/pair.js";
interface OnboardOptions extends DaemonStartOptions {
interface OnboardOptions extends CommandOptions {
port?: string;
listen?: string;
relay?: boolean;
mcp?: boolean;
hostnames?: string;
timeout?: string;
voice?: "ask" | "enable" | "disable";
}
@@ -39,41 +45,12 @@ type OnboardPersistedConfig = PersistedConfig & {
};
};
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,
@@ -135,134 +112,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boo
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 = [
@@ -272,13 +121,13 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
"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"',
`5. Example: paseo run --home ${JSON.stringify(paseoHome)} --output-schema schema.json "extract fields"`,
];
const quickReferenceLines = [
"1. paseo --help",
"2. paseo ls",
'3. paseo run "your prompt"',
"4. paseo status",
`2. paseo ls --home ${JSON.stringify(paseoHome)}`,
`3. paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`,
`4. paseo status --home ${JSON.stringify(paseoHome)}`,
`5. Daemon logs: ${daemonLogPath}`,
];
@@ -301,11 +150,10 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
}
export function onboardCommand(): Command {
return new Command("onboard")
return addLocalDaemonOptions(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")
@@ -316,12 +164,15 @@ export function onboardCommand(): Command {
.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,
});
});
.action(
withGlobalOptions(async (options: RawOnboardOptions, command: Command) => {
await runOnboard({
...options,
mcp: command.getOptionValueSource("mcp") === "cli" ? options.mcp : undefined,
hostnames: options.hostnames ?? options.allowedHosts,
});
}),
);
}
async function resolveAndPersistVoice(
@@ -354,70 +205,30 @@ async function resolveAndPersistVoice(
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);
function persistSetupChoices(paseoHome: string, options: OnboardOptions): void {
const persisted = loadPersistedConfig(paseoHome, { defaultsIfMissing: true });
if (options.listen || options.port) {
persisted.daemon = {
...persisted.daemon,
listen: options.listen ?? `127.0.0.1:${options.port}`,
};
}
if (options.relay !== undefined)
persisted.daemon = {
...persisted.daemon,
relay: { ...persisted.daemon?.relay, enabled: options.relay },
};
if (options.mcp !== undefined)
persisted.daemon = {
...persisted.daemon,
mcp: { ...persisted.daemon?.mcp, enabled: options.mcp },
};
if (options.hostnames)
persisted.daemon = {
...persisted.daemon,
hostnames: options.hostnames === "true" ? true : options.hostnames.split(","),
};
savePersistedConfig(paseoHome, persisted);
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
@@ -431,16 +242,12 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
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 timeoutMs = parseTimeoutMs(options.timeout);
const paseoHome = resolveLocalPaseoHome(options.home);
if (options.daemonTarget.kind !== "instance") throw new Error("Onboarding requires a local home");
const paseoHome = options.daemonTarget.home;
const alreadyRunning = await readDaemonInstance(paseoHome);
persistSetupChoices(paseoHome, options);
if (richUi) {
renderNote(paseoHome, "Paseo home");
}
@@ -452,12 +259,19 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
: "Voice features disabled. Local speech models will not be downloaded.",
);
await ensureDaemonStarted(options, richUi);
await waitForDaemonReadyWithUi({
home: options.home ?? paseoHome,
timeoutMs,
richUi,
});
if (alreadyRunning) {
log.message(`Daemon already running (PID ${alreadyRunning.pid}); retaining its supervisor.`);
const client = await connectToDaemon({ target: options.daemonTarget, timeout: timeoutMs });
try {
log.message(JSON.stringify(await client.reloadDaemonConfig()));
} finally {
await client.close();
}
} else {
await launchLocalDaemon({ home: paseoHome, timeoutMs });
}
const ready = await waitForDaemonReady(paseoHome, { timeoutMs });
log.message(`Daemon ready on ${ready.listen}`);
if (options.relay === false) {
log.message("Relay pairing skipped because --no-relay was provided.");
+1
View File
@@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise<void>
try {
launchDesktop([projectPath]);
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
+2 -15
View File
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission response item for display */
@@ -46,8 +46,6 @@ export async function runAllowCommand(
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
const host = getDaemonHost({ host: options.host });
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
@@ -65,18 +63,7 @@ export async function runAllowCommand(
}
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
+2 -15
View File
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
@@ -19,8 +19,6 @@ export async function runDenyCommand(
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
@@ -31,18 +29,7 @@ export async function runDenyCommand(
throw error;
}
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
+2 -15
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission list item for display */
@@ -48,20 +48,7 @@ export async function runLsCommand(
options: PermitLsOptions,
_command: Command,
): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+11 -7
View File
@@ -90,7 +90,9 @@ export async function runPluginListCommand(
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginListItem>> {
const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins());
const plugins = await withPluginManagementClient(options.daemonTarget, (client) =>
client.listPlugins(),
);
const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins;
if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`);
return { type: "list", data, schema: pluginSchema };
@@ -101,7 +103,9 @@ export async function runPluginLogsCommand(
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginLogEntry>> {
const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId));
const data = await withPluginLogsClient(options.daemonTarget, (client) =>
client.getPluginLogs(pluginId),
);
return { type: "list", data, schema: pluginLogsSchema };
}
@@ -126,10 +130,10 @@ async function install(
isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path;
const sourceReference = formatPluginSourceReference(source, options.path);
const data = canUseLegacyDirectoryInstall
? await withPluginManagementClient(options.host, (client) =>
? await withPluginManagementClient(options.daemonTarget, (client) =>
client.installDirectoryPlugin(source, options.id),
)
: await withPluginSourceClient(options.host, (client) =>
: await withPluginSourceClient(options.daemonTarget, (client) =>
client.installPluginSource({
source: sourceReference,
...(options.id ? { id: options.id } : {}),
@@ -147,7 +151,7 @@ async function update(
if ((pluginId === undefined) === (options.all !== true)) {
throw new Error("Choose one plugin ID or pass --all");
}
const data = await withPluginSourceClient(options.host, (client) =>
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
client.updatePluginSources(pluginId),
);
return { type: "list", data, schema: pluginUpdateSchema };
@@ -158,7 +162,7 @@ async function act(
pluginId: string,
options: PluginOptions,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, (client) =>
const data = await withPluginManagementClient(options.daemonTarget, (client) =>
client[`${action}Plugin`](pluginId),
);
return { type: "single", data, schema: pluginSchema };
@@ -169,7 +173,7 @@ async function remove(
options: PluginOptions,
_command: Command,
): Promise<SingleResult<PluginListItem>> {
const data = await withPluginManagementClient(options.host, async (client) => {
const data = await withPluginManagementClient(options.daemonTarget, async (client) => {
const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId);
if (!current) throw new Error(`Plugin is not configured: ${pluginId}`);
await client.removePlugin(pluginId);
+9 -8
View File
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
@@ -5,12 +6,12 @@ import { connectToDaemon } from "../../utils/client.js";
type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement";
async function withPluginClient<T>(
host: string | undefined,
target: DaemonTarget,
feature: PluginFeature,
updateMessage: string,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
const client = await connectToDaemon({ host });
const client = await connectToDaemon({ target });
if (client.getLastServerInfoMessage()?.features?.[feature] !== true) {
await client.close().catch(() => undefined);
throw {
@@ -26,12 +27,12 @@ async function withPluginClient<T>(
}
export async function withPluginManagementClient<T>(
host: string | undefined,
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14.
return withPluginClient(
host,
target,
"pluginManagement",
"Update the host to use plugin management.",
run,
@@ -39,20 +40,20 @@ export async function withPluginManagementClient<T>(
}
export async function withPluginLogsClient<T>(
host: string | undefined,
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run);
return withPluginClient(target, "pluginLogs", "Update the host to view plugin logs.", run);
}
export async function withPluginSourceClient<T>(
host: string | undefined,
target: DaemonTarget,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
return withPluginClient(
host,
target,
"pluginGitManagement",
"Update the host to install and update Git plugins.",
run,
+4 -3
View File
@@ -31,10 +31,10 @@ export async function runCreateCommand(
const projectPath = resolveProjectPath({
pathArg,
cwd: process.cwd(),
daemonTarget: options.host ?? process.env.PASEO_HOST,
daemonTarget: options.daemonTarget.kind === "endpoint" ? options.daemonTarget.host : undefined,
});
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -44,6 +44,7 @@ export async function runCreateCommand(
}
return { type: "single", data: toProjectRow(payload.project), schema: projectSchema };
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError;
} finally {
+3 -2
View File
@@ -24,8 +24,8 @@ export async function runDeleteCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<ProjectDeleteResult>> {
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -36,6 +36,7 @@ export async function runDeleteCommand(
schema: projectDeleteSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError;
} finally {
+2 -2
View File
@@ -7,8 +7,8 @@ export async function runLsCommand(
options: CommandOptions,
_command: Command,
): Promise<ListResult<ProjectRow>> {
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -47,7 +47,11 @@ describe("project commands", () => {
});
it("creates a project from an absolute directory path", async () => {
const result = await runCreateCommand("relative/project", {}, {} as never);
const result = await runCreateCommand(
"relative/project",
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project"));
expect(result.data).toEqual({
@@ -77,7 +81,10 @@ describe("project commands", () => {
});
it("lists projects", async () => {
const result = await runLsCommand({}, {} as never);
const result = await runLsCommand(
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(listProjects).toHaveBeenCalled();
expect(result.data).toHaveLength(1);
@@ -88,7 +95,12 @@ describe("project commands", () => {
expect(resolveProjectName({ name: " New name " })).toBe("New name");
expect(resolveProjectName({ reset: true })).toBeNull();
const result = await runRenameCommand("project-1", " New name ", {}, {} as never);
const result = await runRenameCommand(
"project-1",
" New name ",
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(renameProject).toHaveBeenCalledWith("project-1", "New name");
expect(result.data).toEqual({ projectId: "project-1", name: "New name" });
});
@@ -98,7 +110,11 @@ describe("project commands", () => {
});
it("deletes a project", async () => {
const result = await runDeleteCommand("project-1", {}, {} as never);
const result = await runDeleteCommand(
"project-1",
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
{} as never,
);
expect(removeProject).toHaveBeenCalledWith("project-1");
expect(result.data).toEqual({
+3 -2
View File
@@ -44,8 +44,8 @@ export async function runRenameCommand(
_command: Command,
): Promise<SingleResult<ProjectRenameResult>> {
const name = resolveProjectName({ name: nameArg, reset: options.reset });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
});
try {
@@ -56,6 +56,7 @@ export async function runRenameCommand(
schema: projectRenameSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError;
} finally {
@@ -28,7 +28,7 @@ export async function runDiagnosticCommand(
options: ProviderDiagnosticOptions,
_command: Command,
): Promise<SingleResult<ProviderDiagnosticResult>> {
const client = await connectToDaemon({ host: options.host });
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const result = await client.getProviderDiagnostic(provider.trim().toLowerCase());
return {
+2 -31
View File
@@ -1,8 +1,7 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
import { tryConnectToDaemon } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
export interface ProviderListItem {
provider: ProviderSnapshotEntry["provider"];
@@ -13,20 +12,6 @@ export interface ProviderListItem {
modes: string;
}
/** Derive provider list from the manifest — single source of truth */
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
function getStaticProviders(): ProviderListItem[] {
return PROVIDERS;
}
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: "provider",
@@ -59,15 +44,7 @@ export async function runLsCommand(
options: ProviderLsOptions,
_command: Command,
): Promise<ProviderLsResult> {
const client = await tryConnectToDaemon({ host: options.host });
if (!client) {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const snapshot = await client.getProvidersSnapshot();
@@ -83,12 +60,6 @@ export async function runLsCommand(
})),
schema: providerLsSchema,
};
} catch {
return {
type: "list",
data: getStaticProviders(),
schema: providerLsSchema,
};
} finally {
await client.close().catch(() => {});
}
+1 -1
View File
@@ -62,7 +62,7 @@ export async function runModelsCommand(
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase();
const client = await connectToDaemon({ host: options.host });
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const result = await client.listProviderModels(normalizedProvider);
+2 -2
View File
@@ -43,12 +43,12 @@ export async function runCreateCommand(
mode: options.mode,
thinking: options.thinking,
cwd: options.cwd,
host: options.host,
daemonTarget: options.daemonTarget,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
runNow,
});
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleCreate(input);
if (payload.error || !payload.schedule) {
+1 -1
View File
@@ -25,7 +25,7 @@ export async function runDeleteCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleDeleteRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleDelete({ id });
if (payload.error) {
@@ -16,7 +16,7 @@ export async function runInspectCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleInspectRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runLogsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleLogRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleLogs({ id });
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runLsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
const payload = await client.scheduleList();
if (payload.error) {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function runPauseCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.schedulePause({ id });
+1 -1
View File
@@ -15,7 +15,7 @@ export async function runResumeCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleResume({ id });
@@ -15,7 +15,7 @@ export async function runRunOnceCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleRunOnce({ id });
+8 -5
View File
@@ -1,4 +1,5 @@
import { connectToDaemon, getDaemonHost, getExplicitDaemonHost } from "../../utils/client.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type {
CreateScheduleInput,
@@ -19,15 +20,16 @@ export interface ScheduleCommandOptions extends CommandOptions {
}
export async function connectScheduleClient(
host: string | undefined,
target: DaemonTarget,
): Promise<{ client: ScheduleDaemonClient; host: string }> {
const resolvedHost = getDaemonHost({ host });
const resolvedHost = getDaemonHost({ target });
try {
const client = (await connectToDaemon({
host,
target,
})) as unknown as ScheduleDaemonClient;
return { client, host: resolvedHost };
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
@@ -147,6 +149,7 @@ export function parseScheduleCreateInput(options: {
thinking?: string;
cwd?: string;
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
maxRuns?: string;
expiresIn?: string;
runNow?: boolean;
@@ -168,7 +171,7 @@ export function parseScheduleCreateInput(options: {
}
const cwdInput = options.cwd?.trim();
if (getExplicitDaemonHost(options.host) !== undefined && !cwdInput) {
if (options.daemonTarget.kind === "endpoint" && !cwdInput) {
throw {
code: "MISSING_CWD",
message:
+1 -1
View File
@@ -50,7 +50,7 @@ export async function runUpdateCommand(
clearMaxRuns: options.noMaxRuns,
clearExpires: options.noExpiresIn,
});
const { client } = await connectScheduleClient(options.host);
const { client } = await connectScheduleClient(options.daemonTarget);
try {
await requireNewAgentSchedule(client, id);
const payload = await client.scheduleUpdate(input);
+1 -1
View File
@@ -12,7 +12,7 @@ export async function runLsCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<ListResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
const client = await connectWorkspaceScriptClient(options.daemonTarget);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.listWorkspaceScripts(workspaceId);
+4 -3
View File
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { resolve } from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
@@ -9,10 +10,10 @@ export interface WorkspaceScriptCommandOptions extends CommandOptions {
workspace?: string;
}
export async function connectWorkspaceScriptClient(host?: string): Promise<DaemonClient> {
const daemonHost = getDaemonHost({ host });
export async function connectWorkspaceScriptClient(target: DaemonTarget): Promise<DaemonClient> {
const daemonHost = getDaemonHost({ target });
try {
const client = await connectToDaemon({ host });
const client = await connectToDaemon({ target });
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) {
await client.close().catch(() => {});
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runStartCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<SingleResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
const client = await connectWorkspaceScriptClient(options.daemonTarget);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName);
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runStopCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<SingleResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
const client = await connectWorkspaceScriptClient(options.daemonTarget);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.stopWorkspaceScript(workspaceId, scriptName);
@@ -16,11 +16,9 @@ export interface TerminalCaptureOptions extends TerminalCommandOptions {
export async function runCaptureCommand(
terminalId: string,
_options: TerminalCaptureOptions,
command: Command,
options: TerminalCaptureOptions,
_command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalCaptureOptions;
try {
const payload = await executeCaptureCommand(terminalId, options);
if (options.json) {
@@ -55,7 +53,7 @@ async function executeCaptureCommand(
terminalId: string,
options: TerminalCaptureOptions,
): Promise<{ terminalId: string; lines: string[]; totalLines: number }> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
+1 -1
View File
@@ -17,7 +17,7 @@ export async function runCreateCommand(
options: TerminalCreateOptions,
_command: Command,
): Promise<SingleResult<TerminalRow>> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd());
const workspaceId =
+1 -1
View File
@@ -13,7 +13,7 @@ export async function runKillCommand(
options: TerminalCommandOptions,
_command: Command,
): Promise<SingleResult<TerminalKillRow>> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const resolvedId = await requireTerminalId(client, terminalId);
+1 -1
View File
@@ -17,7 +17,7 @@ export async function runLsCommand(
options: TerminalLsOptions,
_command: Command,
): Promise<ListResult<TerminalRow>> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd());
try {
@@ -14,11 +14,9 @@ export interface TerminalSendKeysOptions extends TerminalCommandOptions {
export async function runSendKeysCommand(
terminalId: string,
keys: string[],
_options: TerminalSendKeysOptions,
command: Command,
options: TerminalSendKeysOptions,
_command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalSendKeysOptions;
try {
const payload = await executeSendKeysCommand(terminalId, keys, options);
if (options.json) {
@@ -39,7 +37,7 @@ async function executeSendKeysCommand(
keys: string[],
options: TerminalSendKeysOptions,
): Promise<{ terminalId: string; keysSent: number }> {
const { client, close } = await connectTerminalClient(options.host);
const { client, close } = await connectTerminalClient(options.daemonTarget);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
+5 -3
View File
@@ -1,3 +1,4 @@
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { createPaseoApi } from "@getpaseo/client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
@@ -11,12 +12,13 @@ interface TerminalLike {
name?: string | null;
}
export async function connectTerminalClient(host?: string) {
const daemonHost = getDaemonHost({ host });
export async function connectTerminalClient(target: DaemonTarget) {
const daemonHost = getDaemonHost({ target });
try {
const client = await connectToDaemon({ host });
const client = await connectToDaemon({ target });
return { client: createPaseoApi(client), daemonHost, close: () => client.close() };
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
@@ -19,11 +19,11 @@ const workspaceArchiveSchema: OutputSchema<WorkspaceArchiveResult> = {
export async function runArchiveCommand(
workspaceId: string,
options: { host?: string },
options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget },
_command: Command,
): Promise<SingleResult<WorkspaceArchiveResult>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const host = getDaemonHost({ target: options.daemonTarget });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
@@ -44,6 +44,7 @@ export async function runArchiveCommand(
schema: workspaceArchiveSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError;
} finally {
@@ -134,8 +134,8 @@ export async function runCreateCommand(
options: WorkspaceCreateOptions,
_command: Command,
): Promise<SingleResult<WorkspaceRow>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const host = getDaemonHost({ target: options.daemonTarget });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
@@ -153,6 +153,7 @@ export async function runCreateCommand(
}
return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema };
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError;
} finally {
+3 -3
View File
@@ -4,11 +4,11 @@ import type { CommandError, ListResult } from "../../output/index.js";
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
export async function runLsCommand(
options: { host?: string },
options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget },
_command: Command,
): Promise<ListResult<WorkspaceRow>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const host = getDaemonHost({ target: options.daemonTarget });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
@@ -18,6 +18,7 @@ const workspaceRenameSchema: OutputSchema<WorkspaceRenameResult> = {
export interface WorkspaceRenameOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
reset?: boolean;
}
@@ -55,8 +56,8 @@ export async function runRenameCommand(
): Promise<SingleResult<WorkspaceRenameResult>> {
const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset });
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const host = getDaemonHost({ target: options.daemonTarget });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
@@ -72,6 +73,7 @@ export async function runRenameCommand(
schema: workspaceRenameSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError;
} finally {
+3 -3
View File
@@ -17,11 +17,11 @@ const workspaceSetupSchema: OutputSchema<WorkspaceSetupResult> = {
export async function runSetupCommand(
workspaceId: string,
options: { host?: string },
options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget },
_command: Command,
): Promise<SingleResult<WorkspaceSetupResult>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
const host = getDaemonHost({ target: options.daemonTarget });
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`,
@@ -48,7 +48,7 @@ export async function runArchiveCommandWithDeps(
options: WorktreeArchiveOptions,
deps: { connectToDaemon: typeof connectToDaemon },
): Promise<WorktreeArchiveCommandResult> {
const host = getDaemonHost({ host: options.host });
const host = getDaemonHost({ target: options.daemonTarget });
// Validate arguments
if (!nameArg || nameArg.trim().length === 0) {
@@ -62,8 +62,9 @@ export async function runArchiveCommandWithDeps(
let client: DaemonClient;
try {
client = await deps.connectToDaemon({ host: options.host });
client = await deps.connectToDaemon({ target: options.daemonTarget });
} catch (err) {
if (err && typeof err === "object" && "code" in err) throw err;
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
+2 -14
View File
@@ -1,7 +1,6 @@
import path from "node:path";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
@@ -31,18 +30,7 @@ export async function runCreateCommand(
const cwd = options.cwd ?? process.cwd();
const request = buildCreateWorktreeRequest(options, cwd);
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw cmdError(
"DAEMON_NOT_RUNNING",
`Cannot connect to daemon at ${host}: ${message}`,
"Start the daemon with: paseo daemon start",
);
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const response = await client.createPaseoWorktree(request);
+4 -34
View File
@@ -1,8 +1,6 @@
import type { Command } from "commander";
import { homedir } from "node:os";
import { basename, join, sep } from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { basename } from "node:path";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Worktree list item for display */
@@ -27,19 +25,6 @@ function extractWorktreeName(path: string): string {
return basename(path);
}
export function resolvePaseoHomePath(): string {
return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
}
export function resolvePaseoWorktreesDir(): string {
return join(resolvePaseoHomePath(), "worktrees");
}
function isAgentInManagedWorktree(agentCwd: string): boolean {
const worktreesDir = resolvePaseoWorktreesDir();
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep);
}
/** Schema for worktree ls output */
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
idField: "name",
@@ -61,20 +46,7 @@ export async function runLsCommand(
options: WorktreeLsOptions,
_command: Command,
): Promise<WorktreeLsResult> {
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
@@ -96,9 +68,7 @@ export async function runLsCommand(
// Build a map of worktree paths to agent IDs
const worktreeAgentMap = new Map<string, string>();
for (const agent of agents) {
if (isAgentInManagedWorktree(agent.cwd)) {
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7));
}
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7));
}
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
+2
View File
@@ -11,6 +11,8 @@ import { withGlobalOptions } from "../utils/command-options.js";
/** Options that include output settings from global options */
export interface CommandOptions extends Partial<OutputOptions> {
daemonTarget: import("../utils/daemon-target.js").DaemonTarget;
home?: string;
/** Daemon host target from --host option */
host?: string;
/** JSON output flag from --json option */
+12 -1
View File
@@ -1,3 +1,4 @@
import { renderError, toCommandError, defaultOutputOptions } from "./output/render.js";
import { createCli } from "./cli.js";
import { classifyInvocation } from "./classify.js";
import { openDesktopWithProject } from "./commands/open.js";
@@ -48,6 +49,16 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
}
const program = createCli();
await program.parseAsync(parseArgv, { from: "node" });
try {
await program.parseAsync(parseArgv, { from: "node" });
} catch (error) {
process.stderr.write(
renderError(toCommandError(error), {
...defaultOutputOptions,
format: argv.includes("--json") ? "json" : "table",
}) + "\n",
);
return 1;
}
return typeof process.exitCode === "number" ? process.exitCode : 0;
}
+8 -19
View File
@@ -1,14 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { ensurePrivateDirectory } from "@getpaseo/server";
import { readFile, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
const CLIENT_SESSION_KEY_FILE = join(
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
"cli-client-id",
);
let cachedClientId: string | null = null;
import { join } from "node:path";
function normalizeClientId(value: string): string | null {
const trimmed = value.trim();
@@ -19,15 +12,12 @@ function generateClientId(): string {
return `cid_${randomUUID().replace(/-/g, "")}`;
}
export async function getOrCreateCliClientId(): Promise<string> {
if (cachedClientId) {
return cachedClientId;
}
export async function getOrCreateCliClientId(home: string): Promise<string> {
const clientSessionKeyFile = join(home, "cli-client-id");
try {
const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8"));
const existing = normalizeClientId(await readFile(clientSessionKeyFile, "utf8"));
if (existing) {
cachedClientId = existing;
return existing;
}
} catch (error) {
@@ -38,8 +28,7 @@ export async function getOrCreateCliClientId(): Promise<string> {
}
const nextValue = generateClientId();
await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true });
await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 });
cachedClientId = nextValue;
ensurePrivateDirectory(home);
await writeFile(clientSessionKeyFile, nextValue, { mode: 0o600 });
return nextValue;
}
+3 -1
View File
@@ -47,7 +47,9 @@ describe("CLI SSH transport", () => {
});
it("routes an SSH host through a local tunnel", async () => {
await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" });
await connectToDaemon({
target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" },
});
expect(mocks.createSshTunnel).toHaveBeenCalledWith({
host: "deploy@build-box",
+76 -151
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
import { waitForDaemonReady, resolvePaseoHome, type DaemonInstance } from "@getpaseo/server";
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
export type { DaemonTarget } from "./daemon-target.js";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
@@ -13,57 +14,44 @@ import {
} from "@getpaseo/protocol/connection-offer";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import { WebSocket } from "ws";
import { getOrCreateCliClientId } from "./client-id.js";
import { resolveCliVersion } from "../version.js";
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
export interface ConnectOptions {
host?: string;
target: DaemonTarget;
timeout?: number;
instance?: DaemonInstance;
}
export interface DaemonConnectionCommandError {
code: "DAEMON_NOT_RUNNING";
message: string;
details: string;
}
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT = 15000;
const PID_FILENAME = "paseo.pid";
type TransportTarget =
| { type: "tcp"; url: string }
| { type: "ipc"; url: string; socketPath: string };
type DaemonTarget =
| {
type: "tcp";
url: string;
}
| {
type: "ipc";
url: string;
socketPath: string;
};
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
export function getDaemonHost(options: ConnectOptions): string {
return describeDaemonTarget(options.target);
}
export function buildDaemonConnectionCommandError(options: {
host?: string;
error: unknown;
}): DaemonConnectionCommandError {
const host = getDaemonHost({ host: options.host });
const message = options.error instanceof Error ? options.error.message : String(options.error);
export function buildDaemonConnectionCommandError(options: ConnectOptions & { error: unknown }) {
const error = options.error;
let message = error instanceof Error ? error.message : String(error);
if (error && typeof error === "object" && "message" in error) message = String(error.message);
if (options.target.kind === "endpoint")
message = message.replaceAll(options.target.host, describeDaemonTarget(options.target));
if (message.startsWith("Cannot connect to daemon at "))
return error as { code: string; message: string; details: string };
let code = "DAEMON_UNREACHABLE";
if (typeof error === "object" && error !== null && "code" in error) code = String(error.code);
else if (message === "Password required") code = "AUTH_REQUIRED";
else if (message === "Incorrect password") code = "AUTH_FAILED";
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: host.trim().startsWith("ssh://")
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: "Start the daemon with: paseo daemon start",
code,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details:
options.target.kind === "instance"
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
};
}
@@ -118,100 +106,13 @@ export function normalizeDaemonHost(raw: string): string | null {
return trimmed.includes(":") ? trimmed : null;
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host);
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME);
if (!existsSync(pidPath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
listen?: unknown;
sockPath?: unknown;
};
if (typeof parsed.listen === "string") return parsed.listen;
if (typeof parsed.sockPath === "string") return parsed.sockPath;
return null;
} catch {
return null;
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost;
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
if (isIpcDaemonHost(pidHost)) {
return pidHost;
}
const config = loadConfig(paseoHome, { env });
const configuredHost = normalizeDaemonHost(config.listen);
return isIpcDaemonHost(configuredHost) ? configuredHost : null;
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
if (!isTcpDaemonHost(configuredHost)) {
return null;
}
return configuredHost === "127.0.0.1:6767" ? null : configuredHost;
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env);
const candidates: string[] = [];
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
if (configuredIpcHost) {
candidates.push(configuredIpcHost);
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
if (configuredTcpHost) {
candidates.push(configuredTcpHost);
}
candidates.push(DEFAULT_HOST);
return Array.from(new Set(candidates));
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = getExplicitDaemonHost(options?.host);
if (explicitHost) {
return [explicitHost];
}
return resolveDefaultDaemonHosts();
}
export function getExplicitDaemonHost(
host: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const explicitHost = host ?? env.PASEO_HOST;
return explicitHost?.trim() ? explicitHost : undefined;
}
function stripIpcPrefix(trimmed: string): string {
if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim();
if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim();
return trimmed;
}
export function resolveDaemonTarget(host: string): DaemonTarget {
export function resolveDaemonTarget(host: string): TransportTarget {
const trimmed = normalizeDaemonHost(host);
if (!trimmed) {
throw new Error(`Invalid daemon target: ${host}`);
@@ -363,12 +264,21 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null
}
}
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
const clientId = await getOrCreateCliClientId();
async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonClient> {
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const deadline = Date.now() + timeout;
const explicitHost =
options.target.kind === "endpoint"
? options.target.host
: (
await waitForDaemonReady(options.target.home, {
timeoutMs: timeout,
instance: options.instance,
})
).listen;
const clientId = await getOrCreateCliClientId(resolvePaseoHome({}));
const nodeWebSocketFactory = createNodeWebSocketFactory();
const explicitHost = getExplicitDaemonHost(options?.host);
if (explicitHost?.trim().startsWith("ssh://")) {
const target = parseSshTransportUri(explicitHost.trim());
const tunnel = await createSshTunnel(target);
@@ -377,10 +287,20 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
tunnel.endpoint,
password,
clientId,
timeout,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
if ("client" in result) {
const close = result.client.close.bind(result.client);
result.client.close = async () => {
try {
await close();
} finally {
tunnel.close();
}
};
return result.client;
}
const failure = tunnel.failureDetail();
tunnel.close();
@@ -389,32 +309,37 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
}
const offer = parseHostOfferOrNull(explicitHost);
if (offer) {
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
return connectViaRelayOffer(
offer,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
}
const hosts = resolveDaemonHostCandidates(options);
const result = await tryConnectHost(
explicitHost,
resolveDaemonPassword(explicitHost),
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
throw result.error;
}
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
if (index >= hosts.length) {
if (lastError instanceof Error) throw lastError;
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
}
const host = hosts[index];
const password = resolveDaemonPassword(host);
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
if ("client" in result) {
return result.client;
}
return tryNext(index + 1, result.error);
export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> {
try {
return await connectSelectedDaemon(options);
} catch (error) {
throw buildDaemonConnectionCommandError({ ...options, error });
}
return tryNext(0, null);
}
/**
* Try to connect to the daemon, returns null if connection fails
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
export async function tryConnectToDaemon(options: ConnectOptions): Promise<DaemonClient | null> {
try {
return await connectToDaemon(options);
} catch {
+14 -11
View File
@@ -1,16 +1,19 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { addDaemonHostOption, withGlobalOptions } from "./command-options.js";
import { createCli } from "../cli.js";
import { withGlobalOptions } from "./command-options.js";
async function parseHost(argv: string[]): Promise<string | undefined> {
const program = new Command().exitOverride().option("--host <host>");
const program = createCli().exitOverride();
let receivedHost: string | undefined;
addDaemonHostOption(program.command("ls")).action(
withGlobalOptions((options: { host?: string }) => {
receivedHost = options.host;
}),
);
program.commands
.find((command) => command.name() === "ls")!
.action(
withGlobalOptions((options: { host?: string }) => {
receivedHost = options.host;
}),
);
await program.parseAsync(argv, { from: "user" });
return receivedHost;
@@ -25,10 +28,10 @@ describe("global command options", () => {
await expect(parseHost(["ls", "--host", "local:6767"])).resolves.toBe("local:6767");
});
it("uses the last explicit host", async () => {
await expect(parseHost(["--host", "first:6767", "ls", "--host", "last:6767"])).resolves.toBe(
"last:6767",
);
it("rejects conflicting parent and command selectors", async () => {
await expect(
parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]),
).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" });
});
it("lets local-only commands ignore a global host", async () => {
+25 -2
View File
@@ -1,8 +1,17 @@
import type { Command } from "commander";
import { selectDaemonTarget } from "./daemon-target.js";
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
"Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
"Explicit daemon endpoint: host:port, TCP, socket, SSH, or pairing URL (default: local home)";
const localCommands = new WeakSet<Command>();
export function addLocalDaemonOptions<T extends Command>(command: T): T {
localCommands.add(command);
command.option("--home <path>", "Local daemon home (default: ~/.paseo)");
return command;
}
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value]);
@@ -15,6 +24,8 @@ export function addJsonOption<T extends Command>(command: T): T {
export function addDaemonHostOption<T extends Command>(command: T): T {
command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION);
if (!command.options.some((option) => option.long === "--home"))
command.option("--home <path>", "Select a local daemon home (default: ~/.paseo)");
return command;
}
@@ -28,7 +39,19 @@ export function withGlobalOptions<Args extends unknown[], Result>(
return (...args) => {
const command = args.at(-1) as Command;
const mergedArgs = [...args];
mergedArgs[mergedArgs.length - 2] = command.optsWithGlobals();
const options = command.optsWithGlobals();
const selectors = { home: new Set<string>(), host: new Set<string>() };
for (let current: Command | null = command; current; current = current.parent) {
for (const key of ["home", "host"] as const) {
const value = current.opts()[key];
if (typeof value === "string") selectors[key].add(value);
}
}
if (selectors.home.size > 1 || selectors.host.size > 1)
throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." };
const localOnly = localCommands.has(command);
options.daemonTarget = selectDaemonTarget(options, process.env, localOnly);
mergedArgs[mergedArgs.length - 2] = options;
return handler(...(mergedArgs as Args));
};
}
@@ -0,0 +1,32 @@
import { test, expect } from "vitest";
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
test("explicit selectors win over both environment selectors", () => {
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
expect(selectDaemonTarget({ home: "/tmp/b" }, env)).toEqual({ kind: "instance", home: "/tmp/b" });
expect(selectDaemonTarget({ host: "chosen:23456" }, env)).toEqual({
kind: "endpoint",
host: "chosen:23456",
});
expect(() => selectDaemonTarget({}, env)).toThrow();
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
});
test("local operations ignore routing environment but reject an explicit endpoint", () => {
expect(
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
).toEqual({ kind: "instance", home: "/tmp/b" });
expect(() => selectDaemonTarget({ host: "chosen:23456" }, {}, true)).toThrow();
});
test("endpoint descriptions redact pairing material and credentials", () => {
expect(
describeDaemonTarget({
kind: "endpoint",
host: "tcp://user:private@example.test:23456?password=secret",
}),
).not.toMatch(/private|secret/);
expect(
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
).not.toContain("private");
});
+51
View File
@@ -0,0 +1,51 @@
import { resolvePaseoHome } from "@getpaseo/server";
export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string };
export function selectDaemonTarget(
options: { home?: string; host?: string },
env: NodeJS.ProcessEnv = process.env,
localOnly = false,
): DaemonTarget {
for (const key of ["home", "host"] as const) {
if (options[key] !== undefined && !options[key]!.trim())
throw { code: "TARGET_INVALID", message: `--${key} requires a non-empty value.` };
}
if (options.home !== undefined && options.host !== undefined)
throw { code: "TARGET_AMBIGUOUS", message: "Choose either --home or --host, not both." };
if (localOnly) {
if (options.host !== undefined)
throw {
code: "LOCAL_OPERATION",
message: "This is a local operation; use --home. --host is not supported.",
};
return {
kind: "instance",
home: resolvePaseoHome({ PASEO_HOME: options.home ?? env.PASEO_HOME }),
};
}
if (options.home !== undefined)
return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: options.home }) };
if (options.host !== undefined) return { kind: "endpoint", host: options.host };
if (env.PASEO_HOME && env.PASEO_HOST)
throw {
code: "TARGET_AMBIGUOUS",
message: "PASEO_HOME and PASEO_HOST are both set. Choose --home or --host explicitly.",
};
if (env.PASEO_HOST) return { kind: "endpoint", host: env.PASEO_HOST };
return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: env.PASEO_HOME }) };
}
export function describeDaemonTarget(target: DaemonTarget): string {
if (target.kind === "instance") return `home ${target.home}`;
try {
const url = new URL(target.host);
if (url.password) url.password = "REDACTED";
for (const key of url.searchParams.keys())
if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED");
if (url.hash) url.hash = "REDACTED";
return url.toString();
} catch {
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
}
}