mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
+23
-55
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ try {
|
||||
const result = await daemonCommand(["status"]);
|
||||
assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped");
|
||||
const output = result.stdout.toLowerCase();
|
||||
assert(output.includes("local daemon"), "status table should include Local Daemon row");
|
||||
assert(output.includes("localdaemon"), "status table should include Local Daemon row");
|
||||
assert(output.includes("stopped"), "status should report stopped");
|
||||
console.log("✓ daemon status reports stopped when not running\n");
|
||||
}
|
||||
@@ -164,7 +164,7 @@ try {
|
||||
const result = await daemonCommand(["status", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "--json status should succeed");
|
||||
const status = JSON.parse(result.stdout);
|
||||
assert.strictEqual(typeof status.serverId, "string", "json status should include serverId");
|
||||
assert.strictEqual(status.serverId, undefined, "stopped observation must not invent identity");
|
||||
assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped");
|
||||
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
|
||||
assert.strictEqual(
|
||||
@@ -175,20 +175,12 @@ try {
|
||||
console.log("✓ daemon status --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Global hosts do not change local daemon lifecycle commands.
|
||||
{
|
||||
console.log("Test 5b: daemon status ignores a global --host");
|
||||
const result = await runLocalPaseo(["--host", "localhost:1", "daemon", "status", "--json"], {
|
||||
const result = await runLocalPaseo(["--host", "127.0.0.1:1", "daemon", "status", "--json"], {
|
||||
PASEO_HOME: paseoHome,
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0, `daemon status should stay local: ${result.stderr}`);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(
|
||||
parsed.localDaemon,
|
||||
"stopped",
|
||||
"daemon should report the local stopped state",
|
||||
);
|
||||
console.log("✓ daemon status ignores a global --host\n");
|
||||
assert.strictEqual(result.exitCode, 1, "an explicit endpoint must not report local status");
|
||||
assert.match(result.stderr, /127.0.0.1:1/);
|
||||
}
|
||||
|
||||
// Test 6: daemon stop handles daemon not running gracefully
|
||||
@@ -205,16 +197,12 @@ try {
|
||||
console.log("✓ daemon stop succeeds gracefully when daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 7: daemon restart starts daemon and can be stopped
|
||||
{
|
||||
console.log("Test 7: daemon restart starts daemon and can be stopped");
|
||||
const result = await daemonCommand(["restart", "--port", String(port)]);
|
||||
assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped");
|
||||
assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart");
|
||||
|
||||
const cleanup = await daemonCommand(["stop", "--force"]);
|
||||
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
|
||||
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
|
||||
const result = await daemonCommand(["restart", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 1, "restart must not start a stopped daemon");
|
||||
assert.match(result.stderr, /DAEMON_NOT_RUNNING/);
|
||||
const configured = await daemonCommand(["config", "set", "daemon.listen", `127.0.0.1:${port}`]);
|
||||
assert.strictEqual(configured.exitCode, 0, configured.stderr);
|
||||
}
|
||||
|
||||
// Test 8: status uses the running daemon as relay authority over local IPC
|
||||
@@ -256,10 +244,10 @@ try {
|
||||
const relayProbe = await retryWhileWorkerRuns(
|
||||
worker,
|
||||
async () => {
|
||||
const statusResult = await daemonCommand(["status", "--json"]);
|
||||
const statusResult = await daemonCommand(["status", "--host", listen, "--json"]);
|
||||
const statusPayload =
|
||||
statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null;
|
||||
const pairingResult = await daemonCommand(["pair", "--json"]);
|
||||
const pairingResult = await daemonCommand(["pair", "--host", listen, "--json"]);
|
||||
return { statusResult, statusPayload, pairingResult };
|
||||
},
|
||||
(result) =>
|
||||
@@ -280,8 +268,8 @@ try {
|
||||
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
|
||||
assert.strictEqual(
|
||||
payload.localDaemon,
|
||||
"stopped",
|
||||
"missing PID should report stopped locally",
|
||||
undefined,
|
||||
"endpoint status must not invent local PID state",
|
||||
);
|
||||
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
|
||||
assert.strictEqual(
|
||||
@@ -335,31 +323,16 @@ try {
|
||||
try {
|
||||
await writeFile(
|
||||
join(foreignHome, "config.json"),
|
||||
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
|
||||
`${JSON.stringify({ daemon: { listen, relay: { enabled: false } } }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const foreignPairing = await retryWhileWorkerRuns(
|
||||
worker,
|
||||
() =>
|
||||
runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], {
|
||||
PASEO_HOME: foreignHome,
|
||||
}),
|
||||
(result) => result.stderr.includes("different Paseo home"),
|
||||
(result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`,
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
foreignPairing.exitCode,
|
||||
0,
|
||||
"pairing should reject a daemon owned by another home",
|
||||
);
|
||||
assert(
|
||||
foreignPairing.stderr.includes("different Paseo home"),
|
||||
"pairing should explain the daemon identity mismatch",
|
||||
);
|
||||
assert(
|
||||
!foreignPairing.stdout.includes("#offer="),
|
||||
"pairing should not expose another offer",
|
||||
const foreignPairing = await runLocalPaseo(
|
||||
["daemon", "pair", "--home", foreignHome, "--json"],
|
||||
{ PASEO_HOME: foreignHome },
|
||||
);
|
||||
assert.strictEqual(foreignPairing.exitCode, 1);
|
||||
assert.match(foreignPairing.stderr, /RELAY_DISABLED/);
|
||||
assert(!foreignPairing.stdout.includes("#offer="));
|
||||
} finally {
|
||||
await rm(foreignHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Phase 18: Local daemon utility tests.
|
||||
*
|
||||
* Tests pure helpers that do not require a running daemon.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js";
|
||||
|
||||
console.log("=== Local Daemon Utility Helpers ===\n");
|
||||
|
||||
{
|
||||
console.log("Test 1: resolves numeric listen values to localhost host:port");
|
||||
assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767");
|
||||
assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777");
|
||||
console.log("✓ resolves numeric listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: preserves explicit host:port listen values");
|
||||
assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767");
|
||||
assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080");
|
||||
console.log("✓ preserves explicit host:port values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3: rejects unix socket listen values");
|
||||
assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null);
|
||||
console.log("✓ rejects unix socket listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: rejects empty and non-host listen values");
|
||||
assert.strictEqual(resolveTcpHostFromListen(""), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen(" "), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("localhost"), null);
|
||||
console.log("✓ rejects empty and non-host listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
|
||||
console.log("✓ rejects Windows absolute paths\n");
|
||||
}
|
||||
|
||||
console.log("=== All local daemon utility tests passed ===");
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js";
|
||||
|
||||
console.log("=== Worktree LS Path Helper Tests ===\n");
|
||||
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: resolves explicit PASEO_HOME when set");
|
||||
process.env.PASEO_HOME = "/tmp/paseo-explicit-home";
|
||||
|
||||
assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home");
|
||||
assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees");
|
||||
console.log("\u2713 explicit PASEO_HOME is respected\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset");
|
||||
delete process.env.PASEO_HOME;
|
||||
|
||||
assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo"));
|
||||
assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees"));
|
||||
console.log("\u2713 fallback home path is derived from os.homedir()\n");
|
||||
}
|
||||
} finally {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("=== All worktree ls path helper tests passed ===");
|
||||
@@ -213,8 +213,9 @@ try {
|
||||
);
|
||||
const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs);
|
||||
assert(
|
||||
capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') &&
|
||||
capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'),
|
||||
process.platform === "win32"
|
||||
? capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"')
|
||||
: capturedSupervisorLogs.includes("supervisor_received_SIGTERM"),
|
||||
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -40,57 +40,33 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkerPid(supervisorPid: number): number | null {
|
||||
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (result.status !== 0 || result.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const [pidToken, ppidToken] = trimmed.split(/\s+/);
|
||||
const pid = Number.parseInt(pidToken ?? "", 10);
|
||||
const ppid = Number.parseInt(ppidToken ?? "", 10);
|
||||
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
|
||||
continue;
|
||||
}
|
||||
if (ppid === supervisorPid && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
localDaemon: string | null;
|
||||
pid: number | null;
|
||||
workerPid: number | null;
|
||||
}
|
||||
|
||||
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
|
||||
if (result.exitCode !== 0) {
|
||||
return { localDaemon: null, pid: null };
|
||||
return { localDaemon: null, pid: null, workerPid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
localDaemon?: unknown;
|
||||
pid?: unknown;
|
||||
workerPid?: number;
|
||||
};
|
||||
const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { localDaemon, pid };
|
||||
return { localDaemon, pid, workerPid: parsed.workerPid ?? null };
|
||||
} catch {
|
||||
return { localDaemon: null, pid: null };
|
||||
return { localDaemon: null, pid: null, workerPid: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +149,7 @@ try {
|
||||
);
|
||||
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
|
||||
assert(isProcessRunning(supervisorPid), "supervisor process should be running");
|
||||
const workerPidBeforeRestart = readWorkerPid(supervisorPid);
|
||||
const workerPidBeforeRestart = statusBeforeRestart.workerPid;
|
||||
assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart");
|
||||
assert(
|
||||
isProcessRunning(workerPidBeforeRestart),
|
||||
@@ -184,7 +160,7 @@ try {
|
||||
);
|
||||
|
||||
console.log("Test 2: app-style restart request should restart worker and keep daemon healthy");
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 });
|
||||
const client = await tryConnectToDaemon({ target: { kind: "endpoint", host }, timeout: 5000 });
|
||||
assert(client, "daemon client should connect");
|
||||
try {
|
||||
const restartAck = await client.restartServer("settings_update");
|
||||
@@ -198,8 +174,8 @@ try {
|
||||
}
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const workerPid = readWorkerPid(supervisorPid);
|
||||
async () => {
|
||||
const workerPid = (await readDaemonStatus(paseoHome)).workerPid;
|
||||
return (
|
||||
workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
|
||||
);
|
||||
@@ -208,7 +184,7 @@ try {
|
||||
"worker pid did not change after restart request",
|
||||
);
|
||||
|
||||
const workerPidAfterRestart = readWorkerPid(supervisorPid);
|
||||
const workerPidAfterRestart = (await readDaemonStatus(paseoHome)).workerPid;
|
||||
assert(workerPidAfterRestart !== null, "worker process should exist after restart");
|
||||
assert.notStrictEqual(
|
||||
workerPidAfterRestart,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
getDaemonHost,
|
||||
normalizeDaemonHost,
|
||||
resolveDaemonPassword,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
} from "../src/utils/client.js";
|
||||
import { selectDaemonTarget } from "../src/utils/daemon-target.js";
|
||||
import { resolveCliVersion } from "../src/version.js";
|
||||
|
||||
console.log("=== CLI IPC Target Helpers ===\n");
|
||||
@@ -81,48 +78,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 6: default host resolution tries local IPC first, then localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||
try {
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }),
|
||||
);
|
||||
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
|
||||
"unix:///tmp/paseo-from-pid.sock",
|
||||
"localhost:6767",
|
||||
]);
|
||||
const previousHome = process.env.PASEO_HOME;
|
||||
const previousHost = process.env.PASEO_HOST;
|
||||
process.env.PASEO_HOME = paseoHome;
|
||||
delete process.env.PASEO_HOST;
|
||||
assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock");
|
||||
if (previousHome === undefined) delete process.env.PASEO_HOME;
|
||||
else process.env.PASEO_HOME = previousHome;
|
||||
if (previousHost === undefined) delete process.env.PASEO_HOST;
|
||||
else process.env.PASEO_HOST = previousHost;
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log("✓ default host resolution tries local IPC first, then localhost fallback\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 7: configured TCP host is preserved before the localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
|
||||
try {
|
||||
assert.deepStrictEqual(
|
||||
resolveDefaultDaemonHosts({
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: "127.0.0.1:7777",
|
||||
}),
|
||||
["127.0.0.1:7777", "localhost:6767"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log("✓ configured TCP host is preserved before the localhost fallback\n");
|
||||
const target = selectDaemonTarget(
|
||||
{ home: "/tmp/selected-home" },
|
||||
{ PASEO_HOST: "ignored:12345", PASEO_LISTEN: "ignored:23456" },
|
||||
);
|
||||
assert.deepStrictEqual(target, { kind: "instance", home: "/tmp/selected-home" });
|
||||
assert.strictEqual(getDaemonHost({ target }), "home /tmp/selected-home");
|
||||
assert.throws(() => selectDaemonTarget({}, { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" }));
|
||||
}
|
||||
|
||||
{
|
||||
@@ -131,28 +93,6 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
console.log("✓ CLI app version resolves for daemon hello compatibility\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 9: local IPC still takes priority over configured TCP hosts");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
|
||||
try {
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }),
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
resolveDefaultDaemonHosts({
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: "127.0.0.1:7777",
|
||||
}),
|
||||
["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 10: daemon password resolution prefers TCP URI query, falls back to env");
|
||||
const previousEnv = process.env.PASEO_PASSWORD;
|
||||
|
||||
@@ -51,7 +51,10 @@ try {
|
||||
const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8"));
|
||||
|
||||
assert.strictEqual(result.configPath, join(paseoHome, "config.json"));
|
||||
assert.strictEqual(result.restartCommand, "paseo daemon restart");
|
||||
assert.strictEqual(
|
||||
result.restartCommand,
|
||||
`paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
|
||||
);
|
||||
assert.strictEqual(config.daemon.listen, "127.0.0.1:9999");
|
||||
assert.strictEqual(config.daemon.relay.enabled, false);
|
||||
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
|
||||
@@ -68,6 +71,7 @@ try {
|
||||
const result = await runSetPasswordCommand(
|
||||
{
|
||||
home: paseoHome,
|
||||
daemonTarget: { kind: "instance", home: paseoHome },
|
||||
promptPassword: promptSequence(["new-secret", "new-secret"]),
|
||||
},
|
||||
{} as Command,
|
||||
@@ -88,6 +92,7 @@ try {
|
||||
runSetPasswordCommand(
|
||||
{
|
||||
home: paseoHome,
|
||||
daemonTarget: { kind: "instance", home: paseoHome },
|
||||
promptPassword: promptSequence(["first-secret", "second-secret"]),
|
||||
},
|
||||
{} as Command,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user