Switch to double quotes and reformat codebase with Biome
This commit is contained in:
+157
-134
@@ -1,207 +1,230 @@
|
||||
import { Command } from 'commander'
|
||||
import { createRequire } from 'node:module'
|
||||
import { createAgentCommand } from './commands/agent/index.js'
|
||||
import { createDaemonCommand } from './commands/daemon/index.js'
|
||||
import { createPermitCommand } from './commands/permit/index.js'
|
||||
import { createProviderCommand } from './commands/provider/index.js'
|
||||
import { createSpeechCommand } from './commands/speech/index.js'
|
||||
import { createWorktreeCommand } from './commands/worktree/index.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 { runLsCommand } from './commands/agent/ls.js'
|
||||
import { runRunCommand } from './commands/agent/run.js'
|
||||
import { runLogsCommand } from './commands/agent/logs.js'
|
||||
import { runDeleteCommand } from './commands/agent/delete.js'
|
||||
import { runStopCommand } from './commands/agent/stop.js'
|
||||
import { runSendCommand } from './commands/agent/send.js'
|
||||
import { runInspectCommand } from './commands/agent/inspect.js'
|
||||
import { runWaitCommand } from './commands/agent/wait.js'
|
||||
import { runAttachCommand } from './commands/agent/attach.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { onboardCommand } from './commands/onboard.js'
|
||||
import { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
import { createPermitCommand } from "./commands/permit/index.js";
|
||||
import { createProviderCommand } from "./commands/provider/index.js";
|
||||
import { createSpeechCommand } from "./commands/speech/index.js";
|
||||
import { createWorktreeCommand } from "./commands/worktree/index.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 { runLsCommand } from "./commands/agent/ls.js";
|
||||
import { runRunCommand } from "./commands/agent/run.js";
|
||||
import { runLogsCommand } from "./commands/agent/logs.js";
|
||||
import { runDeleteCommand } from "./commands/agent/delete.js";
|
||||
import { runStopCommand } from "./commands/agent/stop.js";
|
||||
import { runSendCommand } from "./commands/agent/send.js";
|
||||
import { runInspectCommand } from "./commands/agent/inspect.js";
|
||||
import { runWaitCommand } from "./commands/agent/wait.js";
|
||||
import { runAttachCommand } from "./commands/agent/attach.js";
|
||||
import { withOutput } from "./output/index.js";
|
||||
import { onboardCommand } from "./commands/onboard.js";
|
||||
import {
|
||||
addDaemonHostOption,
|
||||
addJsonAndDaemonHostOptions,
|
||||
addJsonOption,
|
||||
collectMultiple,
|
||||
} from './utils/command-options.js'
|
||||
} from "./utils/command-options.js";
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
type CliPackageJson = {
|
||||
version?: unknown
|
||||
}
|
||||
version?: unknown;
|
||||
};
|
||||
|
||||
function resolveCliVersion(): string {
|
||||
const packageJson = require('../package.json') as CliPackageJson
|
||||
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
|
||||
return packageJson.version.trim()
|
||||
const packageJson = require("../package.json") as CliPackageJson;
|
||||
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
|
||||
return packageJson.version.trim();
|
||||
}
|
||||
throw new Error('Unable to resolve @getpaseo/cli version from package.json.')
|
||||
throw new Error("Unable to resolve @getpaseo/cli version from package.json.");
|
||||
}
|
||||
|
||||
const VERSION = resolveCliVersion()
|
||||
const VERSION = resolveCliVersion();
|
||||
|
||||
export function createCli(): Command {
|
||||
const program = new Command()
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('paseo')
|
||||
.description('Paseo CLI - control your AI coding agents from the command line')
|
||||
.version(VERSION, '-v, --version', 'output the version number')
|
||||
.name("paseo")
|
||||
.description("Paseo CLI - control your AI coding agents from the command line")
|
||||
.version(VERSION, "-v, --version", "output the version number")
|
||||
// Global output options
|
||||
.option('-o, --format <format>', 'output format: table, json, yaml', 'table')
|
||||
.option('--json', 'output in JSON format (alias for --format json)')
|
||||
.option('-q, --quiet', 'minimal output (IDs only)')
|
||||
.option('--no-headers', 'omit table headers')
|
||||
.option('--no-color', 'disable colored output')
|
||||
.option("-o, --format <format>", "output format: table, json, yaml", "table")
|
||||
.option("--json", "output in JSON format (alias for --format json)")
|
||||
.option("-q, --quiet", "minimal output (IDs only)")
|
||||
.option("--no-headers", "omit table headers")
|
||||
.option("--no-color", "disable colored output");
|
||||
|
||||
// Primary agent commands (top-level)
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('ls')
|
||||
.description('List agents. By default excludes archived agents.')
|
||||
.option('-a, --all', 'Include archived agents')
|
||||
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
|
||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||
).action(withOutput(runLsCommand))
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('run')
|
||||
.description('Create and start an agent with a task')
|
||||
.argument('<prompt>', 'The task/prompt for the agent')
|
||||
.option('-d, --detach', 'Run in background (detached)')
|
||||
.option('--name <name>', 'Assign a name/title to the agent')
|
||||
.option('--provider <provider>', 'Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)', 'claude')
|
||||
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||
.option('--thinking <id>', 'Thinking option ID to use for this run')
|
||||
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||
.option('--worktree <name>', 'Create agent in a new git worktree')
|
||||
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
|
||||
.command("ls")
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived agents")
|
||||
.option("-g, --global", "Legacy no-op (kept for compatibility)")
|
||||
.option(
|
||||
'--image <path>',
|
||||
'Attach image(s) to the initial prompt (can be used multiple times)',
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[]
|
||||
[],
|
||||
)
|
||||
.option('--cwd <path>', 'Working directory (default: current)')
|
||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||
).action(withOutput(runRunCommand))
|
||||
.option("--thinking <id>", "Filter by thinking option ID"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command("run")
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --detach", "Run in background (detached)")
|
||||
.option("--name <name>", "Assign a name/title to the agent")
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
"claude",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||
.option("--worktree <name>", "Create agent in a new git worktree")
|
||||
.option("--base <branch>", "Base branch for worktree (default: current branch)")
|
||||
.option(
|
||||
"--image <path>",
|
||||
"Attach image(s) to the initial prompt (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--output-schema <schema>",
|
||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
||||
),
|
||||
).action(withOutput(runRunCommand));
|
||||
|
||||
addDaemonHostOption(
|
||||
program
|
||||
.command('attach')
|
||||
.command("attach")
|
||||
.description("Attach to a running agent's output stream")
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
).action(runAttachCommand)
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
).action(runAttachCommand);
|
||||
|
||||
addDaemonHostOption(
|
||||
program
|
||||
.command('logs')
|
||||
.description('View agent activity/timeline')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('-f, --follow', 'Follow log output (streaming)')
|
||||
.option('--tail <n>', 'Show last n entries')
|
||||
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||
.option('--since <time>', 'Show logs since timestamp')
|
||||
).action(runLogsCommand)
|
||||
.command("logs")
|
||||
.description("View agent activity/timeline")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("-f, --follow", "Follow log output (streaming)")
|
||||
.option("--tail <n>", "Show last n entries")
|
||||
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)")
|
||||
.option("--since <time>", "Show logs since timestamp"),
|
||||
).action(runLogsCommand);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('stop')
|
||||
.description('Interrupt an agent if it is running (no-op for idle agents)')
|
||||
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
|
||||
.option('--all', 'Stop all agents')
|
||||
.option('--cwd <path>', 'Stop all agents in directory')
|
||||
).action(withOutput(runStopCommand))
|
||||
.command("stop")
|
||||
.description("Interrupt an agent if it is running (no-op for idle agents)")
|
||||
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
|
||||
.option("--all", "Stop all agents")
|
||||
.option("--cwd <path>", "Stop all agents in directory"),
|
||||
).action(withOutput(runStopCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('delete')
|
||||
.description('Delete an agent (interrupt if running, then hard-delete)')
|
||||
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
|
||||
.option('--all', 'Delete all agents')
|
||||
.option('--cwd <path>', 'Delete all agents in directory')
|
||||
).action(withOutput(runDeleteCommand))
|
||||
.command("delete")
|
||||
.description("Delete an agent (interrupt if running, then hard-delete)")
|
||||
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
|
||||
.option("--all", "Delete all agents")
|
||||
.option("--cwd <path>", "Delete all agents in directory"),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('send')
|
||||
.description('Send a message/task to an existing agent')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.argument('[prompt]', 'The message to send')
|
||||
.option('--prompt <text>', 'Provide the message inline as a flag')
|
||||
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
|
||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
||||
).action(withOutput(runSendCommand))
|
||||
.command("send")
|
||||
.description("Send a message/task to an existing agent")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.argument("[prompt]", "The message to send")
|
||||
.option("--prompt <text>", "Provide the message inline as a flag")
|
||||
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
|
||||
.option("--no-wait", "Return immediately without waiting for completion")
|
||||
.option("--image <path>", "Attach image(s) to the message", collectMultiple, []),
|
||||
).action(withOutput(runSendCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('inspect')
|
||||
.description('Show detailed information about an agent')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
).action(withOutput(runInspectCommand))
|
||||
.command("inspect")
|
||||
.description("Show detailed information about an agent")
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command('wait')
|
||||
.description('Wait for an agent to become idle')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
||||
).action(withOutput(runWaitCommand))
|
||||
.command("wait")
|
||||
.description("Wait for an agent to become idle")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(onboardCommand())
|
||||
program.addCommand(daemonStartCommand())
|
||||
program.addCommand(onboardCommand());
|
||||
program.addCommand(daemonStartCommand());
|
||||
|
||||
addJsonOption(
|
||||
program
|
||||
.command('status')
|
||||
.description('Show local daemon status (alias for "paseo daemon status")')
|
||||
.command("status")
|
||||
.description('Show local daemon status (alias for "paseo daemon status")'),
|
||||
)
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.action(withOutput(runDaemonStatusCommand))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.action(withOutput(runDaemonStatusCommand));
|
||||
|
||||
addJsonOption(
|
||||
program
|
||||
.command('restart')
|
||||
.description('Restart local daemon (alias for "paseo daemon restart")')
|
||||
.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('--no-relay', 'Disable relay on restarted daemon')
|
||||
.option('--no-mcp', 'Disable Agent MCP on restarted 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(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
"--listen <listen>",
|
||||
"Listen target for restarted daemon (host:port, port, or unix socket)",
|
||||
)
|
||||
.action(withOutput(runDaemonRestartCommand))
|
||||
.option("--port <port>", "Port for restarted daemon listen target")
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.action(withOutput(runDaemonRestartCommand));
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
program.addCommand(createAgentCommand())
|
||||
program.addCommand(createAgentCommand());
|
||||
|
||||
// Daemon commands
|
||||
program.addCommand(createDaemonCommand())
|
||||
program.addCommand(createDaemonCommand());
|
||||
|
||||
// Permission commands
|
||||
program.addCommand(createPermitCommand())
|
||||
program.addCommand(createPermitCommand());
|
||||
|
||||
// Provider commands
|
||||
program.addCommand(createProviderCommand())
|
||||
program.addCommand(createProviderCommand());
|
||||
|
||||
// Speech model commands
|
||||
program.addCommand(createSpeechCommand())
|
||||
program.addCommand(createSpeechCommand());
|
||||
|
||||
// Worktree commands
|
||||
program.addCommand(createWorktreeCommand())
|
||||
program.addCommand(createWorktreeCommand());
|
||||
|
||||
return program
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -1,126 +1,131 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
/** Result type for agent archive command */
|
||||
export interface AgentArchiveResult {
|
||||
agentId: string
|
||||
status: 'archived'
|
||||
archivedAt: string
|
||||
agentId: string;
|
||||
status: "archived";
|
||||
archivedAt: string;
|
||||
}
|
||||
|
||||
/** Schema for archive command output */
|
||||
export const archiveSchema: OutputSchema<AgentArchiveResult> = {
|
||||
idField: 'agentId',
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId' },
|
||||
{ header: 'STATUS', field: 'status' },
|
||||
{ header: 'ARCHIVED AT', field: 'archivedAt' },
|
||||
{ header: "AGENT ID", field: "agentId" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{ header: "ARCHIVED AT", field: "archivedAt" },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface AgentArchiveOptions extends CommandOptions {
|
||||
force?: boolean
|
||||
host?: string
|
||||
force?: boolean;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>
|
||||
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>;
|
||||
|
||||
export async function runArchiveCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentArchiveOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentArchiveCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent archive <id-or-name>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_AGENT_ID",
|
||||
message: "Agent ID is required",
|
||||
details: "Usage: paseo agent archive <id-or-name>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||
const agentId = resolveAgentId(agentIdArg, agents)
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
const agentId = resolveAgentId(agentIdArg, agents);
|
||||
if (!agentId) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = agents.find((entry) => entry.id === agentId)
|
||||
const agent = agents.find((entry) => entry.id === agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`)
|
||||
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`);
|
||||
}
|
||||
|
||||
// Check if agent is already archived
|
||||
if (agent.archivedAt) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_ALREADY_ARCHIVED',
|
||||
code: "AGENT_ALREADY_ARCHIVED",
|
||||
message: `Agent ${agentId.slice(0, 7)} is already archived`,
|
||||
details: `Archived at: ${agent.archivedAt}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if agent is running and reject unless --force is set
|
||||
if (agent.status === 'running' && !options.force) {
|
||||
if (agent.status === "running" && !options.force) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_RUNNING',
|
||||
code: "AGENT_RUNNING",
|
||||
message: `Agent ${agentId.slice(0, 7)} is currently running`,
|
||||
details:
|
||||
'Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.',
|
||||
}
|
||||
throw error
|
||||
"Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Archive the agent
|
||||
const result = await client.archiveAgent(agentId)
|
||||
const result = await client.archiveAgent(agentId);
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId,
|
||||
status: 'archived',
|
||||
status: "archived",
|
||||
archivedAt: result.archivedAt,
|
||||
},
|
||||
schema: archiveSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'ARCHIVE_FAILED',
|
||||
code: "ARCHIVE_FAILED",
|
||||
message: `Failed to archive agent: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
|
||||
import type {
|
||||
DaemonClient,
|
||||
AgentStreamMessage,
|
||||
AgentStreamEventPayload,
|
||||
AgentTimelineItem,
|
||||
} from '@getpaseo/server'
|
||||
} from "@getpaseo/server";
|
||||
|
||||
export interface AgentAttachOptions {
|
||||
host?: string
|
||||
[key: string]: unknown
|
||||
host?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,41 +18,41 @@ export interface AgentAttachOptions {
|
||||
*/
|
||||
function printTimelineItem(item: AgentTimelineItem): void {
|
||||
switch (item.type) {
|
||||
case 'assistant_message':
|
||||
case "assistant_message":
|
||||
// Print assistant text directly
|
||||
process.stdout.write(item.text)
|
||||
break
|
||||
process.stdout.write(item.text);
|
||||
break;
|
||||
|
||||
case 'reasoning':
|
||||
case "reasoning":
|
||||
// Print reasoning in a muted color if available
|
||||
console.log(`\n[Reasoning] ${item.text}`)
|
||||
break
|
||||
console.log(`\n[Reasoning] ${item.text}`);
|
||||
break;
|
||||
|
||||
case 'tool_call': {
|
||||
const toolName = item.name
|
||||
const status = item.status ?? 'started'
|
||||
console.log(`\n[Tool: ${toolName}] ${status}`)
|
||||
break
|
||||
case "tool_call": {
|
||||
const toolName = item.name;
|
||||
const status = item.status ?? "started";
|
||||
console.log(`\n[Tool: ${toolName}] ${status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'todo': {
|
||||
const completed = item.items.filter((i) => i.completed).length
|
||||
const total = item.items.length
|
||||
console.log(`\n[Todo] ${completed}/${total} completed`)
|
||||
break
|
||||
case "todo": {
|
||||
const completed = item.items.filter((i) => i.completed).length;
|
||||
const total = item.items.length;
|
||||
console.log(`\n[Todo] ${completed}/${total} completed`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
console.error(`\n[Error] ${item.message}`)
|
||||
break
|
||||
case "error":
|
||||
console.error(`\n[Error] ${item.message}`);
|
||||
break;
|
||||
|
||||
case 'user_message':
|
||||
console.log(`\n[User] ${item.text}`)
|
||||
break
|
||||
case "user_message":
|
||||
console.log(`\n[User] ${item.text}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown item type, skip
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,33 +61,33 @@ function printTimelineItem(item: AgentTimelineItem): void {
|
||||
*/
|
||||
function printStreamEvent(event: AgentStreamEventPayload): void {
|
||||
switch (event.type) {
|
||||
case 'timeline':
|
||||
case "timeline":
|
||||
// Print the timeline item
|
||||
printTimelineItem(event.item)
|
||||
break
|
||||
printTimelineItem(event.item);
|
||||
break;
|
||||
|
||||
case 'permission_requested':
|
||||
console.log(`\n[Permission Required] ${event.request.name}`)
|
||||
case "permission_requested":
|
||||
console.log(`\n[Permission Required] ${event.request.name}`);
|
||||
if (event.request.description) {
|
||||
console.log(` ${event.request.description}`)
|
||||
console.log(` ${event.request.description}`);
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'permission_resolved':
|
||||
console.log(`\n[Permission ${event.resolution.behavior}]`)
|
||||
break
|
||||
case "permission_resolved":
|
||||
console.log(`\n[Permission ${event.resolution.behavior}]`);
|
||||
break;
|
||||
|
||||
case 'turn_failed':
|
||||
console.error(`\n[Turn Failed] ${event.error}`)
|
||||
break
|
||||
case "turn_failed":
|
||||
console.error(`\n[Turn Failed] ${event.error}`);
|
||||
break;
|
||||
|
||||
case 'attention_required':
|
||||
console.log(`\n[Attention Required: ${event.reason}]`)
|
||||
break
|
||||
case "attention_required":
|
||||
console.log(`\n[Attention Required: ${event.reason}]`);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Other event types are internal
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,91 +97,91 @@ function printStreamEvent(event: AgentStreamEventPayload): void {
|
||||
export async function runAttachCommand(
|
||||
id: string,
|
||||
options: AgentAttachOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<void> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
if (!id) {
|
||||
console.error('Error: Agent ID required')
|
||||
console.error('Usage: paseo attach <id>')
|
||||
process.exit(1)
|
||||
console.error("Error: Agent ID required");
|
||||
console.error("Usage: paseo attach <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: DaemonClient
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} 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 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);
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(id)
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
if (!fetchResult) {
|
||||
console.error(`Error: No agent found matching: ${id}`)
|
||||
console.error('Use `paseo ls` to list available agents')
|
||||
await client.close()
|
||||
process.exit(1)
|
||||
console.error(`Error: No agent found matching: ${id}`);
|
||||
console.error("Use `paseo ls` to list available agents");
|
||||
await client.close();
|
||||
process.exit(1);
|
||||
}
|
||||
const resolvedId = fetchResult.agent.id
|
||||
const resolvedId = fetchResult.agent.id;
|
||||
|
||||
// Print header
|
||||
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
|
||||
console.log(`(Press Ctrl+C to detach)\n`)
|
||||
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`);
|
||||
console.log(`(Press Ctrl+C to detach)\n`);
|
||||
|
||||
// Print existing output from timeline fetch.
|
||||
try {
|
||||
const timelineItems = await fetchProjectedTimelineItems({
|
||||
client,
|
||||
agentId: resolvedId,
|
||||
})
|
||||
});
|
||||
for (const item of timelineItems) {
|
||||
printTimelineItem(item)
|
||||
printTimelineItem(item);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Warning: failed to fetch existing timeline', error)
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
|
||||
const message = msg as AgentStreamMessage
|
||||
if (message.type !== 'agent_stream') return
|
||||
if (message.payload.agentId !== resolvedId) return
|
||||
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
|
||||
const message = msg as AgentStreamMessage;
|
||||
if (message.type !== "agent_stream") return;
|
||||
if (message.payload.agentId !== resolvedId) return;
|
||||
|
||||
printStreamEvent(message.payload.event)
|
||||
})
|
||||
printStreamEvent(message.payload.event);
|
||||
});
|
||||
|
||||
// Handle Ctrl+C to detach gracefully
|
||||
let detached = false
|
||||
let detached = false;
|
||||
const detach = () => {
|
||||
if (detached) return
|
||||
detached = true
|
||||
if (detached) return;
|
||||
detached = true;
|
||||
|
||||
console.log('\n\nDetaching from agent...')
|
||||
unsubscribe()
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
client
|
||||
.close()
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', detach)
|
||||
process.on('SIGTERM', detach)
|
||||
process.on("SIGINT", detach);
|
||||
process.on("SIGTERM", detach);
|
||||
|
||||
// Keep the process alive
|
||||
await new Promise(() => {
|
||||
// Wait indefinitely until interrupted
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`Error: Failed to attach to agent: ${message}`)
|
||||
process.exit(1)
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Failed to attach to agent: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,119 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
export interface DeleteResult {
|
||||
deletedCount: number
|
||||
agentIds: string[]
|
||||
deletedCount: number;
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
export const deleteSchema: OutputSchema<DeleteResult> = {
|
||||
idField: (item) => item.agentIds.join('\n'),
|
||||
columns: [{ header: 'DELETED', field: 'deletedCount' }],
|
||||
}
|
||||
idField: (item) => item.agentIds.join("\n"),
|
||||
columns: [{ header: "DELETED", field: "deletedCount" }],
|
||||
};
|
||||
|
||||
export interface AgentDeleteOptions extends CommandOptions {
|
||||
all?: boolean
|
||||
cwd?: string
|
||||
all?: boolean;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export type AgentDeleteResult = SingleResult<DeleteResult>
|
||||
export type AgentDeleteResult = SingleResult<DeleteResult>;
|
||||
|
||||
export async function runDeleteCommand(
|
||||
id: string | undefined,
|
||||
options: AgentDeleteOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentDeleteResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
if (!id && !options.all && !options.cwd) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Agent ID required unless --all or --cwd is specified',
|
||||
details: 'Usage: paseo agent delete <id> | --all | --cwd <path>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_ARGUMENT",
|
||||
message: "Agent ID required unless --all or --cwd is specified",
|
||||
details: "Usage: paseo agent delete <id> | --all | --cwd <path>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||
const deletedIds: string[] = []
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
const deletedIds: string[] = [];
|
||||
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt)
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
const filterCwd = options.cwd
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false
|
||||
const agentCwd = a.cwd.replace(/\/$/, '')
|
||||
const targetCwd = filterCwd.replace(/\/$/, '')
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
|
||||
})
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent(id)
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `No agent found matching: ${id}`,
|
||||
details: 'Use `paseo ls` to list available agents',
|
||||
}
|
||||
throw error
|
||||
details: "Use `paseo ls` to list available agents",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
agents = [fetchResult.agent]
|
||||
agents = [fetchResult.agent];
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
if (agent.status === 'running') {
|
||||
await client.cancelAgent(agent.id)
|
||||
if (agent.status === "running") {
|
||||
await client.cancelAgent(agent.id);
|
||||
}
|
||||
await client.deleteAgent(agent.id)
|
||||
deletedIds.push(agent.id)
|
||||
await client.deleteAgent(agent.id);
|
||||
deletedIds.push(agent.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
deletedCount: deletedIds.length,
|
||||
agentIds: deletedIds,
|
||||
},
|
||||
schema: deleteSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
await client.close().catch(() => {});
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DELETE_AGENT_FAILED',
|
||||
code: "DELETE_AGENT_FAILED",
|
||||
message: `Failed to delete agent(s): ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,169 @@
|
||||
import { Command } from 'commander'
|
||||
import { runModeCommand } from './mode.js'
|
||||
import { runArchiveCommand } from './archive.js'
|
||||
import { runDeleteCommand } from './delete.js'
|
||||
import { runLsCommand } from './ls.js'
|
||||
import { runRunCommand } from './run.js'
|
||||
import { runLogsCommand } from './logs.js'
|
||||
import { runStopCommand } from './stop.js'
|
||||
import { runSendCommand } from './send.js'
|
||||
import { runInspectCommand } from './inspect.js'
|
||||
import { runWaitCommand } from './wait.js'
|
||||
import { runAttachCommand } from './attach.js'
|
||||
import { runUpdateCommand } from './update.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
import { Command } from "commander";
|
||||
import { runModeCommand } from "./mode.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { runDeleteCommand } from "./delete.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runRunCommand } from "./run.js";
|
||||
import { runLogsCommand } from "./logs.js";
|
||||
import { runStopCommand } from "./stop.js";
|
||||
import { runSendCommand } from "./send.js";
|
||||
import { runInspectCommand } from "./inspect.js";
|
||||
import { runWaitCommand } from "./wait.js";
|
||||
import { runAttachCommand } from "./attach.js";
|
||||
import { runUpdateCommand } from "./update.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import {
|
||||
addDaemonHostOption,
|
||||
addJsonAndDaemonHostOptions,
|
||||
collectMultiple,
|
||||
} from '../../utils/command-options.js'
|
||||
} from "../../utils/command-options.js";
|
||||
|
||||
export function createAgentCommand(): Command {
|
||||
const agent = new Command('agent').description('Manage agents (advanced operations)')
|
||||
const agent = new Command("agent").description("Manage agents (advanced operations)");
|
||||
|
||||
// Primary agent commands (same as top-level)
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('ls')
|
||||
.description('List agents. By default excludes archived agents.')
|
||||
.option('-a, --all', 'Include archived agents')
|
||||
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
|
||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||
).action(withOutput(runLsCommand))
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('run')
|
||||
.description('Create and start an agent with a task')
|
||||
.argument('<prompt>', 'The task/prompt for the agent')
|
||||
.option('-d, --detach', 'Run in background (detached)')
|
||||
.option('--name <name>', 'Assign a name/title to the agent')
|
||||
.option('--provider <provider>', 'Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)', 'claude')
|
||||
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||
.option('--thinking <id>', 'Thinking option ID to use for this run')
|
||||
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||
.option('--cwd <path>', 'Working directory (default: current)')
|
||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||
.command("ls")
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived agents")
|
||||
.option("-g, --global", "Legacy no-op (kept for compatibility)")
|
||||
.option(
|
||||
'--wait-timeout <duration>',
|
||||
'Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit'
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
|
||||
).action(withOutput(runRunCommand))
|
||||
.option("--thinking <id>", "Filter by thinking option ID"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("run")
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --detach", "Run in background (detached)")
|
||||
.option("--name <name>", "Assign a name/title to the agent")
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
"claude",
|
||||
)
|
||||
.option(
|
||||
"--model <model>",
|
||||
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID to use for this run")
|
||||
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--wait-timeout <duration>",
|
||||
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
|
||||
)
|
||||
.option(
|
||||
"--output-schema <schema>",
|
||||
"Output JSON matching the provided schema file path or inline JSON schema",
|
||||
),
|
||||
).action(withOutput(runRunCommand));
|
||||
|
||||
addDaemonHostOption(
|
||||
agent
|
||||
.command('attach')
|
||||
.command("attach")
|
||||
.description("Attach to a running agent's output stream")
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
).action(runAttachCommand)
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
).action(runAttachCommand);
|
||||
|
||||
addDaemonHostOption(
|
||||
agent
|
||||
.command('logs')
|
||||
.description('View agent activity/timeline')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('-f, --follow', 'Follow log output (streaming)')
|
||||
.option('--tail <n>', 'Show last n entries')
|
||||
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||
).action(runLogsCommand)
|
||||
.command("logs")
|
||||
.description("View agent activity/timeline")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("-f, --follow", "Follow log output (streaming)")
|
||||
.option("--tail <n>", "Show last n entries")
|
||||
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)"),
|
||||
).action(runLogsCommand);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('stop')
|
||||
.description('Interrupt an agent if it is running (no-op for idle agents)')
|
||||
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
|
||||
.option('--all', 'Stop all agents')
|
||||
.option('--cwd <path>', 'Stop all agents in directory')
|
||||
).action(withOutput(runStopCommand))
|
||||
.command("stop")
|
||||
.description("Interrupt an agent if it is running (no-op for idle agents)")
|
||||
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
|
||||
.option("--all", "Stop all agents")
|
||||
.option("--cwd <path>", "Stop all agents in directory"),
|
||||
).action(withOutput(runStopCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('delete')
|
||||
.description('Delete an agent (interrupt if running, then hard-delete)')
|
||||
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
|
||||
.option('--all', 'Delete all agents')
|
||||
.option('--cwd <path>', 'Delete all agents in directory')
|
||||
).action(withOutput(runDeleteCommand))
|
||||
.command("delete")
|
||||
.description("Delete an agent (interrupt if running, then hard-delete)")
|
||||
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
|
||||
.option("--all", "Delete all agents")
|
||||
.option("--cwd <path>", "Delete all agents in directory"),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('send')
|
||||
.description('Send a message/task to an existing agent')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.argument('[prompt]', 'The message to send')
|
||||
.option('--prompt <text>', 'Provide the message inline as a flag')
|
||||
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
|
||||
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||
).action(withOutput(runSendCommand))
|
||||
.command("send")
|
||||
.description("Send a message/task to an existing agent")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.argument("[prompt]", "The message to send")
|
||||
.option("--prompt <text>", "Provide the message inline as a flag")
|
||||
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
|
||||
.option("--no-wait", "Return immediately without waiting for completion"),
|
||||
).action(withOutput(runSendCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('inspect')
|
||||
.description('Show detailed information about an agent')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
).action(withOutput(runInspectCommand))
|
||||
.command("inspect")
|
||||
.description("Show detailed information about an agent")
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('wait')
|
||||
.description('Wait for an agent to become idle')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
|
||||
).action(withOutput(runWaitCommand))
|
||||
.command("wait")
|
||||
.description("Wait for an agent to become idle")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('mode')
|
||||
.command("mode")
|
||||
.description("Change an agent's operational mode")
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.argument('[mode]', 'Mode to set (required unless --list)')
|
||||
.option('--list', 'List available modes for this agent')
|
||||
).action(withOutput(runModeCommand))
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.argument("[mode]", "Mode to set (required unless --list)")
|
||||
.option("--list", "List available modes for this agent"),
|
||||
).action(withOutput(runModeCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('archive')
|
||||
.description('Archive an agent (soft-delete)')
|
||||
.argument('<id>', 'Agent ID, prefix, or name')
|
||||
.option('--force', 'Force archive running agent (interrupts active run first)')
|
||||
).action(withOutput(runArchiveCommand))
|
||||
.command("archive")
|
||||
.description("Archive an agent (soft-delete)")
|
||||
.argument("<id>", "Agent ID, prefix, or name")
|
||||
.option("--force", "Force archive running agent (interrupts active run first)"),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command('update')
|
||||
.command("update")
|
||||
.description("Update an agent's metadata")
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('--name <name>', "Update the agent's display name")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("--name <name>", "Update the agent's display name")
|
||||
.option(
|
||||
'--label <label>',
|
||||
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
|
||||
"--label <label>",
|
||||
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",
|
||||
collectMultiple,
|
||||
[]
|
||||
)
|
||||
).action(withOutput(runUpdateCommand))
|
||||
[],
|
||||
),
|
||||
).action(withOutput(runUpdateCommand));
|
||||
|
||||
return agent
|
||||
return agent;
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentSnapshotPayload } from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Agent inspect data for display (matches CLI spec format) */
|
||||
interface AgentInspect {
|
||||
Id: string
|
||||
Name: string
|
||||
Provider: string
|
||||
Model: string
|
||||
Thinking: string
|
||||
Status: string
|
||||
Archived: boolean
|
||||
ArchivedAt: string | null
|
||||
Mode: string
|
||||
Cwd: string
|
||||
CreatedAt: string
|
||||
UpdatedAt: string
|
||||
Id: string;
|
||||
Name: string;
|
||||
Provider: string;
|
||||
Model: string;
|
||||
Thinking: string;
|
||||
Status: string;
|
||||
Archived: boolean;
|
||||
ArchivedAt: string | null;
|
||||
Mode: string;
|
||||
Cwd: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
LastUsage: {
|
||||
InputTokens: number
|
||||
OutputTokens: number
|
||||
CachedTokens: number
|
||||
CostUsd: number
|
||||
} | null
|
||||
InputTokens: number;
|
||||
OutputTokens: number;
|
||||
CachedTokens: number;
|
||||
CostUsd: number;
|
||||
} | null;
|
||||
Capabilities: {
|
||||
Streaming: boolean
|
||||
Persistence: boolean
|
||||
DynamicModes: boolean
|
||||
McpServers: boolean
|
||||
} | null
|
||||
Streaming: boolean;
|
||||
Persistence: boolean;
|
||||
DynamicModes: boolean;
|
||||
McpServers: boolean;
|
||||
} | null;
|
||||
AvailableModes: Array<{
|
||||
id: string
|
||||
label: string
|
||||
}> | null
|
||||
id: string;
|
||||
label: string;
|
||||
}> | null;
|
||||
PendingPermissions: Array<{
|
||||
id: string
|
||||
tool: string
|
||||
}>
|
||||
Worktree: string | null
|
||||
ParentAgentId: string | null
|
||||
id: string;
|
||||
tool: string;
|
||||
}>;
|
||||
Worktree: string | null;
|
||||
ParentAgentId: string | null;
|
||||
}
|
||||
|
||||
/** Key-value row for table display */
|
||||
interface InspectRow {
|
||||
key: string
|
||||
value: string
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Schema for key-value display with custom serialization for JSON/YAML */
|
||||
function createInspectSchema(agent: AgentInspect): OutputSchema<InspectRow> {
|
||||
return {
|
||||
idField: 'key',
|
||||
idField: "key",
|
||||
columns: [
|
||||
{ header: 'KEY', field: 'key' },
|
||||
{ header: "KEY", field: "key" },
|
||||
{
|
||||
header: 'VALUE',
|
||||
field: 'value',
|
||||
header: "VALUE",
|
||||
field: "value",
|
||||
color: (_, item) => {
|
||||
if (item.key === 'Status') {
|
||||
if (item.value === 'running') return 'green'
|
||||
if (item.value === 'idle') return 'yellow'
|
||||
if (item.value === 'error') return 'red'
|
||||
if (item.key === "Status") {
|
||||
if (item.value === "running") return "green";
|
||||
if (item.value === "idle") return "yellow";
|
||||
if (item.value === "error") return "red";
|
||||
}
|
||||
return undefined
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
// For JSON/YAML, return the structured agent object
|
||||
serialize: (_item) => agent,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Shorten home directory in path */
|
||||
function shortenPath(path: string): string {
|
||||
const home = process.env.HOME
|
||||
const home = process.env.HOME;
|
||||
if (home && path.startsWith(home)) {
|
||||
return '~' + path.slice(home.length)
|
||||
return "~" + path.slice(home.length);
|
||||
}
|
||||
return path
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Format cost in USD */
|
||||
function formatCost(costUsd: number): string {
|
||||
if (costUsd === 0) return '$0.00'
|
||||
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`
|
||||
return `$${costUsd.toFixed(2)}`
|
||||
if (costUsd === 0) return "$0.00";
|
||||
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`;
|
||||
return `$${costUsd.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function normalizeModelId(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
if (!normalized || normalized.toLowerCase() === 'default') return null
|
||||
return normalized
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.toLowerCase() === "default") return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveModel(snapshot: AgentSnapshotPayload): string | null {
|
||||
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model)
|
||||
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model);
|
||||
}
|
||||
|
||||
/** Convert agent snapshot to inspection data */
|
||||
@@ -107,7 +107,7 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
|
||||
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
|
||||
}
|
||||
: null
|
||||
: null;
|
||||
|
||||
const capabilities = snapshot.capabilities
|
||||
? {
|
||||
@@ -116,22 +116,22 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
|
||||
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
|
||||
}
|
||||
: null
|
||||
: null;
|
||||
|
||||
// Extract worktree and parentAgentId from labels if they exist
|
||||
const worktree = snapshot.labels?.['paseo.worktree'] ?? null
|
||||
const parentAgentId = snapshot.labels?.['paseo.parent-agent-id'] ?? null
|
||||
const worktree = snapshot.labels?.["paseo.worktree"] ?? null;
|
||||
const parentAgentId = snapshot.labels?.["paseo.parent-agent-id"] ?? null;
|
||||
|
||||
return {
|
||||
Id: snapshot.id,
|
||||
Name: snapshot.title ?? '-',
|
||||
Name: snapshot.title ?? "-",
|
||||
Provider: snapshot.provider,
|
||||
Model: resolveModel(snapshot) ?? '-',
|
||||
Thinking: snapshot.effectiveThinkingOptionId ?? 'auto',
|
||||
Model: resolveModel(snapshot) ?? "-",
|
||||
Thinking: snapshot.effectiveThinkingOptionId ?? "auto",
|
||||
Status: snapshot.status,
|
||||
Archived: snapshot.archivedAt != null,
|
||||
ArchivedAt: snapshot.archivedAt ?? null,
|
||||
Mode: snapshot.currentModeId ?? 'default',
|
||||
Mode: snapshot.currentModeId ?? "default",
|
||||
Cwd: snapshot.cwd,
|
||||
CreatedAt: snapshot.createdAt,
|
||||
UpdatedAt: snapshot.updatedAt,
|
||||
@@ -142,133 +142,134 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
: null,
|
||||
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
|
||||
id: p.id,
|
||||
tool: p.name ?? 'unknown',
|
||||
tool: p.name ?? "unknown",
|
||||
})),
|
||||
Worktree: worktree,
|
||||
ParentAgentId: parentAgentId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert agent to key-value rows for table display */
|
||||
function toInspectRows(agent: AgentInspect): InspectRow[] {
|
||||
const rows: InspectRow[] = [
|
||||
{ key: 'Id', value: agent.Id },
|
||||
{ key: 'Name', value: agent.Name },
|
||||
{ key: 'Provider', value: agent.Provider },
|
||||
{ key: 'Model', value: agent.Model },
|
||||
{ key: 'Thinking', value: agent.Thinking },
|
||||
{ key: 'Status', value: agent.Status },
|
||||
{ key: 'Archived', value: String(agent.Archived) },
|
||||
{ key: 'ArchivedAt', value: agent.ArchivedAt ?? 'null' },
|
||||
{ key: 'Mode', value: agent.Mode },
|
||||
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
|
||||
{ key: 'CreatedAt', value: agent.CreatedAt },
|
||||
{ key: 'UpdatedAt', value: agent.UpdatedAt },
|
||||
]
|
||||
{ key: "Id", value: agent.Id },
|
||||
{ key: "Name", value: agent.Name },
|
||||
{ key: "Provider", value: agent.Provider },
|
||||
{ key: "Model", value: agent.Model },
|
||||
{ key: "Thinking", value: agent.Thinking },
|
||||
{ key: "Status", value: agent.Status },
|
||||
{ key: "Archived", value: String(agent.Archived) },
|
||||
{ key: "ArchivedAt", value: agent.ArchivedAt ?? "null" },
|
||||
{ key: "Mode", value: agent.Mode },
|
||||
{ key: "Cwd", value: shortenPath(agent.Cwd) },
|
||||
{ key: "CreatedAt", value: agent.CreatedAt },
|
||||
{ key: "UpdatedAt", value: agent.UpdatedAt },
|
||||
];
|
||||
|
||||
if (agent.LastUsage) {
|
||||
rows.push({
|
||||
key: 'LastUsage',
|
||||
key: "LastUsage",
|
||||
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (agent.Capabilities) {
|
||||
rows.push({
|
||||
key: 'Capabilities',
|
||||
key: "Capabilities",
|
||||
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
|
||||
rows.push({
|
||||
key: 'AvailableModes',
|
||||
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
|
||||
})
|
||||
key: "AvailableModes",
|
||||
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: 'PendingPermissions',
|
||||
value: agent.PendingPermissions.length > 0
|
||||
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
|
||||
: '[]'
|
||||
})
|
||||
key: "PendingPermissions",
|
||||
value:
|
||||
agent.PendingPermissions.length > 0
|
||||
? agent.PendingPermissions.map((p) => `${p.id} (${p.tool})`).join(", ")
|
||||
: "[]",
|
||||
});
|
||||
|
||||
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
|
||||
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
|
||||
rows.push({ key: "Worktree", value: agent.Worktree ?? "null" });
|
||||
rows.push({ key: "ParentAgentId", value: agent.ParentAgentId ?? "null" });
|
||||
|
||||
return rows
|
||||
return rows;
|
||||
}
|
||||
|
||||
export type AgentInspectResult = ListResult<InspectRow>
|
||||
export type AgentInspectResult = ListResult<InspectRow>;
|
||||
|
||||
export interface AgentInspectOptions extends CommandOptions {
|
||||
host?: string
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runInspectCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentInspectOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentInspectResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent inspect <id>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_AGENT_ID",
|
||||
message: "Agent ID is required",
|
||||
details: "Usage: paseo agent inspect <id>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdArg)
|
||||
const fetchResult = await client.fetchAgent(agentIdArg);
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
const inspectData = toInspectData(fetchResult.agent)
|
||||
const inspectData = toInspectData(fetchResult.agent);
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: toInspectRows(inspectData),
|
||||
schema: createInspectSchema(inspectData),
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'INSPECT_FAILED',
|
||||
code: "INSPECT_FAILED",
|
||||
message: `Failed to inspect agent: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,155 +1,151 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions } from '../../output/index.js'
|
||||
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
|
||||
import type {
|
||||
DaemonClient,
|
||||
AgentStreamMessage,
|
||||
AgentTimelineItem,
|
||||
} from '@getpaseo/server'
|
||||
import { curateAgentActivity } from '@getpaseo/server'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
|
||||
import type { DaemonClient, AgentStreamMessage, AgentTimelineItem } from "@getpaseo/server";
|
||||
import { curateAgentActivity } from "@getpaseo/server";
|
||||
|
||||
export interface AgentLogsOptions extends CommandOptions {
|
||||
follow?: boolean
|
||||
tail?: string
|
||||
filter?: string
|
||||
since?: string
|
||||
follow?: boolean;
|
||||
tail?: string;
|
||||
filter?: string;
|
||||
since?: string;
|
||||
}
|
||||
|
||||
// Logs command returns void - it outputs directly to console
|
||||
export type AgentLogsResult = void
|
||||
export type AgentLogsResult = void;
|
||||
|
||||
export const NO_ACTIVITY_MESSAGE = 'No activity to display.'
|
||||
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
|
||||
|
||||
export async function fetchAgentTimelineItems(
|
||||
client: DaemonClient,
|
||||
agentId: string
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
return fetchProjectedTimelineItems({ client, agentId })
|
||||
return fetchProjectedTimelineItems({ client, agentId });
|
||||
}
|
||||
|
||||
export function formatAgentActivityTranscript(
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number
|
||||
tailCount?: number,
|
||||
): string {
|
||||
if (tailCount === 0) {
|
||||
return ''
|
||||
return "";
|
||||
}
|
||||
return curateAgentActivity(
|
||||
timelineItems,
|
||||
tailCount !== undefined ? { maxItems: tailCount } : undefined
|
||||
)
|
||||
tailCount !== undefined ? { maxItems: tailCount } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function parseTailCount(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
return parsed
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a timeline item matches the filter type
|
||||
*/
|
||||
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
|
||||
if (!filter) return true
|
||||
if (!filter) return true;
|
||||
|
||||
const filterLower = filter.toLowerCase()
|
||||
const type = item.type.toLowerCase()
|
||||
const filterLower = filter.toLowerCase();
|
||||
const type = item.type.toLowerCase();
|
||||
|
||||
switch (filterLower) {
|
||||
case 'tools':
|
||||
return type === 'tool_call'
|
||||
case 'text':
|
||||
return type === 'user_message' || type === 'assistant_message' || type === 'reasoning'
|
||||
case 'errors':
|
||||
return type === 'error'
|
||||
case 'permissions':
|
||||
case "tools":
|
||||
return type === "tool_call";
|
||||
case "text":
|
||||
return type === "user_message" || type === "assistant_message" || type === "reasoning";
|
||||
case "errors":
|
||||
return type === "error";
|
||||
case "permissions":
|
||||
// Permissions might be in tool_call status or a separate event type
|
||||
return type.includes('permission')
|
||||
return type.includes("permission");
|
||||
default:
|
||||
// If filter doesn't match predefined types, match against the actual type
|
||||
return type.includes(filterLower)
|
||||
return type.includes(filterLower);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLogsCommand(
|
||||
id: string,
|
||||
options: AgentLogsOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentLogsResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
if (!id) {
|
||||
console.error('Error: Agent ID required')
|
||||
console.error('Usage: paseo agent logs <id>')
|
||||
process.exit(1)
|
||||
console.error("Error: Agent ID required");
|
||||
console.error("Usage: paseo agent logs <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: DaemonClient
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} 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 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);
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(id)
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
if (!fetchResult) {
|
||||
console.error(`Error: No agent found matching: ${id}`)
|
||||
console.error('Use `paseo ls` to list available agents')
|
||||
await client.close()
|
||||
process.exit(1)
|
||||
console.error(`Error: No agent found matching: ${id}`);
|
||||
console.error("Use `paseo ls` to list available agents");
|
||||
await client.close();
|
||||
process.exit(1);
|
||||
}
|
||||
const resolvedId = fetchResult.agent.id
|
||||
const resolvedId = fetchResult.agent.id;
|
||||
|
||||
// For follow mode, we stream events continuously
|
||||
if (options.follow) {
|
||||
if (options.tail !== undefined && parseTailCount(options.tail) === undefined) {
|
||||
console.error(`Error: Invalid --tail value: ${options.tail}`)
|
||||
console.error('Usage: --tail <n> (where n is >= 0)')
|
||||
await client.close().catch(() => {})
|
||||
process.exit(1)
|
||||
console.error(`Error: Invalid --tail value: ${options.tail}`);
|
||||
console.error("Usage: --tail <n> (where n is >= 0)");
|
||||
await client.close().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
await runFollowMode(client, resolvedId, options)
|
||||
return
|
||||
await runFollowMode(client, resolvedId, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch timeline directly via cursor RPC.
|
||||
let timelineItems = await fetchAgentTimelineItems(client, resolvedId)
|
||||
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
|
||||
|
||||
// Apply filter
|
||||
if (options.filter) {
|
||||
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
|
||||
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
const tailCount = parseTailCount(options.tail)
|
||||
const tailCount = parseTailCount(options.tail);
|
||||
if (options.tail !== undefined && tailCount === undefined) {
|
||||
console.error(`Error: Invalid --tail value: ${options.tail}`)
|
||||
console.error('Usage: --tail <n> (where n is >= 0)')
|
||||
await client.close().catch(() => {})
|
||||
process.exit(1)
|
||||
console.error(`Error: Invalid --tail value: ${options.tail}`);
|
||||
console.error("Usage: --tail <n> (where n is >= 0)");
|
||||
await client.close().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
// Use curateAgentActivity to format the transcript
|
||||
if (tailCount === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const transcript = formatAgentActivityTranscript(timelineItems, tailCount)
|
||||
console.log(transcript)
|
||||
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
|
||||
console.log(transcript);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`Error: Failed to get logs: ${message}`)
|
||||
await client.close().catch(() => {})
|
||||
process.exit(1)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Failed to get logs: ${message}`);
|
||||
await client.close().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,60 +155,61 @@ export async function runLogsCommand(
|
||||
async function runFollowMode(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
options: AgentLogsOptions
|
||||
options: AgentLogsOptions,
|
||||
): Promise<void> {
|
||||
const DEFAULT_FOLLOW_TAIL = 10
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL
|
||||
const DEFAULT_FOLLOW_TAIL = 10;
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
// First, get existing timeline.
|
||||
let existingItems = await fetchAgentTimelineItems(client, agentId)
|
||||
let existingItems = await fetchAgentTimelineItems(client, agentId);
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
// Print existing transcript (tail-like behavior)
|
||||
if (tailCount > 0) {
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount)
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
||||
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(existingTranscript)
|
||||
console.log(existingTranscript);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
const tailLabel = tailCount === 0 ? 'no history' : `last ${tailCount} entr${tailCount === 1 ? 'y' : 'ies'}`
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`)
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
|
||||
const message = msg as AgentStreamMessage
|
||||
if (message.type !== 'agent_stream') return
|
||||
if (message.payload.agentId !== agentId) return
|
||||
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
|
||||
const message = msg as AgentStreamMessage;
|
||||
if (message.type !== "agent_stream") return;
|
||||
if (message.payload.agentId !== agentId) return;
|
||||
|
||||
if (message.payload.event.type === 'timeline') {
|
||||
const item = message.payload.event.item
|
||||
if (message.payload.event.type === "timeline") {
|
||||
const item = message.payload.event.item;
|
||||
// Apply filter
|
||||
if (options.filter && !matchesFilter(item, options.filter)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// Print each timeline item as it arrives using the curator format
|
||||
const transcript = formatAgentActivityTranscript([item])
|
||||
const transcript = formatAgentActivityTranscript([item]);
|
||||
if (transcript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(transcript)
|
||||
console.log(transcript);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Wait for interrupt
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
})
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
});
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -1,102 +1,102 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentSnapshotPayload } from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Agent list item for display */
|
||||
export interface AgentListItem {
|
||||
id: string
|
||||
shortId: string
|
||||
name: string
|
||||
provider: string
|
||||
thinking: string
|
||||
status: string
|
||||
cwd: string
|
||||
created: string
|
||||
id: string;
|
||||
shortId: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
thinking: string;
|
||||
status: string;
|
||||
cwd: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
/** Helper to get relative time string */
|
||||
function relativeTime(date: Date | string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(date).getTime()
|
||||
const seconds = Math.floor((now - then) / 1000)
|
||||
const now = Date.now();
|
||||
const then = new Date(date).getTime();
|
||||
const seconds = Math.floor((now - then) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
|
||||
return `${Math.floor(seconds / 86400)} days ago`
|
||||
if (seconds < 60) return "just now";
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
|
||||
return `${Math.floor(seconds / 86400)} days ago`;
|
||||
}
|
||||
|
||||
/** Shorten home directory in path */
|
||||
function shortenPath(path: string): string {
|
||||
const home = process.env.HOME
|
||||
const home = process.env.HOME;
|
||||
if (home && path.startsWith(home)) {
|
||||
return '~' + path.slice(home.length)
|
||||
return "~" + path.slice(home.length);
|
||||
}
|
||||
return path
|
||||
return path;
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
if (typeof modelId !== 'string') return null
|
||||
const normalized = modelId.trim()
|
||||
if (!normalized || normalized.toLowerCase() === 'default') return null
|
||||
return normalized
|
||||
if (typeof modelId !== "string") return null;
|
||||
const normalized = modelId.trim();
|
||||
if (!normalized || normalized.toLowerCase() === "default") return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Schema for agent ls output */
|
||||
export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
idField: 'shortId',
|
||||
idField: "shortId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'shortId', width: 12 },
|
||||
{ header: 'NAME', field: 'name', width: 20 },
|
||||
{ header: 'PROVIDER', field: 'provider', width: 15 },
|
||||
{ header: 'THINKING', field: 'thinking', width: 12 },
|
||||
{ header: "AGENT ID", field: "shortId", width: 12 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "PROVIDER", field: "provider", width: 15 },
|
||||
{ header: "THINKING", field: "thinking", width: 12 },
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'status',
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
width: 10,
|
||||
color: (value) => {
|
||||
if (value === 'running') return 'green'
|
||||
if (value === 'idle') return 'yellow'
|
||||
if (value === 'error') return 'red'
|
||||
return undefined
|
||||
if (value === "running") return "green";
|
||||
if (value === "idle") return "yellow";
|
||||
if (value === "error") return "red";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{ header: 'CWD', field: 'cwd', width: 30 },
|
||||
{ header: 'CREATED', field: 'created', width: 15 },
|
||||
{ header: "CWD", field: "cwd", width: 30 },
|
||||
{ header: "CREATED", field: "created", width: 15 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
/** Transform agent snapshot to AgentListItem */
|
||||
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model)
|
||||
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
|
||||
return {
|
||||
id: agent.id,
|
||||
shortId: agent.id.slice(0, 7),
|
||||
name: agent.title ?? '-',
|
||||
name: agent.title ?? "-",
|
||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||
thinking: agent.effectiveThinkingOptionId ?? 'auto',
|
||||
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentLsResult = ListResult<AgentListItem>
|
||||
export type AgentLsResult = ListResult<AgentListItem>;
|
||||
|
||||
export interface AgentLsOptions extends CommandOptions {
|
||||
/** -a: Include archived agents */
|
||||
all?: boolean
|
||||
all?: boolean;
|
||||
/** Legacy flag retained for CLI compatibility */
|
||||
global?: boolean
|
||||
global?: boolean;
|
||||
/** Filter by specific status */
|
||||
status?: string
|
||||
status?: string;
|
||||
/** Filter by specific cwd */
|
||||
cwd?: string
|
||||
cwd?: string;
|
||||
/** Filter by labels (key=value format) */
|
||||
label?: string[]
|
||||
label?: string[];
|
||||
/** Filter by thinking option ID */
|
||||
thinking?: string
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,128 +106,128 @@ export interface AgentLsOptions extends CommandOptions {
|
||||
*/
|
||||
export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentLsResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedThinkingOptionId = options.thinking?.trim()
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
if (options.thinking !== undefined && !normalizedThinkingOptionId) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_THINKING_OPTION',
|
||||
message: '--thinking cannot be empty',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_THINKING_OPTION",
|
||||
message: "--thinking cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse --label filters (key=value).
|
||||
const labelFilters: Record<string, string> = {}
|
||||
const labelFilters: Record<string, string> = {};
|
||||
if (options.label) {
|
||||
for (const labelStr of options.label) {
|
||||
const eqIndex = labelStr.indexOf('=')
|
||||
const eqIndex = labelStr.indexOf("=");
|
||||
if (eqIndex !== -1) {
|
||||
const key = labelStr.slice(0, eqIndex)
|
||||
const value = labelStr.slice(eqIndex + 1)
|
||||
labelFilters[key] = value
|
||||
const key = labelStr.slice(0, eqIndex);
|
||||
const value = labelStr.slice(eqIndex + 1);
|
||||
labelFilters[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const daemonFilter: {
|
||||
includeArchived?: boolean
|
||||
labels?: Record<string, string>
|
||||
thinkingOptionId?: string
|
||||
} = {}
|
||||
includeArchived?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
thinkingOptionId?: string;
|
||||
} = {};
|
||||
if (options.all) {
|
||||
daemonFilter.includeArchived = true
|
||||
daemonFilter.includeArchived = true;
|
||||
}
|
||||
if (Object.keys(labelFilters).length > 0) {
|
||||
daemonFilter.labels = labelFilters
|
||||
daemonFilter.labels = labelFilters;
|
||||
}
|
||||
if (normalizedThinkingOptionId) {
|
||||
daemonFilter.thinkingOptionId = normalizedThinkingOptionId
|
||||
daemonFilter.thinkingOptionId = normalizedThinkingOptionId;
|
||||
}
|
||||
|
||||
const fetchPayload = await client.fetchAgents({
|
||||
filter: Object.keys(daemonFilter).length > 0 ? daemonFilter : undefined,
|
||||
})
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||
});
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
|
||||
// By default, exclude archived agents. `-a` includes them.
|
||||
if (!options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt)
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
}
|
||||
|
||||
// If explicit status filter is provided, apply it.
|
||||
if (options.status) {
|
||||
agents = agents.filter((a) => a.status === options.status)
|
||||
agents = agents.filter((a) => a.status === options.status);
|
||||
}
|
||||
|
||||
// Optional cwd filter.
|
||||
if (options.cwd) {
|
||||
const targetCwd = options.cwd.replace(/\/$/, '')
|
||||
const targetCwd = options.cwd.replace(/\/$/, "");
|
||||
agents = agents.filter((a) => {
|
||||
const agentCwd = a.cwd.replace(/\/$/, '')
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
|
||||
})
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
});
|
||||
}
|
||||
|
||||
// Apply label filtering only when explicitly requested.
|
||||
if (Object.keys(labelFilters).length > 0) {
|
||||
agents = agents.filter((a) => {
|
||||
const agentLabels = a.labels
|
||||
const agentLabels = a.labels;
|
||||
for (const [key, value] of Object.entries(labelFilters)) {
|
||||
if (agentLabels[key] !== value) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
// Sort agents: running first, then idle, then others; within each group, most recent first
|
||||
const statusOrder = { running: 0, idle: 1 } as Record<string, number>
|
||||
const statusOrder = { running: 0, idle: 1 } as Record<string, number>;
|
||||
agents.sort((a, b) => {
|
||||
// Primary sort: by status
|
||||
const aOrder = statusOrder[a.status] ?? 999
|
||||
const bOrder = statusOrder[b.status] ?? 999
|
||||
if (aOrder !== bOrder) return aOrder - bOrder
|
||||
const aOrder = statusOrder[a.status] ?? 999;
|
||||
const bOrder = statusOrder[b.status] ?? 999;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
|
||||
// Secondary sort: by creation time (most recent first)
|
||||
const aTime = new Date(a.createdAt).getTime()
|
||||
const bTime = new Date(b.createdAt).getTime()
|
||||
return bTime - aTime
|
||||
})
|
||||
const aTime = new Date(a.createdAt).getTime();
|
||||
const bTime = new Date(b.createdAt).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
const items = agents.map(toListItem)
|
||||
const items = agents.map(toListItem);
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: agentLsSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'LIST_AGENTS_FAILED',
|
||||
code: "LIST_AGENTS_FAILED",
|
||||
message: `Failed to list agents: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +1,139 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
AnyCommandResult,
|
||||
} from '../../output/index.js'
|
||||
import type { AgentMode } from '@getpaseo/server'
|
||||
} from "../../output/index.js";
|
||||
import type { AgentMode } from "@getpaseo/server";
|
||||
|
||||
/** Result for setting mode */
|
||||
export interface SetModeResult {
|
||||
agentId: string
|
||||
mode: string
|
||||
agentId: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/** Schema for mode list output */
|
||||
export const modeListSchema: OutputSchema<AgentMode> = {
|
||||
idField: 'id',
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: 'MODE', field: 'id', width: 15 },
|
||||
{ header: 'LABEL', field: 'label', width: 25 },
|
||||
{ header: 'DESCRIPTION', field: 'description', width: 40 },
|
||||
{ header: "MODE", field: "id", width: 15 },
|
||||
{ header: "LABEL", field: "label", width: 25 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 40 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
/** Schema for set mode output */
|
||||
export const setModeSchema: OutputSchema<SetModeResult> = {
|
||||
idField: 'agentId',
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||
{ header: 'MODE', field: 'mode', width: 20 },
|
||||
{ header: "AGENT ID", field: "agentId", width: 12 },
|
||||
{ header: "MODE", field: "mode", width: 20 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface AgentModeOptions extends CommandOptions {
|
||||
list?: boolean
|
||||
list?: boolean;
|
||||
}
|
||||
|
||||
const missingModeError = (): CommandError => ({
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Mode argument required unless --list is specified',
|
||||
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
|
||||
})
|
||||
code: "MISSING_ARGUMENT",
|
||||
message: "Mode argument required unless --list is specified",
|
||||
details: "Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>",
|
||||
});
|
||||
|
||||
// This command returns two different data shapes (set result vs mode list).
|
||||
// Keep `any` here to match the existing output wrapper generic contract.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AgentModeResult = AnyCommandResult<any>
|
||||
export type AgentModeResult = AnyCommandResult<any>;
|
||||
|
||||
export async function runModeCommand(
|
||||
id: string,
|
||||
mode: string | undefined,
|
||||
options: AgentModeOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentModeResult> {
|
||||
const normalizedMode = mode?.trim()
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const normalizedMode = mode?.trim();
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!options.list && !normalizedMode) {
|
||||
throw missingModeError()
|
||||
throw missingModeError();
|
||||
}
|
||||
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
const fetchResult = await client.fetchAgent(id)
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `No agent found matching: ${id}`,
|
||||
details: 'Use `paseo ls` to list available agents',
|
||||
}
|
||||
throw error
|
||||
details: "Use `paseo ls` to list available agents",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = fetchResult.agent
|
||||
const resolvedId = agent.id
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedId = agent.id;
|
||||
|
||||
if (options.list) {
|
||||
// List available modes for this agent
|
||||
const availableModes = agent.availableModes ?? []
|
||||
const availableModes = agent.availableModes ?? [];
|
||||
|
||||
const items: AgentMode[] = availableModes.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
description: m.description,
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: modeListSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalizedMode) {
|
||||
throw missingModeError()
|
||||
throw missingModeError();
|
||||
}
|
||||
|
||||
// Set the agent mode
|
||||
await client.setAgentMode(resolvedId, normalizedMode)
|
||||
await client.setAgentMode(resolvedId, normalizedMode);
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedId.slice(0, 7),
|
||||
mode: normalizedMode,
|
||||
},
|
||||
schema: setModeSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
// Re-throw if it's already a CommandError
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'MODE_OPERATION_FAILED',
|
||||
message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`,
|
||||
}
|
||||
throw error
|
||||
code: "MODE_OPERATION_FAILED",
|
||||
message: `Failed to ${options.list ? "list modes" : "set mode"}: ${message}`,
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,331 +1,338 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
type AgentSnapshotPayload,
|
||||
} from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { lookup } from 'mime-types'
|
||||
import { parseDuration } from '../../utils/duration.js'
|
||||
} from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { lookup } from "mime-types";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
|
||||
/** Result type for agent run command */
|
||||
export interface AgentRunResult {
|
||||
agentId: string
|
||||
status: 'created' | 'running' | 'completed' | 'timeout' | 'permission' | 'error'
|
||||
provider: string
|
||||
cwd: string
|
||||
title: string | null
|
||||
agentId: string;
|
||||
status: "created" | "running" | "completed" | "timeout" | "permission" | "error";
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** Schema for agent run output */
|
||||
export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
||||
idField: 'agentId',
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||
{ header: 'STATUS', field: 'status', width: 10 },
|
||||
{ header: 'PROVIDER', field: 'provider', width: 10 },
|
||||
{ header: 'CWD', field: 'cwd', width: 30 },
|
||||
{ header: 'TITLE', field: 'title', width: 20 },
|
||||
{ header: "AGENT ID", field: "agentId", width: 12 },
|
||||
{ header: "STATUS", field: "status", width: 10 },
|
||||
{ header: "PROVIDER", field: "provider", width: 10 },
|
||||
{ header: "CWD", field: "cwd", width: 30 },
|
||||
{ header: "TITLE", field: "title", width: 20 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface AgentRunOptions extends CommandOptions {
|
||||
detach?: boolean
|
||||
name?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
thinking?: string
|
||||
mode?: string
|
||||
worktree?: string
|
||||
base?: string
|
||||
image?: string[]
|
||||
cwd?: string
|
||||
label?: string[]
|
||||
waitTimeout?: string
|
||||
outputSchema?: string
|
||||
detach?: boolean;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
mode?: string;
|
||||
worktree?: string;
|
||||
base?: string;
|
||||
image?: string[];
|
||||
cwd?: string;
|
||||
label?: string[];
|
||||
waitTimeout?: string;
|
||||
outputSchema?: string;
|
||||
}
|
||||
|
||||
interface ResolvedProviderModel {
|
||||
provider: string
|
||||
model: string | undefined
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
function toRunResult(
|
||||
agent: AgentSnapshotPayload,
|
||||
statusOverride?: AgentRunResult['status']
|
||||
statusOverride?: AgentRunResult["status"],
|
||||
): AgentRunResult {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
|
||||
status: statusOverride ?? (agent.status === "running" ? "running" : "created"),
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadOutputSchema(value: string): Record<string, unknown> {
|
||||
const trimmed = value.trim()
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: '--output-schema cannot be empty',
|
||||
details: 'Provide a JSON schema file path or inline JSON object',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_OUTPUT_SCHEMA",
|
||||
message: "--output-schema cannot be empty",
|
||||
details: "Provide a JSON schema file path or inline JSON object",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let source = trimmed
|
||||
if (!trimmed.startsWith('{')) {
|
||||
let source = trimmed;
|
||||
if (!trimmed.startsWith("{")) {
|
||||
try {
|
||||
source = readFileSync(resolve(trimmed), 'utf8')
|
||||
source = readFileSync(resolve(trimmed), "utf8");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
code: "INVALID_OUTPUT_SCHEMA",
|
||||
message: `Failed to read output schema file: ${trimmed}`,
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(source)
|
||||
parsed = JSON.parse(source);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: 'Failed to parse output schema JSON',
|
||||
code: "INVALID_OUTPUT_SCHEMA",
|
||||
message: "Failed to parse output schema JSON",
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OUTPUT_SCHEMA',
|
||||
message: 'Output schema must be a JSON object',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_OUTPUT_SCHEMA",
|
||||
message: "Output schema must be a JSON object",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
class StructuredRunStatusError extends Error {
|
||||
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
|
||||
readonly kind: "timeout" | "permission" | "error" | "empty";
|
||||
|
||||
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
|
||||
super(message)
|
||||
this.name = 'StructuredRunStatusError'
|
||||
this.kind = kind
|
||||
constructor(kind: "timeout" | "permission" | "error" | "empty", message: string) {
|
||||
super(message);
|
||||
this.name = "StructuredRunStatusError";
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
|
||||
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>;
|
||||
|
||||
export interface StructuredResponseTimelineClient {
|
||||
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
|
||||
fetchAgentTimeline: ConnectedDaemonClient["fetchAgentTimeline"];
|
||||
}
|
||||
|
||||
export async function resolveStructuredResponseMessage(options: {
|
||||
client: StructuredResponseTimelineClient
|
||||
agentId: string
|
||||
lastMessage: string | null
|
||||
client: StructuredResponseTimelineClient;
|
||||
agentId: string;
|
||||
lastMessage: string | null;
|
||||
}): Promise<string | null> {
|
||||
const direct = options.lastMessage?.trim()
|
||||
const direct = options.lastMessage?.trim();
|
||||
if (direct) {
|
||||
return direct
|
||||
return direct;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||
direction: 'tail',
|
||||
projection: 'projected',
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 200,
|
||||
})
|
||||
});
|
||||
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = timeline.entries[index]
|
||||
if (!entry || entry.item.type !== 'assistant_message') {
|
||||
continue
|
||||
const entry = timeline.entries[index];
|
||||
if (!entry || entry.item.type !== "assistant_message") {
|
||||
continue;
|
||||
}
|
||||
const text = entry.item.text.trim()
|
||||
const text = entry.item.text.trim();
|
||||
if (text.length > 0) {
|
||||
return text
|
||||
return text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Leave empty; caller will surface a consistent structured-output failure message.
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
||||
return {
|
||||
...agentRunSchema,
|
||||
serialize: () => output,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(options: Pick<AgentRunOptions, 'provider' | 'model'>): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || 'claude'
|
||||
const modelInput = options.model?.trim()
|
||||
export function resolveProviderAndModel(
|
||||
options: Pick<AgentRunOptions, "provider" | "model">,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_MODEL',
|
||||
message: '--model cannot be empty',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf('/')
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim()
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim()
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_PROVIDER',
|
||||
message: 'Invalid --provider value',
|
||||
details: 'Use --provider <provider> or --provider <provider>/<model>',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: 'CONFLICTING_MODEL_OPTIONS',
|
||||
message: 'Conflicting model values provided',
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
prompt: string,
|
||||
options: AgentRunOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentRunResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined
|
||||
let waitTimeoutMs = 0
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
|
||||
let waitTimeoutMs = 0;
|
||||
|
||||
// Validate prompt is provided
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_PROMPT',
|
||||
message: 'A prompt is required',
|
||||
details: 'Usage: paseo agent run [options] <prompt>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_PROMPT",
|
||||
message: "A prompt is required",
|
||||
details: "Usage: paseo agent run [options] <prompt>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate --base is only used with --worktree
|
||||
if (options.base && !options.worktree) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OPTIONS',
|
||||
message: '--base can only be used with --worktree',
|
||||
details: 'Usage: paseo agent run --worktree <name> --base <branch> <prompt>',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--base can only be used with --worktree",
|
||||
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// --output-schema always runs in attached/wait mode
|
||||
if (outputSchema && options.detach) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OPTIONS',
|
||||
message: '--output-schema cannot be used with --detach',
|
||||
details: 'Structured output requires waiting for the agent to finish',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--output-schema cannot be used with --detach",
|
||||
details: "Structured output requires waiting for the agent to finish",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (options.waitTimeout) {
|
||||
try {
|
||||
waitTimeoutMs = parseDuration(options.waitTimeout)
|
||||
waitTimeoutMs = parseDuration(options.waitTimeout);
|
||||
if (waitTimeoutMs <= 0) {
|
||||
throw new Error('Timeout must be positive')
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_TIMEOUT',
|
||||
message: 'Invalid wait timeout value',
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid wait timeout value",
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedProviderModel = resolveProviderAndModel(options)
|
||||
const resolvedProviderModel = resolveProviderAndModel(options);
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve working directory
|
||||
const cwd = options.cwd ?? process.cwd()
|
||||
const thinkingOptionId = options.thinking?.trim()
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const thinkingOptionId = options.thinking?.trim();
|
||||
if (options.thinking !== undefined && !thinkingOptionId) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_THINKING_OPTION',
|
||||
message: '--thinking cannot be empty',
|
||||
code: "INVALID_THINKING_OPTION",
|
||||
message: "--thinking cannot be empty",
|
||||
details:
|
||||
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Process images if provided
|
||||
let images: Array<{ data: string; mimeType: string }> | undefined
|
||||
let images: Array<{ data: string; mimeType: string }> | undefined;
|
||||
if (options.image && options.image.length > 0) {
|
||||
images = options.image.map((imagePath) => {
|
||||
const resolvedPath = resolve(imagePath)
|
||||
const resolvedPath = resolve(imagePath);
|
||||
try {
|
||||
const imageData = readFileSync(resolvedPath)
|
||||
const mimeType = lookup(resolvedPath) || 'application/octet-stream'
|
||||
const imageData = readFileSync(resolvedPath);
|
||||
const mimeType = lookup(resolvedPath) || "application/octet-stream";
|
||||
|
||||
// Verify it's an image MIME type
|
||||
if (!mimeType.startsWith('image/')) {
|
||||
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`)
|
||||
if (!mimeType.startsWith("image/")) {
|
||||
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: imageData.toString('base64'),
|
||||
data: imageData.toString("base64"),
|
||||
mimeType,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Failed to read image ${imagePath}: ${message}`)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read image ${imagePath}: ${message}`);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Build git options if worktree is specified
|
||||
@@ -335,34 +342,34 @@ export async function runRunCommand(
|
||||
worktreeSlug: options.worktree,
|
||||
baseBranch: options.base,
|
||||
}
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
// Build labels from --label flags
|
||||
const labels: Record<string, string> = {}
|
||||
const labels: Record<string, string> = {};
|
||||
if (options.label) {
|
||||
for (const labelStr of options.label) {
|
||||
const eqIndex = labelStr.indexOf('=')
|
||||
const eqIndex = labelStr.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_LABEL',
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${labelStr}`,
|
||||
details: 'Labels must be in key=value format',
|
||||
}
|
||||
throw error
|
||||
details: "Labels must be in key=value format",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const key = labelStr.slice(0, eqIndex)
|
||||
const value = labelStr.slice(eqIndex + 1)
|
||||
labels[key] = value
|
||||
const key = labelStr.slice(0, eqIndex);
|
||||
const value = labelStr.slice(eqIndex + 1);
|
||||
labels[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (outputSchema) {
|
||||
let structuredAgent: AgentSnapshotPayload | null = null
|
||||
let structuredAgent: AgentSnapshotPayload | null = null;
|
||||
|
||||
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
||||
if (!structuredAgent) {
|
||||
structuredAgent = await client.createAgent({
|
||||
provider: resolvedProviderModel.provider as 'claude' | 'codex' | 'opencode',
|
||||
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
|
||||
cwd,
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
@@ -374,94 +381,94 @@ export async function runRunCommand(
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
await client.sendMessage(structuredAgent.id, structuredPrompt)
|
||||
await client.sendMessage(structuredAgent.id, structuredPrompt);
|
||||
}
|
||||
|
||||
const state = await client.waitForFinish(structuredAgent.id, waitTimeoutMs)
|
||||
if (state.status === 'timeout') {
|
||||
throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output')
|
||||
const state = await client.waitForFinish(structuredAgent.id, waitTimeoutMs);
|
||||
if (state.status === "timeout") {
|
||||
throw new StructuredRunStatusError("timeout", "Timed out waiting for structured output");
|
||||
}
|
||||
if (state.status === 'permission') {
|
||||
if (state.status === "permission") {
|
||||
throw new StructuredRunStatusError(
|
||||
'permission',
|
||||
'Agent is waiting for permission before producing structured output'
|
||||
)
|
||||
"permission",
|
||||
"Agent is waiting for permission before producing structured output",
|
||||
);
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
if (state.status === "error") {
|
||||
throw new StructuredRunStatusError(
|
||||
'error',
|
||||
state.error ?? 'Agent failed before producing structured output'
|
||||
)
|
||||
"error",
|
||||
state.error ?? "Agent failed before producing structured output",
|
||||
);
|
||||
}
|
||||
|
||||
const lastMessage = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: structuredAgent.id,
|
||||
lastMessage: state.lastMessage,
|
||||
})
|
||||
});
|
||||
if (!lastMessage) {
|
||||
throw new StructuredRunStatusError(
|
||||
'empty',
|
||||
'Agent finished without a structured output message'
|
||||
)
|
||||
"empty",
|
||||
"Agent finished without a structured output message",
|
||||
);
|
||||
}
|
||||
|
||||
return lastMessage
|
||||
}
|
||||
return lastMessage;
|
||||
};
|
||||
|
||||
let output: Record<string, unknown>
|
||||
let output: Record<string, unknown>;
|
||||
try {
|
||||
output = await getStructuredAgentResponse<Record<string, unknown>>({
|
||||
caller: callStructuredTurn,
|
||||
prompt,
|
||||
schema: outputSchema,
|
||||
schemaName: 'RunOutput',
|
||||
schemaName: "RunOutput",
|
||||
maxRetries: 2,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StructuredRunStatusError) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: err.message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
if (err instanceof StructuredAgentResponseError) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response did not match the required output schema',
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: "Agent response did not match the required output schema",
|
||||
details:
|
||||
err.validationErrors.length > 0
|
||||
? err.validationErrors.join('\n')
|
||||
: err.lastResponse || 'No response',
|
||||
}
|
||||
throw error
|
||||
? err.validationErrors.join("\n")
|
||||
: err.lastResponse || "No response",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
throw err
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!structuredAgent) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent finished without a structured output message',
|
||||
}
|
||||
throw error
|
||||
code: "OUTPUT_SCHEMA_FAILED",
|
||||
message: "Agent finished without a structured output message",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(structuredAgent, 'completed'),
|
||||
type: "single",
|
||||
data: toRunResult(structuredAgent, "completed"),
|
||||
schema: structuredRunSchema(output),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create the agent
|
||||
const agent = await client.createAgent({
|
||||
provider: resolvedProviderModel.provider as 'claude' | 'codex' | 'opencode',
|
||||
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
|
||||
cwd,
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
@@ -472,43 +479,42 @@ export async function runRunCommand(
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
});
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||
if (!options.detach) {
|
||||
const state = await client.waitForFinish(agent.id, waitTimeoutMs)
|
||||
await client.close()
|
||||
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
||||
await client.close();
|
||||
|
||||
const finalAgent = state.final ?? agent
|
||||
const status: AgentRunResult['status'] =
|
||||
state.status === 'idle' ? 'completed' : state.status
|
||||
const finalAgent = state.final ?? agent;
|
||||
const status: AgentRunResult["status"] = state.status === "idle" ? "completed" : state.status;
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: toRunResult(finalAgent, status),
|
||||
schema: agentRunSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: toRunResult(agent),
|
||||
schema: agentRunSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_CREATE_FAILED',
|
||||
code: "AGENT_CREATE_FAILED",
|
||||
message: `Failed to create agent: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,137 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { extname, resolve } from 'node:path'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
|
||||
/** Result type for agent send command */
|
||||
export interface AgentSendResult {
|
||||
agentId: string
|
||||
status: 'sent' | 'completed' | 'timeout' | 'permission' | 'error'
|
||||
message: string
|
||||
agentId: string;
|
||||
status: "sent" | "completed" | "timeout" | "permission" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Schema for agent send output */
|
||||
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
idField: 'agentId',
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||
{ header: 'STATUS', field: 'status', width: 12 },
|
||||
{ header: 'MESSAGE', field: 'message', width: 40 },
|
||||
{ header: "AGENT ID", field: "agentId", width: 12 },
|
||||
{ header: "STATUS", field: "status", width: 12 },
|
||||
{ header: "MESSAGE", field: "message", width: 40 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface AgentSendOptions extends CommandOptions {
|
||||
noWait?: boolean
|
||||
image?: string[]
|
||||
prompt?: string
|
||||
promptFile?: string
|
||||
noWait?: boolean;
|
||||
image?: string[];
|
||||
prompt?: string;
|
||||
promptFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image files and convert them to base64 data URIs
|
||||
*/
|
||||
async function readImageFiles(imagePaths: string[]): Promise<Array<{ data: string; mimeType: string }>> {
|
||||
const images: Array<{ data: string; mimeType: string }> = []
|
||||
async function readImageFiles(
|
||||
imagePaths: string[],
|
||||
): Promise<Array<{ data: string; mimeType: string }>> {
|
||||
const images: Array<{ data: string; mimeType: string }> = [];
|
||||
|
||||
for (const path of imagePaths) {
|
||||
try {
|
||||
const buffer = await readFile(path)
|
||||
const ext = extname(path).toLowerCase()
|
||||
const buffer = await readFile(path);
|
||||
const ext = extname(path).toLowerCase();
|
||||
|
||||
// Determine media type from extension
|
||||
let mimeType = 'image/jpeg'
|
||||
let mimeType = "image/jpeg";
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
mimeType = 'image/png'
|
||||
break
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
mimeType = 'image/jpeg'
|
||||
break
|
||||
case '.gif':
|
||||
mimeType = 'image/gif'
|
||||
break
|
||||
case '.webp':
|
||||
mimeType = 'image/webp'
|
||||
break
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case ".gif":
|
||||
mimeType = "image/gif";
|
||||
break;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
default:
|
||||
// Default to jpeg for unknown types
|
||||
mimeType = 'image/jpeg'
|
||||
mimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
const data = buffer.toString('base64')
|
||||
const data = buffer.toString("base64");
|
||||
images.push({
|
||||
data,
|
||||
mimeType,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'IMAGE_READ_ERROR',
|
||||
code: "IMAGE_READ_ERROR",
|
||||
message: `Failed to read image file: ${path}`,
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
return images;
|
||||
}
|
||||
|
||||
async function resolvePromptInput(options: {
|
||||
promptArgument: string | undefined
|
||||
promptOption: string | undefined
|
||||
promptFile: string | undefined
|
||||
promptArgument: string | undefined;
|
||||
promptOption: string | undefined;
|
||||
promptFile: string | undefined;
|
||||
}): Promise<string> {
|
||||
const promptText = options.promptArgument?.trim()
|
||||
const promptOptionText = options.promptOption?.trim()
|
||||
const promptFilePath = options.promptFile?.trim()
|
||||
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length
|
||||
const promptText = options.promptArgument?.trim();
|
||||
const promptOptionText = options.promptOption?.trim();
|
||||
const promptFilePath = options.promptFile?.trim();
|
||||
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length;
|
||||
|
||||
if (providedSourceCount > 1) {
|
||||
const error: CommandError = {
|
||||
code: 'CONFLICTING_PROMPT_INPUT',
|
||||
message: 'Provide exactly one of prompt argument, --prompt, or --prompt-file',
|
||||
}
|
||||
throw error
|
||||
code: "CONFLICTING_PROMPT_INPUT",
|
||||
message: "Provide exactly one of prompt argument, --prompt, or --prompt-file",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (promptText) {
|
||||
return options.promptArgument as string
|
||||
return options.promptArgument as string;
|
||||
}
|
||||
|
||||
if (promptOptionText) {
|
||||
return options.promptOption as string
|
||||
return options.promptOption as string;
|
||||
}
|
||||
|
||||
if (!promptFilePath) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_PROMPT',
|
||||
message: 'A prompt is required',
|
||||
details: 'Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_PROMPT",
|
||||
message: "A prompt is required",
|
||||
details:
|
||||
"Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return await readFile(resolve(promptFilePath), 'utf8')
|
||||
return await readFile(resolve(promptFilePath), "utf8");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'PROMPT_FILE_READ_ERROR',
|
||||
code: "PROMPT_FILE_READ_ERROR",
|
||||
message: `Failed to read prompt file: ${promptFilePath}`,
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,126 +139,125 @@ export async function runSendCommand(
|
||||
agentIdArg: string,
|
||||
prompt: string | undefined,
|
||||
options: AgentSendOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentSendResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent send [options] <id> [prompt]',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_AGENT_ID",
|
||||
message: "Agent ID is required",
|
||||
details: "Usage: paseo agent send [options] <id> [prompt]",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const promptInput = await resolvePromptInput({
|
||||
promptArgument: prompt,
|
||||
promptOption: options.prompt,
|
||||
promptFile: options.promptFile,
|
||||
})
|
||||
});
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read image files if provided
|
||||
const images = options.image && options.image.length > 0
|
||||
? await readImageFiles(options.image)
|
||||
: undefined
|
||||
const images =
|
||||
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
|
||||
|
||||
// Send the message
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, { images })
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
||||
|
||||
// If --no-wait, return immediately
|
||||
if (options.noWait) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: agentIdArg,
|
||||
status: 'sent',
|
||||
message: 'Message sent, not waiting for completion',
|
||||
status: "sent",
|
||||
message: "Message sent, not waiting for completion",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for agent to finish
|
||||
const state = await client.waitForFinish(agentIdArg, 600000) // 10 minute timeout
|
||||
const state = await client.waitForFinish(agentIdArg, 600000); // 10 minute timeout
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
if (state.status === 'timeout') {
|
||||
if (state.status === "timeout") {
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: 'timeout',
|
||||
message: 'Timed out waiting for agent to finish',
|
||||
status: "timeout",
|
||||
message: "Timed out waiting for agent to finish",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'permission') {
|
||||
if (state.status === "permission") {
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: 'permission',
|
||||
message: 'Agent is waiting for permission',
|
||||
status: "permission",
|
||||
message: "Agent is waiting for permission",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
if (state.status === "error") {
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: 'error',
|
||||
message: state.error ?? 'Agent finished with error',
|
||||
status: "error",
|
||||
message: state.error ?? "Agent finished with error",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: state.final?.id ?? agentIdArg,
|
||||
status: 'completed',
|
||||
message: 'Agent completed processing the message',
|
||||
status: "completed",
|
||||
message: "Agent completed processing the message",
|
||||
},
|
||||
schema: agentSendSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'SEND_FAILED',
|
||||
code: "SEND_FAILED",
|
||||
message: `Failed to send message: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,128 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
/** Result type for agent stop command */
|
||||
export interface StopResult {
|
||||
stoppedCount: number
|
||||
agentIds: string[]
|
||||
stoppedCount: number;
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
/** Schema for stop command output */
|
||||
export const stopSchema: OutputSchema<StopResult> = {
|
||||
// For quiet mode, output the stopped agent IDs (one per line)
|
||||
idField: (item) => item.agentIds.join('\n'),
|
||||
columns: [{ header: 'INTERRUPTED', field: 'stoppedCount' }],
|
||||
}
|
||||
idField: (item) => item.agentIds.join("\n"),
|
||||
columns: [{ header: "INTERRUPTED", field: "stoppedCount" }],
|
||||
};
|
||||
|
||||
export interface AgentStopOptions extends CommandOptions {
|
||||
all?: boolean
|
||||
cwd?: string
|
||||
all?: boolean;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export type AgentStopResult = SingleResult<StopResult>
|
||||
export type AgentStopResult = SingleResult<StopResult>;
|
||||
|
||||
export async function runStopCommand(
|
||||
id: string | undefined,
|
||||
options: AgentStopOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentStopResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments - need either an id, --all, or --cwd
|
||||
if (!id && !options.all && !options.cwd) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Agent ID required unless --all or --cwd is specified',
|
||||
details: 'Usage: paseo agent stop <id> | --all | --cwd <path>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_ARGUMENT",
|
||||
message: "Agent ID required unless --all or --cwd is specified",
|
||||
details: "Usage: paseo agent stop <id> | --all | --cwd <path>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||
const stoppedIds: string[] = []
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
const stoppedIds: string[] = [];
|
||||
|
||||
if (options.all) {
|
||||
// Stop all agents (not archived)
|
||||
agents = agents.filter((a) => !a.archivedAt)
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
// Stop agents in directory
|
||||
const filterCwd = options.cwd
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false
|
||||
const agentCwd = a.cwd.replace(/\/$/, '')
|
||||
const targetCwd = filterCwd.replace(/\/$/, '')
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
|
||||
})
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
});
|
||||
} else if (id) {
|
||||
// Stop specific agent
|
||||
const fetchResult = await client.fetchAgent(id)
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `No agent found matching: ${id}`,
|
||||
details: 'Use `paseo ls` to list available agents',
|
||||
}
|
||||
throw error
|
||||
details: "Use `paseo ls` to list available agents",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
agents = [fetchResult.agent]
|
||||
agents = [fetchResult.agent];
|
||||
}
|
||||
|
||||
// Interrupt each running agent. Idle agents are a no-op.
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
if (agent.status === 'running') {
|
||||
await client.cancelAgent(agent.id)
|
||||
stoppedIds.push(agent.id)
|
||||
if (agent.status === "running") {
|
||||
await client.cancelAgent(agent.id);
|
||||
stoppedIds.push(agent.id);
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue interrupting other agents even if one fails
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
stoppedCount: stoppedIds.length,
|
||||
agentIds: stoppedIds,
|
||||
},
|
||||
schema: stopSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw if it's already a CommandError
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'STOP_AGENT_FAILED',
|
||||
code: "STOP_AGENT_FAILED",
|
||||
message: `Failed to stop agent(s): ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,177 +1,182 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
/** Result type for agent update command */
|
||||
export interface AgentUpdateResult {
|
||||
agentId: string
|
||||
name: string | null
|
||||
labels: string
|
||||
agentId: string;
|
||||
name: string | null;
|
||||
labels: string;
|
||||
}
|
||||
|
||||
/** Schema for update command output */
|
||||
export const updateSchema: OutputSchema<AgentUpdateResult> = {
|
||||
idField: 'agentId',
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId' },
|
||||
{ header: 'NAME', field: 'name' },
|
||||
{ header: 'LABELS', field: 'labels' },
|
||||
{ header: "AGENT ID", field: "agentId" },
|
||||
{ header: "NAME", field: "name" },
|
||||
{ header: "LABELS", field: "labels" },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface AgentUpdateOptions extends CommandOptions {
|
||||
name?: string
|
||||
label?: string[]
|
||||
host?: string
|
||||
name?: string;
|
||||
label?: string[];
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
|
||||
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
|
||||
|
||||
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
|
||||
const parsed: Record<string, string> = {}
|
||||
const parsed: Record<string, string> = {};
|
||||
if (!labels) {
|
||||
return parsed
|
||||
return parsed;
|
||||
}
|
||||
|
||||
for (const rawLabel of labels) {
|
||||
for (const segment of rawLabel.split(',')) {
|
||||
const label = segment.trim()
|
||||
for (const segment of rawLabel.split(",")) {
|
||||
const label = segment.trim();
|
||||
if (!label) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = label.indexOf('=')
|
||||
const eqIndex = label.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_LABEL',
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${label}`,
|
||||
details: 'Labels must be in key=value format',
|
||||
}
|
||||
throw error
|
||||
details: "Labels must be in key=value format",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const key = label.slice(0, eqIndex).trim()
|
||||
const value = label.slice(eqIndex + 1)
|
||||
const key = label.slice(0, eqIndex).trim();
|
||||
const value = label.slice(eqIndex + 1);
|
||||
if (!key) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_LABEL',
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${label}`,
|
||||
details: 'Labels must include a non-empty key in key=value format',
|
||||
}
|
||||
throw error
|
||||
details: "Labels must include a non-empty key in key=value format",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
parsed[key] = value
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatLabels(labels: Record<string, string>): string {
|
||||
const entries = Object.entries(labels)
|
||||
const entries = Object.entries(labels);
|
||||
if (entries.length === 0) {
|
||||
return '-'
|
||||
return "-";
|
||||
}
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join(',')
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join(",");
|
||||
}
|
||||
|
||||
export async function runUpdateCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentUpdateOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<AgentUpdateCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_AGENT_ID",
|
||||
message: "Agent ID is required",
|
||||
details: "Usage: paseo agent update <id> [--name <name>] [--label <key=value>]",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const name = options.name?.trim()
|
||||
const name = options.name?.trim();
|
||||
if (options.name !== undefined && !name) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_NAME',
|
||||
message: 'Name cannot be empty',
|
||||
details: 'Use --name <name> with a non-empty value',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_NAME",
|
||||
message: "Name cannot be empty",
|
||||
details: "Use --name <name> with a non-empty value",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const labels = parseLabelOptions(options.label)
|
||||
const labels = parseLabelOptions(options.label);
|
||||
if (!name && Object.keys(labels).length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'NO_CHANGES_PROVIDED',
|
||||
message: 'Nothing to update',
|
||||
details: 'Provide at least one of: --name <name>, --label <key=value>',
|
||||
}
|
||||
throw error
|
||||
code: "NO_CHANGES_PROVIDED",
|
||||
message: "Nothing to update",
|
||||
details: "Provide at least one of: --name <name>, --label <key=value>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdArg)
|
||||
const fetchResult = await client.fetchAgent(agentIdArg);
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdArg}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agentId = fetchResult.agent.id
|
||||
const agentId = fetchResult.agent.id;
|
||||
|
||||
await client.updateAgent(agentId, {
|
||||
...(name ? { name } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
})
|
||||
});
|
||||
|
||||
const updatedResult = await client.fetchAgent(agentId)
|
||||
const updatedResult = await client.fetchAgent(agentId);
|
||||
if (!updatedResult) {
|
||||
throw new Error(`Agent not found after update: ${agentId}`)
|
||||
throw new Error(`Agent not found after update: ${agentId}`);
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId,
|
||||
name: updatedResult.agent.title,
|
||||
labels: formatLabels(updatedResult.agent.labels),
|
||||
},
|
||||
schema: updateSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'UPDATE_FAILED',
|
||||
code: "UPDATE_FAILED",
|
||||
message: `Failed to update agent: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,193 +1,198 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import { fetchAgentTimelineItems, formatAgentActivityTranscript } from './logs.js'
|
||||
import { parseDuration } from '../../utils/duration.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
import { fetchAgentTimelineItems, formatAgentActivityTranscript } from "./logs.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
|
||||
/** Result type for agent wait command */
|
||||
export interface AgentWaitResult {
|
||||
agentId: string
|
||||
status: 'idle' | 'timeout' | 'permission' | 'error'
|
||||
message: string
|
||||
agentId: string;
|
||||
status: "idle" | "timeout" | "permission" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Schema for agent wait output */
|
||||
export const agentWaitSchema: OutputSchema<AgentWaitResult> = {
|
||||
idField: 'agentId',
|
||||
idField: "agentId",
|
||||
columns: [
|
||||
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||
{ header: 'STATUS', field: 'status', width: 12 },
|
||||
{ header: 'MESSAGE', field: 'message', width: 40 },
|
||||
{ header: "AGENT ID", field: "agentId", width: 12 },
|
||||
{ header: "STATUS", field: "status", width: 12 },
|
||||
{ header: "MESSAGE", field: "message", width: 40 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface AgentWaitOptions extends CommandOptions {
|
||||
timeout?: string
|
||||
host?: string
|
||||
timeout?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
const WAIT_ACTIVITY_PREVIEW_COUNT = 5
|
||||
const WAIT_ACTIVITY_PREVIEW_COUNT = 5;
|
||||
|
||||
function appendRecentActivity(message: string, transcript: string | null): string {
|
||||
if (!transcript || transcript.trim().length === 0) {
|
||||
return message
|
||||
return message;
|
||||
}
|
||||
|
||||
return `${message}\nLast ${WAIT_ACTIVITY_PREVIEW_COUNT} activity items:\n${transcript}`
|
||||
return `${message}\nLast ${WAIT_ACTIVITY_PREVIEW_COUNT} activity items:\n${transcript}`;
|
||||
}
|
||||
|
||||
async function getRecentActivityTranscript(
|
||||
client: Awaited<ReturnType<typeof connectToDaemon>>,
|
||||
agentId: string
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const timelineItems = await fetchAgentTimelineItems(client, agentId)
|
||||
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT)
|
||||
const timelineItems = await fetchAgentTimelineItems(client, agentId);
|
||||
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT);
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWaitCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentWaitOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentWaitResult>> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_AGENT_ID',
|
||||
message: 'Agent ID is required',
|
||||
details: 'Usage: paseo agent wait <id>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_AGENT_ID",
|
||||
message: "Agent ID is required",
|
||||
details: "Usage: paseo agent wait <id>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Parse timeout (no limit unless explicitly provided)
|
||||
let timeoutMs = 0
|
||||
let timeoutLabel: string | null = null
|
||||
let timeoutMs = 0;
|
||||
let timeoutLabel: string | null = null;
|
||||
if (options.timeout) {
|
||||
try {
|
||||
timeoutMs = parseDuration(options.timeout)
|
||||
timeoutMs = parseDuration(options.timeout);
|
||||
if (timeoutMs <= 0) {
|
||||
throw new Error('Timeout must be positive')
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
const timeoutSeconds = Math.floor(timeoutMs / 1000)
|
||||
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? '' : 's'}`
|
||||
const timeoutSeconds = Math.floor(timeoutMs / 1000);
|
||||
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_TIMEOUT',
|
||||
message: 'Invalid timeout value',
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid timeout value",
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||
client = await connectToDaemon({ host: options.host as string | undefined });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
const state = await client.waitForFinish(agentIdArg, timeoutMs)
|
||||
const resolvedAgentId = state.final?.id ?? agentIdArg
|
||||
const state = await client.waitForFinish(agentIdArg, timeoutMs);
|
||||
const resolvedAgentId = state.final?.id ?? agentIdArg;
|
||||
const recentActivity =
|
||||
state.status === 'timeout' || state.status === 'idle'
|
||||
state.status === "timeout" || state.status === "idle"
|
||||
? await getRecentActivityTranscript(client, resolvedAgentId)
|
||||
: null
|
||||
: null;
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
if (state.status === 'timeout') {
|
||||
if (state.status === "timeout") {
|
||||
const timeoutMessage = timeoutLabel
|
||||
? `Agent did not finish within ${timeoutLabel}. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`
|
||||
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`
|
||||
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`;
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: 'timeout',
|
||||
status: "timeout",
|
||||
message: appendRecentActivity(timeoutMessage, recentActivity),
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'permission') {
|
||||
const permission = state.final?.pendingPermissions?.[0]
|
||||
if (state.status === "permission") {
|
||||
const permission = state.final?.pendingPermissions?.[0];
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: 'permission',
|
||||
status: "permission",
|
||||
message: permission
|
||||
? `Agent is waiting for permission: ${permission.kind}`
|
||||
: 'Agent is waiting for permission',
|
||||
: "Agent is waiting for permission",
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
if (state.status === "error") {
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: 'error',
|
||||
message: state.error ?? 'Agent finished with error',
|
||||
status: "error",
|
||||
message: state.error ?? "Agent finished with error",
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Agent is idle
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
agentId: resolvedAgentId,
|
||||
status: 'idle',
|
||||
message: appendRecentActivity('Agent is idle.', recentActivity),
|
||||
status: "idle",
|
||||
message: appendRecentActivity("Agent is idle.", recentActivity),
|
||||
},
|
||||
schema: agentWaitSchema,
|
||||
}
|
||||
};
|
||||
} catch (waitErr) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr)
|
||||
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr);
|
||||
|
||||
// Other errors
|
||||
const error: CommandError = {
|
||||
code: 'WAIT_FAILED',
|
||||
code: "WAIT_FAILED",
|
||||
message: `Failed to wait for agent: ${waitMessage}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'WAIT_FAILED',
|
||||
code: "WAIT_FAILED",
|
||||
message: `Failed to wait for agent: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,44 @@
|
||||
import { Command } from 'commander'
|
||||
import { startCommand } from './start.js'
|
||||
import { runStatusCommand } from './status.js'
|
||||
import { runStopCommand } from './stop.js'
|
||||
import { runRestartCommand } from './restart.js'
|
||||
import { pairCommand } from './pair.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
import { addJsonOption } from '../../utils/command-options.js'
|
||||
import { Command } from "commander";
|
||||
import { startCommand } from "./start.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
import { runStopCommand } from "./stop.js";
|
||||
import { runRestartCommand } from "./restart.js";
|
||||
import { pairCommand } from "./pair.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
|
||||
export function createDaemonCommand(): Command {
|
||||
const daemon = new Command('daemon').description('Manage the Paseo daemon')
|
||||
const daemon = new Command("daemon").description("Manage the Paseo daemon");
|
||||
|
||||
daemon.addCommand(startCommand())
|
||||
daemon.addCommand(pairCommand())
|
||||
daemon.addCommand(startCommand());
|
||||
daemon.addCommand(pairCommand());
|
||||
|
||||
addJsonOption(
|
||||
daemon
|
||||
.command('status')
|
||||
.description('Show local daemon status')
|
||||
)
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.action(withOutput(runStatusCommand))
|
||||
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')
|
||||
.action(withOutput(runStopCommand))
|
||||
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")
|
||||
.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('--no-relay', 'Disable relay on restarted daemon')
|
||||
.option('--no-mcp', 'Disable Agent MCP on restarted daemon')
|
||||
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(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
"--listen <listen>",
|
||||
"Listen target for restarted daemon (host:port, port, or unix socket)",
|
||||
)
|
||||
.action(withOutput(runRestartCommand))
|
||||
.option("--port <port>", "Port for restarted daemon listen target")
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.action(withOutput(runRestartCommand));
|
||||
|
||||
return daemon
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -1,312 +1,313 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
|
||||
import { tryConnectToDaemon } from '../../utils/client.js'
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
export interface DaemonStartOptions {
|
||||
port?: string
|
||||
listen?: string
|
||||
home?: string
|
||||
foreground?: boolean
|
||||
relay?: boolean
|
||||
mcp?: boolean
|
||||
allowedHosts?: string
|
||||
port?: string;
|
||||
listen?: string;
|
||||
home?: string;
|
||||
foreground?: boolean;
|
||||
relay?: boolean;
|
||||
mcp?: boolean;
|
||||
allowedHosts?: string;
|
||||
}
|
||||
|
||||
export interface LocalDaemonPidInfo {
|
||||
pid: number
|
||||
startedAt?: string
|
||||
hostname?: string
|
||||
uid?: number
|
||||
listen?: string
|
||||
pid: number;
|
||||
startedAt?: string;
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
}
|
||||
|
||||
export interface LocalDaemonState {
|
||||
home: string
|
||||
listen: string
|
||||
logPath: string
|
||||
pidPath: string
|
||||
pidInfo: LocalDaemonPidInfo | null
|
||||
running: boolean
|
||||
stalePidFile: boolean
|
||||
home: string;
|
||||
listen: string;
|
||||
logPath: string;
|
||||
pidPath: string;
|
||||
pidInfo: LocalDaemonPidInfo | null;
|
||||
running: boolean;
|
||||
stalePidFile: boolean;
|
||||
}
|
||||
|
||||
export interface DetachedStartResult {
|
||||
pid: number | null
|
||||
logPath: string
|
||||
pid: number | null;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface StopLocalDaemonOptions {
|
||||
home?: string
|
||||
timeoutMs?: number
|
||||
force?: boolean
|
||||
home?: string;
|
||||
timeoutMs?: number;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface StopLocalDaemonResult {
|
||||
action: 'stopped' | 'not_running'
|
||||
home: string
|
||||
pid: number | null
|
||||
forced: boolean
|
||||
message: string
|
||||
action: "stopped" | "not_running";
|
||||
home: string;
|
||||
pid: number | null;
|
||||
forced: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ProcessExitDetails = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
error?: Error
|
||||
}
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
type DetachedStartupResult =
|
||||
| { exitedEarly: false }
|
||||
| ({ exitedEarly: true } & ProcessExitDetails)
|
||||
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
||||
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200
|
||||
const PID_POLL_INTERVAL_MS = 100
|
||||
const KILL_TIMEOUT_MS = 3000
|
||||
const DAEMON_LOG_FILENAME = 'daemon.log'
|
||||
const DAEMON_PID_FILENAME = 'paseo.pid'
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const PID_POLL_INTERVAL_MS = 100;
|
||||
const KILL_TIMEOUT_MS = 3000;
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
|
||||
export const DEFAULT_STOP_TIMEOUT_MS = 15_000
|
||||
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false })
|
||||
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;
|
||||
}
|
||||
|
||||
return { ...process.env, PASEO_HOME: home }
|
||||
return { ...process.env, PASEO_HOME: home };
|
||||
}
|
||||
|
||||
function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
||||
const args: string[] = []
|
||||
const args: string[] = [];
|
||||
if (options.relay === false) {
|
||||
args.push('--no-relay')
|
||||
args.push("--no-relay");
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
args.push('--no-mcp')
|
||||
args.push("--no-mcp");
|
||||
}
|
||||
|
||||
return args
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home
|
||||
childEnv.PASEO_HOME = options.home;
|
||||
}
|
||||
if (options.listen) {
|
||||
childEnv.PASEO_LISTEN = options.listen
|
||||
childEnv.PASEO_LISTEN = options.listen;
|
||||
} else if (options.port) {
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
|
||||
}
|
||||
if (options.allowedHosts) {
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
|
||||
}
|
||||
return childEnv
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
function resolveDaemonRunnerEntry(): string {
|
||||
const serverExportPath = require.resolve('@getpaseo/server')
|
||||
let currentDir = path.dirname(serverExportPath)
|
||||
const serverExportPath = require.resolve("@getpaseo/server");
|
||||
let currentDir = path.dirname(serverExportPath);
|
||||
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(currentDir, 'package.json')
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { name?: string }
|
||||
if (packageJson.name === '@getpaseo/server') {
|
||||
const distRunner = path.join(currentDir, 'dist', 'scripts', 'daemon-runner.js')
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
|
||||
if (packageJson.name === "@getpaseo/server") {
|
||||
const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js");
|
||||
if (existsSync(distRunner)) {
|
||||
return distRunner
|
||||
return distRunner;
|
||||
}
|
||||
return path.join(currentDir, 'scripts', 'daemon-runner.ts')
|
||||
return path.join(currentDir, "scripts", "daemon-runner.ts");
|
||||
}
|
||||
} catch {
|
||||
// Continue searching up if package.json exists but is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir)
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
throw new Error('Unable to resolve @getpaseo/server package root for daemon runner')
|
||||
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)
|
||||
return path.join(paseoHome, DAEMON_PID_FILENAME);
|
||||
}
|
||||
|
||||
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
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
|
||||
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: typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : undefined,
|
||||
}
|
||||
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:
|
||||
typeof parsed.listen === "string"
|
||||
? parsed.listen
|
||||
: typeof parsed.sockPath === "string"
|
||||
? parsed.sockPath
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null
|
||||
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')
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function readNodeErrnoCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return undefined
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typeof error.code === 'string' ? error.code : undefined
|
||||
return typeof error.code === "string" ? error.code : undefined;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err)
|
||||
if (code === 'EPERM') {
|
||||
return true
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err)
|
||||
if (code === 'ESRCH') {
|
||||
return false
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw err
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return signalProcess(pid, signal)
|
||||
return signalProcess(pid, signal);
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err)
|
||||
if (code === 'EPERM') {
|
||||
return true
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
throw err
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return signalProcessSafely(pid, signal)
|
||||
if (process.platform === "win32") {
|
||||
return signalProcessSafely(pid, signal);
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err)
|
||||
if (code === 'ESRCH') {
|
||||
return signalProcessSafely(pid, signal)
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "ESRCH") {
|
||||
return signalProcessSafely(pid, signal);
|
||||
}
|
||||
if (code === 'EPERM') {
|
||||
return true
|
||||
if (code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
throw err
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
await sleep(PID_POLL_INTERVAL_MS)
|
||||
await sleep(PID_POLL_INTERVAL_MS);
|
||||
}
|
||||
return !isProcessRunning(pid)
|
||||
return !isProcessRunning(pid);
|
||||
}
|
||||
|
||||
type LifecycleShutdownAttempt =
|
||||
| { requested: true }
|
||||
| { requested: false; reason: string }
|
||||
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function resolveLocalPaseoHome(home?: string): string {
|
||||
return resolvePaseoHome(envWithHome(home))
|
||||
return resolvePaseoHome(envWithHome(home));
|
||||
}
|
||||
|
||||
export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
const normalized = listen.trim()
|
||||
const normalized = listen.trim();
|
||||
if (!normalized) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.startsWith('/') ||
|
||||
normalized.startsWith('unix://') ||
|
||||
normalized.startsWith('pipe://') ||
|
||||
normalized.startsWith('\\\\.\\pipe\\')
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return `127.0.0.1:${normalized}`
|
||||
return `127.0.0.1:${normalized}`;
|
||||
}
|
||||
|
||||
if (normalized.includes(':')) {
|
||||
return normalized
|
||||
if (normalized.includes(":")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
|
||||
@@ -315,14 +316,14 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||
PASEO_LISTEN: undefined,
|
||||
PASEO_ALLOWED_HOSTS: 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
|
||||
};
|
||||
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,
|
||||
@@ -332,199 +333,197 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
||||
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)
|
||||
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
|
||||
return tailFile(logPath, lines);
|
||||
}
|
||||
|
||||
export async function startLocalDaemonDetached(
|
||||
options: DaemonStartOptions
|
||||
options: DaemonStartOptions,
|
||||
): Promise<DetachedStartResult> {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error('Cannot use --listen and --port together')
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const childEnv = buildChildEnv(options)
|
||||
const childEnv = buildChildEnv(options);
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv)
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry()
|
||||
const paseoHome = resolvePaseoHome(childEnv);
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
}
|
||||
)
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
);
|
||||
|
||||
child.unref()
|
||||
child.unref();
|
||||
|
||||
const startup = await new Promise<DetachedStartupResult>((resolve) => {
|
||||
let settled = false
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: DetachedStartupResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS)
|
||||
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
|
||||
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish(startupExited({ code: null, signal: null, error }))
|
||||
})
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
finish(startupExited({ code: null, signal: null, error }));
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
finish(startupExited({ code, signal }))
|
||||
})
|
||||
})
|
||||
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)
|
||||
: `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')
|
||||
)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid ?? null,
|
||||
logPath,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error('Cannot use --listen and --port together')
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const childEnv = buildChildEnv(options)
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry()
|
||||
const childEnv = buildChildEnv(options);
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: 'inherit',
|
||||
}
|
||||
)
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.status ?? 1
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
async function requestLifecycleShutdown(
|
||||
state: LocalDaemonState,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
): Promise<LifecycleShutdownAttempt> {
|
||||
const host = resolveTcpHostFromListen(state.listen)
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
if (!host) {
|
||||
return {
|
||||
requested: false,
|
||||
reason: 'daemon listen target is not TCP, falling back to owner PID signal',
|
||||
}
|
||||
reason: "daemon listen target is not TCP, falling back to owner PID signal",
|
||||
};
|
||||
}
|
||||
|
||||
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) })
|
||||
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
|
||||
if (!client) {
|
||||
return {
|
||||
requested: false,
|
||||
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await client.shutdownServer()
|
||||
return { requested: true }
|
||||
await client.shutdownServer();
|
||||
return { requested: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
requested: false,
|
||||
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
|
||||
error
|
||||
error,
|
||||
)}), falling back to owner PID signal`,
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => undefined)
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopLocalDaemon(
|
||||
options: StopLocalDaemonOptions = {}
|
||||
options: StopLocalDaemonOptions = {},
|
||||
): Promise<StopLocalDaemonResult> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
|
||||
const state = resolveLocalDaemonState({ home: options.home })
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
||||
const state = resolveLocalDaemonState({ home: options.home });
|
||||
|
||||
if (!state.pidInfo || !state.running) {
|
||||
const staleSuffix =
|
||||
state.stalePidFile && state.pidInfo
|
||||
? ` (stale PID file for ${state.pidInfo.pid})`
|
||||
: ''
|
||||
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
|
||||
return {
|
||||
action: 'not_running',
|
||||
action: "not_running",
|
||||
home: state.home,
|
||||
pid: state.pidInfo?.pid ?? null,
|
||||
forced: false,
|
||||
message: `Daemon is not running${staleSuffix}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pid = state.pidInfo.pid
|
||||
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs)
|
||||
const lifecycleRequested = shutdownAttempt.requested
|
||||
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason
|
||||
let forced = false
|
||||
const pid = state.pidInfo.pid;
|
||||
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
|
||||
const lifecycleRequested = shutdownAttempt.requested;
|
||||
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
|
||||
let forced = false;
|
||||
if (!lifecycleRequested) {
|
||||
const signaled = signalProcessSafely(pid, 'SIGTERM')
|
||||
const signaled = signalProcessSafely(pid, "SIGTERM");
|
||||
if (!signaled) {
|
||||
return {
|
||||
action: 'not_running',
|
||||
action: "not_running",
|
||||
home: state.home,
|
||||
pid,
|
||||
forced: false,
|
||||
message: 'Daemon process was already stopped',
|
||||
}
|
||||
message: "Daemon process was already stopped",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let stopped = await waitForPidExit(pid, timeoutMs)
|
||||
let stopped = await waitForPidExit(pid, timeoutMs);
|
||||
if (!stopped && options.force) {
|
||||
forced = true
|
||||
signalProcessGroupSafely(pid, 'SIGKILL')
|
||||
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS)
|
||||
forced = true;
|
||||
signalProcessGroupSafely(pid, "SIGKILL");
|
||||
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (!stopped) {
|
||||
throw new Error(
|
||||
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`
|
||||
)
|
||||
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'stopped',
|
||||
action: "stopped",
|
||||
home: state.home,
|
||||
pid,
|
||||
forced,
|
||||
message: forced
|
||||
? 'Daemon owner process was force-stopped'
|
||||
? "Daemon owner process was force-stopped"
|
||||
: lifecycleRequested
|
||||
? 'Daemon stopped gracefully'
|
||||
: fallbackMessage ?? 'Daemon stopped via owner PID signal',
|
||||
}
|
||||
? "Daemon stopped gracefully"
|
||||
: (fallbackMessage ?? "Daemon stopped via owner PID signal"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpaseo/server'
|
||||
import { addJsonOption } from '../../utils/command-options.js'
|
||||
import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
|
||||
interface PairOptions {
|
||||
home?: string
|
||||
json?: boolean
|
||||
home?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
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 addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.action(async (_options: PairOptions, command: Command) => {
|
||||
await runPairCommand(command.optsWithGlobals() as PairOptions)
|
||||
})
|
||||
await runPairCommand(command.optsWithGlobals() as PairOptions);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
if (options.home) {
|
||||
process.env.PASEO_HOME = options.home
|
||||
process.env.PASEO_HOME = options.home;
|
||||
}
|
||||
|
||||
const paseoHome = resolvePaseoHome()
|
||||
const config = loadConfig(paseoHome)
|
||||
const paseoHome = resolvePaseoHome();
|
||||
const config = loadConfig(paseoHome);
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome,
|
||||
relayEnabled: config.relayEnabled,
|
||||
@@ -33,12 +30,12 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
includeQr: true,
|
||||
})
|
||||
});
|
||||
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
console.error(chalk.red('Relay pairing is disabled for this daemon config.'))
|
||||
console.error(chalk.yellow('Enable relay and run this command again.'))
|
||||
process.exit(1)
|
||||
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
|
||||
console.error(chalk.yellow("Enable relay and run this command again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
@@ -50,12 +47,12 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
qr: pairing.qr,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
return
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
|
||||
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
|
||||
const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
|
||||
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,129 @@
|
||||
import type { Command } from 'commander'
|
||||
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'
|
||||
} from "./local-daemon.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface RestartResult {
|
||||
action: 'restarted'
|
||||
home: string
|
||||
pid: string
|
||||
message: string
|
||||
action: "restarted";
|
||||
home: string;
|
||||
pid: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const restartResultSchema: OutputSchema<RestartResult> = {
|
||||
idField: 'action',
|
||||
idField: "action",
|
||||
columns: [
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'action',
|
||||
color: () => 'green',
|
||||
header: "STATUS",
|
||||
field: "action",
|
||||
color: () => "green",
|
||||
},
|
||||
{ header: 'HOME', field: 'home' },
|
||||
{ header: 'PID', field: 'pid' },
|
||||
{ header: 'MESSAGE', field: 'message' },
|
||||
{ header: "HOME", field: "home" },
|
||||
{ header: "PID", field: "pid" },
|
||||
{ header: "MESSAGE", field: "message" },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export type RestartCommandResult = SingleResult<RestartResult>
|
||||
export type RestartCommandResult = SingleResult<RestartResult>;
|
||||
|
||||
function parseTimeoutMs(raw: unknown): number {
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const seconds = Number(raw)
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_TIMEOUT',
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: `Invalid timeout value: ${raw}`,
|
||||
details: 'Timeout must be a positive number of seconds',
|
||||
}
|
||||
throw error
|
||||
details: "Timeout must be a positive number of seconds",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000)
|
||||
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,
|
||||
allowedHosts: typeof options.allowedHosts === 'string' ? options.allowedHosts : undefined,
|
||||
}
|
||||
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,
|
||||
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
|
||||
};
|
||||
|
||||
if (startOptions.listen && startOptions.port) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OPTIONS',
|
||||
message: 'Cannot use --listen and --port together',
|
||||
}
|
||||
throw error
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "Cannot use --listen and --port together",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return startOptions
|
||||
return startOptions;
|
||||
}
|
||||
|
||||
export async function runRestartCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<RestartCommandResult> {
|
||||
const timeoutMs = parseTimeoutMs(options.timeout)
|
||||
const force = options.force === true
|
||||
const startOptions = toStartOptions(options)
|
||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
||||
const force = options.force === true;
|
||||
const startOptions = toStartOptions(options);
|
||||
|
||||
try {
|
||||
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>
|
||||
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>;
|
||||
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')
|
||||
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
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
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',
|
||||
type: "single",
|
||||
data: {
|
||||
action: 'restarted',
|
||||
action: "restarted",
|
||||
home: stopResult.home,
|
||||
pid: startup.pid === null ? '-' : String(startup.pid),
|
||||
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 message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'RESTART_FAILED',
|
||||
code: "RESTART_FAILED",
|
||||
message: `Failed to restart local daemon: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export interface NodePathFromPidResult {
|
||||
nodePath: string | null
|
||||
error?: string
|
||||
nodePath: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
return error.message;
|
||||
}
|
||||
return String(error)
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync('ps', ['-o', 'comm=', '-p', String(pid)], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
nodePath: null,
|
||||
error: `ps failed: ${normalizeError(result.error)}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim()
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
nodePath: null,
|
||||
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim()
|
||||
const resolved = result.stdout.trim();
|
||||
if (!resolved) {
|
||||
return {
|
||||
nodePath: null,
|
||||
error: 'ps returned an empty command path',
|
||||
}
|
||||
error: "ps returned an empty command path",
|
||||
};
|
||||
}
|
||||
|
||||
return { nodePath: resolved }
|
||||
return { nodePath: resolved };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
startLocalDaemonForeground,
|
||||
startLocalDaemonDetached,
|
||||
type DaemonStartOptions as StartOptions,
|
||||
} from './local-daemon.js'
|
||||
import { getErrorMessage } from '../../utils/errors.js'
|
||||
} from "./local-daemon.js";
|
||||
import { getErrorMessage } from "../../utils/errors.js";
|
||||
|
||||
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
|
||||
export type { DaemonStartOptions as StartOptions } from "./local-daemon.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('--no-relay', 'Disable relay connection')
|
||||
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
|
||||
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("--no-relay", "Disable relay connection")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.action(async (options: StartOptions) => {
|
||||
await runStart(options)
|
||||
})
|
||||
await runStart(options);
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
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}`))
|
||||
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))
|
||||
exitWithError(getErrorMessage(err));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = startLocalDaemonForeground(options)
|
||||
process.exit(status)
|
||||
const status = startLocalDaemonForeground(options);
|
||||
process.exit(status);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err)
|
||||
exitWithError(`Failed to start daemon: ${message}`)
|
||||
const message = getErrorMessage(err);
|
||||
exitWithError(`Failed to start daemon: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function exitWithError(message: string): never {
|
||||
console.error(chalk.red(message))
|
||||
process.exit(1)
|
||||
console.error(chalk.red(message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,260 +1,260 @@
|
||||
import type { Command } from 'commander'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { getOrCreateServerId, findExecutable, applyProviderEnv } from '@getpaseo/server'
|
||||
import { tryConnectToDaemon } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
|
||||
import { resolveNodePathFromPid } from './runtime-toolchain.js'
|
||||
import type { Command } from "commander";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
|
||||
|
||||
interface ProviderBinaryStatus {
|
||||
label: string
|
||||
path: string | null
|
||||
version: string | null
|
||||
label: string;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
serverId: string | null
|
||||
status: 'running' | 'stopped' | 'unresponsive'
|
||||
home: string
|
||||
listen: string
|
||||
hostname: string | null
|
||||
pid: number | null
|
||||
startedAt: string | null
|
||||
owner: string | null
|
||||
logPath: string
|
||||
runningAgents: number | null
|
||||
idleAgents: number | null
|
||||
daemonNode: string
|
||||
cliNode: string
|
||||
cliVersion: string
|
||||
providers: ProviderBinaryStatus[]
|
||||
note?: string
|
||||
serverId: string | null;
|
||||
status: "running" | "stopped" | "unresponsive";
|
||||
home: string;
|
||||
listen: string;
|
||||
hostname: string | null;
|
||||
pid: number | null;
|
||||
startedAt: string | null;
|
||||
owner: string | null;
|
||||
logPath: string;
|
||||
runningAgents: number | null;
|
||||
idleAgents: number | null;
|
||||
daemonNode: string;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
providers: ProviderBinaryStatus[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface StatusRow {
|
||||
key: string
|
||||
value: string
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type CliPackageJson = {
|
||||
version?: unknown
|
||||
}
|
||||
version?: unknown;
|
||||
};
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
return error.message;
|
||||
}
|
||||
return String(error)
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function shortenMessage(message: string, max = 120): string {
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
const normalized = message.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= max) {
|
||||
return normalized
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, max - 3)}...`
|
||||
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}`
|
||||
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()
|
||||
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'
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
return {
|
||||
idField: 'key',
|
||||
idField: "key",
|
||||
columns: [
|
||||
{ header: 'KEY', field: 'key' },
|
||||
{ header: "KEY", field: "key" },
|
||||
{
|
||||
header: 'VALUE',
|
||||
field: 'value',
|
||||
header: "VALUE",
|
||||
field: "value",
|
||||
color: (_, item) => {
|
||||
if (item.key === 'Status') {
|
||||
if (item.value === 'running') return 'green'
|
||||
if (item.value === 'unresponsive') return 'yellow'
|
||||
return 'red'
|
||||
if (item.key === "Status") {
|
||||
if (item.value === "running") return "green";
|
||||
if (item.value === "unresponsive") return "yellow";
|
||||
return "red";
|
||||
}
|
||||
if (item.key.startsWith(' ')) {
|
||||
if (item.value === 'not found') return 'red'
|
||||
if (item.value.endsWith('(--version failed)')) return 'yellow'
|
||||
return 'green'
|
||||
if (item.key.startsWith(" ")) {
|
||||
if (item.value === "not found") return "red";
|
||||
if (item.value.endsWith("(--version failed)")) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
return undefined
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
serialize: () => status,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
const rows: StatusRow[] = [
|
||||
{ key: 'Server ID', value: status.serverId ?? '-' },
|
||||
{ key: 'Status', value: status.status },
|
||||
{ key: 'Home', value: status.home },
|
||||
{ key: 'Listen', value: status.listen },
|
||||
{ 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: "Server ID", value: status.serverId ?? "-" },
|
||||
{ key: "Status", value: status.status },
|
||||
{ key: "Home", value: status.home },
|
||||
{ key: "Listen", value: status.listen },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
if (status.runningAgents !== null && status.idleAgents !== null) {
|
||||
rows.push({
|
||||
key: 'Agents',
|
||||
key: "Agents",
|
||||
value: `${status.runningAgents} running, ${status.idleAgents} idle`,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
key: 'Agents',
|
||||
value: 'Unavailable (daemon API not reachable)',
|
||||
})
|
||||
key: "Agents",
|
||||
value: "Unavailable (daemon API not reachable)",
|
||||
});
|
||||
}
|
||||
|
||||
if (status.note) {
|
||||
rows.push({ key: 'Note', value: status.note })
|
||||
rows.push({ key: "Note", value: status.note });
|
||||
}
|
||||
|
||||
rows.push({ key: '', value: '' })
|
||||
rows.push({ key: 'Providers', value: '' })
|
||||
rows.push({ key: "", value: "" });
|
||||
rows.push({ key: "Providers", value: "" });
|
||||
for (const provider of status.providers) {
|
||||
if (!provider.path) {
|
||||
rows.push({ key: ` ${provider.label}`, value: 'not found' })
|
||||
rows.push({ key: ` ${provider.label}`, value: "not found" });
|
||||
} else if (!provider.version) {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` })
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
|
||||
} else {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` })
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` });
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
||||
{ label: 'Claude', binary: 'claude' },
|
||||
{ label: 'Codex', binary: 'codex' },
|
||||
{ label: 'OpenCode', binary: 'opencode' },
|
||||
]
|
||||
{ label: "Claude", binary: "claude" },
|
||||
{ label: "Codex", binary: "codex" },
|
||||
{ label: "OpenCode", binary: "opencode" },
|
||||
];
|
||||
|
||||
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
|
||||
const binaryPath = findExecutable(binary)
|
||||
const binaryPath = findExecutable(binary);
|
||||
if (!binaryPath) {
|
||||
return { path: null, version: null }
|
||||
return { path: null, version: null };
|
||||
}
|
||||
const env = applyProviderEnv(process.env)
|
||||
const env = applyProviderEnv(process.env);
|
||||
try {
|
||||
const output = execFileSync(binaryPath, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
const output = execFileSync(binaryPath, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
}).trim()
|
||||
return { path: binaryPath, version: output || null }
|
||||
}).trim();
|
||||
return { path: binaryPath, version: output || null };
|
||||
} catch {
|
||||
return { path: binaryPath, version: null }
|
||||
return { path: binaryPath, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
function checkProviderBinaries(): ProviderBinaryStatus[] {
|
||||
return PROVIDER_BINARIES.map(({ label, binary }) => {
|
||||
const result = checkProviderBinary(binary)
|
||||
return { label, ...result }
|
||||
})
|
||||
const result = checkProviderBinary(binary);
|
||||
return { label, ...result };
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
|
||||
if (uid === undefined && !hostname) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const uidPart = uid === undefined ? '?' : String(uid)
|
||||
const hostPart = hostname ?? 'unknown-host'
|
||||
return `${uidPart}@${hostPart}`
|
||||
const uidPart = uid === undefined ? "?" : String(uid);
|
||||
const hostPart = hostname ?? "unknown-host";
|
||||
return `${uidPart}@${hostPart}`;
|
||||
}
|
||||
|
||||
export type StatusResult = ListResult<StatusRow>
|
||||
export type StatusResult = ListResult<StatusRow>;
|
||||
|
||||
export async function runStatusCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<StatusResult> {
|
||||
const home = typeof options.home === 'string' ? options.home : undefined
|
||||
const state = resolveLocalDaemonState({ home })
|
||||
const home = typeof options.home === "string" ? options.home : undefined;
|
||||
const state = resolveLocalDaemonState({ home });
|
||||
|
||||
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
|
||||
let daemonNode: string
|
||||
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
|
||||
let daemonNode: string;
|
||||
if (!state.running) {
|
||||
daemonNode = '-'
|
||||
daemonNode = "-";
|
||||
} else if (state.pidInfo?.pid) {
|
||||
const fromPid = resolveNodePathFromPid(state.pidInfo.pid)
|
||||
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? 'could not resolve from PID'})`
|
||||
const fromPid = resolveNodePathFromPid(state.pidInfo.pid);
|
||||
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||
} else {
|
||||
daemonNode = 'unknown (no PID available)'
|
||||
daemonNode = "unknown (no PID available)";
|
||||
}
|
||||
const cliNode = process.execPath
|
||||
let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped'
|
||||
let runningAgents: number | null = null
|
||||
let idleAgents: number | null = null
|
||||
let note: string | undefined
|
||||
const cliNode = process.execPath;
|
||||
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
|
||||
let runningAgents: number | null = null;
|
||||
let idleAgents: number | null = null;
|
||||
let note: string | undefined;
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
note = `Stale PID file found for PID ${state.pidInfo.pid}`
|
||||
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
|
||||
}
|
||||
|
||||
if (state.running) {
|
||||
const host = resolveTcpHostFromListen(state.listen)
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 })
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||
runningAgents = agents.filter(a => a.status === 'running').length
|
||||
idleAgents = agents.filter(a => a.status === 'idle').length
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
runningAgents = agents.filter((a) => a.status === "running").length;
|
||||
idleAgents = agents.filter((a) => a.status === "idle").length;
|
||||
} catch {
|
||||
status = 'unresponsive'
|
||||
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`)
|
||||
status = "unresponsive";
|
||||
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
status = 'unresponsive'
|
||||
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`)
|
||||
status = "unresponsive";
|
||||
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
|
||||
}
|
||||
} else {
|
||||
note = appendNote(note, 'Daemon is configured for unix socket listen; API probe skipped')
|
||||
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
|
||||
}
|
||||
}
|
||||
|
||||
const cliVersion = resolveCliVersion()
|
||||
const cliVersion = resolveCliVersion();
|
||||
|
||||
let serverId: string | null = null
|
||||
let serverId: string | null = null;
|
||||
try {
|
||||
serverId = getOrCreateServerId(state.home)
|
||||
serverId = getOrCreateServerId(state.home);
|
||||
} catch (error) {
|
||||
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`)
|
||||
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
|
||||
}
|
||||
|
||||
const providers = checkProviderBinaries()
|
||||
const providers = checkProviderBinaries();
|
||||
|
||||
const daemonStatus: DaemonStatus = {
|
||||
serverId,
|
||||
@@ -273,11 +273,11 @@ export async function runStatusCommand(
|
||||
cliVersion,
|
||||
providers,
|
||||
note,
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: toStatusRows(daemonStatus),
|
||||
schema: createStatusSchema(daemonStatus),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,74 +1,79 @@
|
||||
import type { Command } from 'commander'
|
||||
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from './local-daemon.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from "./local-daemon.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface StopResult {
|
||||
action: 'stopped' | 'not_running'
|
||||
home: string
|
||||
pid: string
|
||||
message: string
|
||||
action: "stopped" | "not_running";
|
||||
home: string;
|
||||
pid: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const stopResultSchema: OutputSchema<StopResult> = {
|
||||
idField: 'action',
|
||||
idField: "action",
|
||||
columns: [
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'action',
|
||||
color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
|
||||
header: "STATUS",
|
||||
field: "action",
|
||||
color: (value) => (value === "stopped" ? "green" : "yellow"),
|
||||
},
|
||||
{ header: 'HOME', field: 'home' },
|
||||
{ header: 'PID', field: 'pid' },
|
||||
{ header: 'MESSAGE', field: 'message' },
|
||||
{ header: "HOME", field: "home" },
|
||||
{ header: "PID", field: "pid" },
|
||||
{ header: "MESSAGE", field: "message" },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export type StopCommandResult = SingleResult<StopResult>
|
||||
export type StopCommandResult = SingleResult<StopResult>;
|
||||
|
||||
function parseTimeoutMs(raw: unknown): number {
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const seconds = Number(raw)
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_TIMEOUT',
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: `Invalid timeout value: ${raw}`,
|
||||
details: 'Timeout must be a positive number of seconds',
|
||||
}
|
||||
throw error
|
||||
details: "Timeout must be a positive number of seconds",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000)
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
export async function runStopCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<StopCommandResult> {
|
||||
const home = typeof options.home === 'string' ? options.home : undefined
|
||||
const force = options.force === true
|
||||
const timeoutMs = parseTimeoutMs(options.timeout)
|
||||
const home = typeof options.home === "string" ? options.home : undefined;
|
||||
const force = options.force === true;
|
||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
||||
|
||||
try {
|
||||
const result = await stopLocalDaemon({ home, force, timeoutMs })
|
||||
const result = await stopLocalDaemon({ home, force, timeoutMs });
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
action: result.action,
|
||||
home: result.home,
|
||||
pid: result.pid === null ? '-' : String(result.pid),
|
||||
pid: result.pid === null ? "-" : String(result.pid),
|
||||
message: result.message,
|
||||
},
|
||||
schema: stopResultSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'STOP_FAILED',
|
||||
code: "STOP_FAILED",
|
||||
message: `Failed to stop local daemon: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
|
||||
import { Command } from 'commander'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
||||
import { Command } from "commander";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
generateLocalPairingOffer,
|
||||
loadConfig,
|
||||
loadPersistedConfig,
|
||||
type CliConfigOverrides,
|
||||
type PersistedConfig,
|
||||
} from '@getpaseo/server'
|
||||
} from "@getpaseo/server";
|
||||
import {
|
||||
resolveLocalPaseoHome,
|
||||
resolveLocalDaemonState,
|
||||
@@ -16,88 +16,94 @@ import {
|
||||
startLocalDaemonDetached,
|
||||
tailDaemonLog,
|
||||
type DaemonStartOptions,
|
||||
} from './daemon/local-daemon.js'
|
||||
import { tryConnectToDaemon } from '../utils/client.js'
|
||||
} from "./daemon/local-daemon.js";
|
||||
import { tryConnectToDaemon } from "../utils/client.js";
|
||||
|
||||
interface OnboardOptions extends DaemonStartOptions {
|
||||
timeout?: string
|
||||
voice?: 'ask' | 'enable' | 'disable'
|
||||
timeout?: string;
|
||||
voice?: "ask" | "enable" | "disable";
|
||||
}
|
||||
|
||||
type OnboardPersistedConfig = PersistedConfig & {
|
||||
features?: PersistedConfig['features'] & {
|
||||
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
|
||||
features?: PersistedConfig["features"] & {
|
||||
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
|
||||
? T & { enabled?: boolean }
|
||||
: { enabled?: boolean }
|
||||
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
|
||||
: { enabled?: boolean };
|
||||
voiceMode?: PersistedConfig["features"] extends { voiceMode?: infer T }
|
||||
? T & { enabled?: boolean }
|
||||
: { enabled?: boolean }
|
||||
}
|
||||
}
|
||||
: { enabled?: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
class OnboardCancelledError extends Error {}
|
||||
|
||||
const plainNoteFormat = (line: string): string => line
|
||||
const plainNoteFormat = (line: string): string => line;
|
||||
|
||||
function renderNote(message: string, title: string): void {
|
||||
note(message, title, { format: plainNoteFormat })
|
||||
note(message, title, { format: plainNoteFormat });
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function parseTimeoutMs(raw: string | undefined): number {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return DEFAULT_READY_TIMEOUT_MS
|
||||
return DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const seconds = Number(raw)
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
throw new Error(`Invalid timeout value: ${raw}`)
|
||||
throw new Error(`Invalid timeout value: ${raw}`);
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000)
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
||||
const cliOverrides: CliConfigOverrides = {}
|
||||
const cliOverrides: CliConfigOverrides = {};
|
||||
|
||||
if (options.listen) {
|
||||
cliOverrides.listen = options.listen
|
||||
cliOverrides.listen = options.listen;
|
||||
} else if (options.port) {
|
||||
cliOverrides.listen = `127.0.0.1:${options.port}`
|
||||
cliOverrides.listen = `127.0.0.1:${options.port}`;
|
||||
}
|
||||
|
||||
if (options.relay === false) {
|
||||
cliOverrides.relayEnabled = false
|
||||
cliOverrides.relayEnabled = false;
|
||||
}
|
||||
|
||||
if (options.allowedHosts) {
|
||||
const raw = options.allowedHosts.trim()
|
||||
const raw = options.allowedHosts.trim();
|
||||
cliOverrides.allowedHosts =
|
||||
raw.toLowerCase() === 'true'
|
||||
raw.toLowerCase() === "true"
|
||||
? true
|
||||
: raw.split(',').map(host => host.trim()).filter(Boolean)
|
||||
: raw
|
||||
.split(",")
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
cliOverrides.mcpEnabled = false
|
||||
cliOverrides.mcpEnabled = false;
|
||||
}
|
||||
|
||||
return cliOverrides
|
||||
return cliOverrides;
|
||||
}
|
||||
|
||||
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
|
||||
const configPath = path.join(paseoHome, 'config.json')
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
|
||||
function applyVoiceSelection(
|
||||
config: OnboardPersistedConfig,
|
||||
enabled: boolean,
|
||||
): OnboardPersistedConfig {
|
||||
return {
|
||||
...config,
|
||||
features: {
|
||||
@@ -111,321 +117,319 @@ function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean):
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
|
||||
const voiceModeEnabled = config.features?.voiceMode?.enabled
|
||||
if (typeof voiceModeEnabled === 'boolean') {
|
||||
return voiceModeEnabled
|
||||
const voiceModeEnabled = config.features?.voiceMode?.enabled;
|
||||
if (typeof voiceModeEnabled === "boolean") {
|
||||
return voiceModeEnabled;
|
||||
}
|
||||
|
||||
const dictationEnabled = config.features?.dictation?.enabled
|
||||
if (typeof dictationEnabled === 'boolean') {
|
||||
return dictationEnabled
|
||||
const dictationEnabled = config.features?.dictation?.enabled;
|
||||
if (typeof dictationEnabled === "boolean") {
|
||||
return dictationEnabled;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
|
||||
if (mode === 'enable') {
|
||||
return true
|
||||
async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boolean> {
|
||||
if (mode === "enable") {
|
||||
return true;
|
||||
}
|
||||
if (mode === 'disable') {
|
||||
return false
|
||||
if (mode === "disable") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
|
||||
return false
|
||||
log.message("Non-interactive terminal detected; voice setup defaults to disabled.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const answer = await confirm({
|
||||
message: 'Enable voice features? (downloads local STT/TTS models in background)',
|
||||
active: 'Yes',
|
||||
inactive: 'No',
|
||||
message: "Enable voice features? (downloads local STT/TTS models in background)",
|
||||
active: "Yes",
|
||||
inactive: "No",
|
||||
initialValue: false,
|
||||
})
|
||||
});
|
||||
|
||||
if (isCancel(answer)) {
|
||||
throw new OnboardCancelledError('Onboarding cancelled by user.')
|
||||
throw new OnboardCancelledError("Onboarding cancelled by user.");
|
||||
}
|
||||
|
||||
return answer
|
||||
return answer;
|
||||
}
|
||||
|
||||
type DownloadProgress = {
|
||||
modelId: string | null
|
||||
pct: number | null
|
||||
}
|
||||
modelId: string | null;
|
||||
pct: number | null;
|
||||
};
|
||||
|
||||
function parseDownloadProgress(logTail: string): DownloadProgress | null {
|
||||
const lines = logTail.split('\n').filter(Boolean)
|
||||
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 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",}]+)/
|
||||
)
|
||||
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
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderProgressLine(progress: DownloadProgress): string {
|
||||
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
|
||||
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : "";
|
||||
if (progress.pct === null) {
|
||||
return `Downloading speech model${modelSuffix}...`
|
||||
return `Downloading speech model${modelSuffix}...`;
|
||||
}
|
||||
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
|
||||
return `Downloading speech model${modelSuffix}: ${progress.pct}%`;
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(args: {
|
||||
home: string
|
||||
timeoutMs: number
|
||||
onStatus?: (message: string) => void
|
||||
home: string;
|
||||
timeoutMs: number;
|
||||
onStatus?: (message: string) => void;
|
||||
}): Promise<{ listen: string; host: string | null }> {
|
||||
const deadline = Date.now() + args.timeoutMs
|
||||
let lastStatus = ''
|
||||
let lastPrintedAt = 0
|
||||
const deadline = Date.now() + args.timeoutMs;
|
||||
let lastStatus = "";
|
||||
let lastPrintedAt = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const state = resolveLocalDaemonState({ home: args.home })
|
||||
const host = resolveTcpHostFromListen(state.listen)
|
||||
const state = resolveLocalDaemonState({ home: args.home });
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
|
||||
if (state.running && host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1200 })
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1200 });
|
||||
if (client) {
|
||||
try {
|
||||
await client.fetchAgents()
|
||||
return { listen: state.listen, host }
|
||||
await client.fetchAgents();
|
||||
return { listen: state.listen, host };
|
||||
} catch {
|
||||
// Daemon process is alive but not API-ready yet.
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
} else if (state.running && !host) {
|
||||
return { listen: state.listen, host: null }
|
||||
return { listen: state.listen, host: null };
|
||||
}
|
||||
|
||||
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
|
||||
const progressLine = progress ? renderProgressLine(progress) : null
|
||||
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
|
||||
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? "");
|
||||
const progressLine = progress ? renderProgressLine(progress) : null;
|
||||
const statusMessage = progressLine ?? "Waiting for daemon to become ready...";
|
||||
|
||||
if (statusMessage !== lastStatus) {
|
||||
args.onStatus?.(statusMessage)
|
||||
lastStatus = statusMessage
|
||||
lastPrintedAt = Date.now()
|
||||
args.onStatus?.(statusMessage);
|
||||
lastStatus = statusMessage;
|
||||
lastPrintedAt = Date.now();
|
||||
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
|
||||
console.log(statusMessage)
|
||||
lastPrintedAt = Date.now()
|
||||
console.log(statusMessage);
|
||||
lastPrintedAt = Date.now();
|
||||
}
|
||||
|
||||
await sleep(200)
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
const recentLogs = tailDaemonLog(args.home, 60)
|
||||
const recentLogs = tailDaemonLog(args.home, 60);
|
||||
throw new Error(
|
||||
[
|
||||
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
|
||||
const daemonLogPath = path.join(paseoHome, 'daemon.log')
|
||||
const daemonLogPath = path.join(paseoHome, "daemon.log");
|
||||
const nextStepsLines = [
|
||||
pairingUrl
|
||||
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
|
||||
: '1. Open Paseo and connect to your daemon.',
|
||||
'2. Web app: https://app.paseo.sh',
|
||||
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
|
||||
'4. Docs: https://paseo.sh/docs',
|
||||
? "1. Open Paseo and scan the QR code above, or paste the pairing link."
|
||||
: "1. Open Paseo and connect to your daemon.",
|
||||
"2. Web app: https://app.paseo.sh",
|
||||
"3. Desktop app: https://github.com/getpaseo/paseo/releases/latest",
|
||||
"4. Docs: https://paseo.sh/docs",
|
||||
'5. Example: paseo run --output-schema schema.json "extract fields"',
|
||||
]
|
||||
];
|
||||
const quickReferenceLines = [
|
||||
'1. paseo --help',
|
||||
'2. paseo ls',
|
||||
"1. paseo --help",
|
||||
"2. paseo ls",
|
||||
'3. paseo run "your prompt"',
|
||||
'4. paseo status',
|
||||
"4. paseo status",
|
||||
`5. Daemon logs: ${daemonLogPath}`,
|
||||
]
|
||||
];
|
||||
|
||||
if (!richUi) {
|
||||
console.log('')
|
||||
console.log('Next steps:')
|
||||
console.log("");
|
||||
console.log("Next steps:");
|
||||
for (const line of nextStepsLines) {
|
||||
console.log(line)
|
||||
console.log(line);
|
||||
}
|
||||
console.log('')
|
||||
console.log('CLI quick reference:')
|
||||
console.log("");
|
||||
console.log("CLI quick reference:");
|
||||
for (const line of quickReferenceLines) {
|
||||
console.log(line)
|
||||
console.log(line);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
renderNote(nextStepsLines.join('\n'), 'Next steps')
|
||||
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
|
||||
renderNote(nextStepsLines.join("\n"), "Next steps");
|
||||
renderNote(quickReferenceLines.join("\n"), "CLI quick reference");
|
||||
}
|
||||
|
||||
export function onboardCommand(): Command {
|
||||
return new Command('onboard')
|
||||
.description('Run first-time setup, start daemon, and print pairing instructions')
|
||||
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
|
||||
.option('--port <port>', 'Port to listen on (default: 6767)')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.option('--no-relay', 'Disable relay connection')
|
||||
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
|
||||
return new Command("onboard")
|
||||
.description("Run first-time setup, start daemon, and print pairing instructions")
|
||||
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
|
||||
.option("--port <port>", "Port to listen on (default: 6767)")
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.option("--no-relay", "Disable relay connection")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
|
||||
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
|
||||
.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: OnboardOptions) => {
|
||||
await runOnboard(options)
|
||||
})
|
||||
await runOnboard(options);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
const richUi = process.stdin.isTTY && process.stdout.isTTY
|
||||
const richUi = process.stdin.isTTY && process.stdout.isTTY;
|
||||
if (richUi) {
|
||||
intro('Welcome to Paseo')
|
||||
intro("Welcome to Paseo");
|
||||
}
|
||||
|
||||
if (options.listen && options.port) {
|
||||
cancel('Cannot use --listen and --port together')
|
||||
process.exit(1)
|
||||
cancel("Cannot use --listen and --port together");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
|
||||
let timeoutMs = DEFAULT_READY_TIMEOUT_MS;
|
||||
try {
|
||||
timeoutMs = parseTimeoutMs(options.timeout)
|
||||
timeoutMs = parseTimeoutMs(options.timeout);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
cancel(message)
|
||||
process.exit(1)
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cancel(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const paseoHome = resolveLocalPaseoHome(options.home)
|
||||
const paseoHome = resolveLocalPaseoHome(options.home);
|
||||
if (richUi) {
|
||||
renderNote(paseoHome, 'Paseo home')
|
||||
renderNote(paseoHome, "Paseo home");
|
||||
}
|
||||
|
||||
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig
|
||||
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted)
|
||||
const shouldPrompt = options.voice === 'ask' || options.voice === undefined
|
||||
let voiceEnabled: boolean
|
||||
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig;
|
||||
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted);
|
||||
const shouldPrompt = options.voice === "ask" || options.voice === undefined;
|
||||
let voiceEnabled: boolean;
|
||||
try {
|
||||
voiceEnabled =
|
||||
shouldPrompt && persistedVoiceSelection !== null
|
||||
? persistedVoiceSelection
|
||||
: await resolveVoiceSelection(options.voice)
|
||||
: await resolveVoiceSelection(options.voice);
|
||||
} catch (error) {
|
||||
if (error instanceof OnboardCancelledError) {
|
||||
cancel('Onboarding cancelled.')
|
||||
process.exit(0)
|
||||
return
|
||||
cancel("Onboarding cancelled.");
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldPrompt && persistedVoiceSelection !== null) {
|
||||
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
|
||||
log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`);
|
||||
}
|
||||
|
||||
persisted = applyVoiceSelection(persisted, voiceEnabled)
|
||||
savePersistedConfig(paseoHome, persisted)
|
||||
persisted = applyVoiceSelection(persisted, voiceEnabled);
|
||||
savePersistedConfig(paseoHome, persisted);
|
||||
|
||||
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) });
|
||||
|
||||
const voiceStatus = voiceEnabled
|
||||
? 'Voice features enabled. Local speech models will be downloaded automatically if missing.'
|
||||
: 'Voice features disabled. Local speech models will not be downloaded.'
|
||||
log.message(voiceStatus)
|
||||
? "Voice features enabled. Local speech models will be downloaded automatically if missing."
|
||||
: "Voice features disabled. Local speech models will not be downloaded.";
|
||||
log.message(voiceStatus);
|
||||
|
||||
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
|
||||
const startSpinner = richUi ? spinner() : null
|
||||
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
|
||||
const startSpinner = richUi ? spinner() : null;
|
||||
|
||||
if (!stateBeforeStart.running) {
|
||||
try {
|
||||
if (startSpinner) {
|
||||
startSpinner.start('Starting daemon...')
|
||||
startSpinner.start("Starting daemon...");
|
||||
} else {
|
||||
log.message('Starting daemon...')
|
||||
log.message("Starting daemon...");
|
||||
}
|
||||
const startup = await startLocalDaemonDetached(options)
|
||||
const startup = await startLocalDaemonDetached(options);
|
||||
if (startSpinner) {
|
||||
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
|
||||
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
} else {
|
||||
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
|
||||
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
}
|
||||
log.message(`Logs: ${startup.logPath}`)
|
||||
log.message(`Logs: ${startup.logPath}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (startSpinner) {
|
||||
startSpinner.error(message)
|
||||
startSpinner.error(message);
|
||||
} else {
|
||||
log.error(message)
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1)
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
|
||||
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
|
||||
}
|
||||
|
||||
let readyState: { listen: string; host: string | null }
|
||||
const readySpinner = richUi ? spinner() : null
|
||||
let readyState: { listen: string; host: string | null };
|
||||
const readySpinner = richUi ? spinner() : null;
|
||||
try {
|
||||
if (readySpinner) {
|
||||
readySpinner.start('Waiting for daemon to become ready...')
|
||||
readySpinner.start("Waiting for daemon to become ready...");
|
||||
} else {
|
||||
log.message('Waiting for daemon to become ready...')
|
||||
log.message("Waiting for daemon to become ready...");
|
||||
}
|
||||
readyState = await waitForDaemonReady({
|
||||
home: options.home ?? paseoHome,
|
||||
timeoutMs,
|
||||
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
|
||||
})
|
||||
});
|
||||
if (readySpinner) {
|
||||
readySpinner.stop(`Daemon ready on ${readyState.listen}`)
|
||||
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
|
||||
} else {
|
||||
log.message(`Daemon ready on ${readyState.listen}`)
|
||||
log.message(`Daemon ready on ${readyState.listen}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (readySpinner) {
|
||||
readySpinner.error(message)
|
||||
readySpinner.error(message);
|
||||
} else {
|
||||
log.error(message)
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1)
|
||||
return
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.relayEnabled === false) {
|
||||
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
|
||||
printNextSteps(null, paseoHome, richUi)
|
||||
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
|
||||
printNextSteps(null, paseoHome, richUi);
|
||||
if (richUi) {
|
||||
outro('Paseo daemon is running.')
|
||||
outro("Paseo daemon is running.");
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
@@ -435,24 +439,24 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
includeQr: true,
|
||||
})
|
||||
});
|
||||
|
||||
if (!pairing.url) {
|
||||
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
|
||||
printNextSteps(null, paseoHome, richUi)
|
||||
log.warn("Relay pairing URL is unavailable for this daemon configuration.");
|
||||
printNextSteps(null, paseoHome, richUi);
|
||||
if (richUi) {
|
||||
outro('Paseo daemon is running.')
|
||||
outro("Paseo daemon is running.");
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
renderNote(
|
||||
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
|
||||
'Scan to pair'
|
||||
)
|
||||
renderNote(pairing.url, 'Pairing link')
|
||||
printNextSteps(pairing.url, paseoHome, richUi)
|
||||
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
|
||||
"Scan to pair",
|
||||
);
|
||||
renderNote(pairing.url, "Pairing link");
|
||||
printNextSteps(pairing.url, paseoHome, richUi);
|
||||
if (richUi) {
|
||||
outro('Paseo is ready!')
|
||||
outro("Paseo is ready!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +1,163 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentPermissionRequest } from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Permission response item for display */
|
||||
export interface PermissionResponseItem {
|
||||
requestId: string
|
||||
agentId: string
|
||||
agentShortId: string
|
||||
name: string
|
||||
result: string
|
||||
requestId: string;
|
||||
agentId: string;
|
||||
agentShortId: string;
|
||||
name: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
/** Schema for permit allow/deny output */
|
||||
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
idField: 'requestId',
|
||||
idField: "requestId",
|
||||
columns: [
|
||||
{ header: 'REQUEST ID', field: 'requestId', width: 12 },
|
||||
{ header: 'AGENT', field: 'agentShortId', width: 10 },
|
||||
{ header: 'TOOL', field: 'name', width: 20 },
|
||||
{ header: "REQUEST ID", field: "requestId", width: 12 },
|
||||
{ header: "AGENT", field: "agentShortId", width: 10 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{
|
||||
header: 'RESULT',
|
||||
field: 'result',
|
||||
header: "RESULT",
|
||||
field: "result",
|
||||
width: 10,
|
||||
color: (value) => {
|
||||
if (value === 'allowed') return 'green'
|
||||
if (value === 'denied') return 'red'
|
||||
return undefined
|
||||
if (value === "allowed") return "green";
|
||||
if (value === "denied") return "red";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export type PermitAllowResult = ListResult<PermissionResponseItem>
|
||||
export type PermitAllowResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
export interface PermitAllowOptions extends CommandOptions {
|
||||
all?: boolean
|
||||
input?: string
|
||||
host?: string
|
||||
all?: boolean;
|
||||
input?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runAllowCommand(
|
||||
agentIdOrPrefix: string,
|
||||
reqId: string | undefined,
|
||||
options: PermitAllowOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<PermitAllowResult> {
|
||||
const host = getDaemonHost({ host: options.host })
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// No validation needed - if no reqId provided, allow all by default
|
||||
|
||||
// Parse input JSON if provided
|
||||
let updatedInput: Record<string, unknown> | undefined
|
||||
let updatedInput: Record<string, unknown> | undefined;
|
||||
if (options.input) {
|
||||
try {
|
||||
updatedInput = JSON.parse(options.input)
|
||||
updatedInput = JSON.parse(options.input);
|
||||
} catch (err) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_JSON',
|
||||
code: "INVALID_JSON",
|
||||
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host })
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
|
||||
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
|
||||
if (!fetchResult) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = fetchResult.agent
|
||||
const resolvedAgentId = agent.id
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedAgentId = agent.id;
|
||||
|
||||
// Get pending permissions for this agent
|
||||
const pendingPermissions = agent.pendingPermissions || []
|
||||
const pendingPermissions = agent.pendingPermissions || [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: 'NO_PENDING_PERMISSIONS',
|
||||
code: "NO_PENDING_PERMISSIONS",
|
||||
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine which permissions to allow
|
||||
let permissionsToAllow: AgentPermissionRequest[]
|
||||
let permissionsToAllow: AgentPermissionRequest[];
|
||||
if (!reqId || options.all) {
|
||||
// Default: allow all pending permissions if no req_id specified
|
||||
// --all flag is kept as an explicit alias for clarity
|
||||
permissionsToAllow = pendingPermissions
|
||||
permissionsToAllow = pendingPermissions;
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
const permission = pendingPermissions.find(
|
||||
(p) => p.id === reqId || p.id.startsWith(reqId!)
|
||||
)
|
||||
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
|
||||
if (!permission) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: 'PERMISSION_NOT_FOUND',
|
||||
code: "PERMISSION_NOT_FOUND",
|
||||
message: `Permission request not found: ${reqId}`,
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
|
||||
}
|
||||
throw error
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
permissionsToAllow = [permission]
|
||||
permissionsToAllow = [permission];
|
||||
}
|
||||
|
||||
// Allow permissions
|
||||
const results: PermissionResponseItem[] = []
|
||||
const results: PermissionResponseItem[] = [];
|
||||
for (const permission of permissionsToAllow) {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: 'allow',
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
})
|
||||
});
|
||||
results.push({
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: 'allowed',
|
||||
})
|
||||
result: "allowed",
|
||||
});
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: results,
|
||||
schema: permitResponseSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw CommandErrors
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'ALLOW_PERMISSION_FAILED',
|
||||
code: "ALLOW_PERMISSION_FAILED",
|
||||
message: `Failed to allow permission: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +1,128 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentPermissionRequest } from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, CommandError } from '../../output/index.js'
|
||||
import { permitResponseSchema, type PermissionResponseItem } from './allow.js'
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
|
||||
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
|
||||
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
export interface PermitDenyOptions extends CommandOptions {
|
||||
all?: boolean
|
||||
message?: string
|
||||
interrupt?: boolean
|
||||
host?: string
|
||||
all?: boolean;
|
||||
message?: string;
|
||||
interrupt?: boolean;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runDenyCommand(
|
||||
agentIdOrPrefix: string,
|
||||
reqId: string | undefined,
|
||||
options: PermitDenyOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<PermitDenyResult> {
|
||||
const host = getDaemonHost({ host: options.host })
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!options.all && !reqId) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_ARGUMENT',
|
||||
message: 'Request ID is required unless --all is specified',
|
||||
details: 'Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_ARGUMENT",
|
||||
message: "Request ID is required unless --all is specified",
|
||||
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host })
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
|
||||
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
|
||||
if (!fetchResult) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: 'AGENT_NOT_FOUND',
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = fetchResult.agent
|
||||
const resolvedAgentId = agent.id
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedAgentId = agent.id;
|
||||
|
||||
// Get pending permissions for this agent
|
||||
const pendingPermissions = agent.pendingPermissions || []
|
||||
const pendingPermissions = agent.pendingPermissions || [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: 'NO_PENDING_PERMISSIONS',
|
||||
code: "NO_PENDING_PERMISSIONS",
|
||||
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine which permissions to deny
|
||||
let permissionsToDeny: AgentPermissionRequest[]
|
||||
let permissionsToDeny: AgentPermissionRequest[];
|
||||
if (options.all) {
|
||||
permissionsToDeny = pendingPermissions
|
||||
permissionsToDeny = pendingPermissions;
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
const permission = pendingPermissions.find(
|
||||
(p) => p.id === reqId || p.id.startsWith(reqId!)
|
||||
)
|
||||
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
|
||||
if (!permission) {
|
||||
await client.close()
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: 'PERMISSION_NOT_FOUND',
|
||||
code: "PERMISSION_NOT_FOUND",
|
||||
message: `Permission request not found: ${reqId}`,
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
|
||||
}
|
||||
throw error
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
permissionsToDeny = [permission]
|
||||
permissionsToDeny = [permission];
|
||||
}
|
||||
|
||||
// Deny permissions
|
||||
const results: PermissionResponseItem[] = []
|
||||
const results: PermissionResponseItem[] = [];
|
||||
for (const permission of permissionsToDeny) {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: 'deny',
|
||||
behavior: "deny",
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
})
|
||||
});
|
||||
results.push({
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: 'denied',
|
||||
})
|
||||
result: "denied",
|
||||
});
|
||||
}
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: results,
|
||||
schema: permitResponseSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw CommandErrors
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DENY_PERMISSION_FAILED',
|
||||
code: "DENY_PERMISSION_FAILED",
|
||||
message: `Failed to deny permission: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
import { Command } from 'commander'
|
||||
import { runLsCommand } from './ls.js'
|
||||
import { runAllowCommand } from './allow.js'
|
||||
import { runDenyCommand } from './deny.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runAllowCommand } from "./allow.js";
|
||||
import { runDenyCommand } from "./deny.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createPermitCommand(): Command {
|
||||
const permit = new Command('permit').description('Manage permission requests')
|
||||
const permit = new Command("permit").description("Manage permission requests");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
permit.command("ls").description("List all pending permissions"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
permit
|
||||
.command('ls')
|
||||
.description('List all pending permissions')
|
||||
).action(withOutput(runLsCommand))
|
||||
.command("allow")
|
||||
.description("Allow a permission request")
|
||||
.argument("<agent>", "Agent ID (or prefix)")
|
||||
.argument("[req_id]", "Permission request ID (optional if --all)")
|
||||
.option("--all", "Allow all pending permissions for this agent")
|
||||
.option("--input <json>", "Modified input parameters (JSON)"),
|
||||
).action(withOutput(runAllowCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
permit
|
||||
.command('allow')
|
||||
.description('Allow a permission request')
|
||||
.argument('<agent>', 'Agent ID (or prefix)')
|
||||
.argument('[req_id]', 'Permission request ID (optional if --all)')
|
||||
.option('--all', 'Allow all pending permissions for this agent')
|
||||
.option('--input <json>', 'Modified input parameters (JSON)')
|
||||
).action(withOutput(runAllowCommand))
|
||||
.command("deny")
|
||||
.description("Deny a permission request")
|
||||
.argument("<agent>", "Agent ID (or prefix)")
|
||||
.argument("[req_id]", "Permission request ID (optional if --all)")
|
||||
.option("--all", "Deny all pending permissions for this agent")
|
||||
.option("--message <msg>", "Denial reason message")
|
||||
.option("--interrupt", "Stop agent after denial"),
|
||||
).action(withOutput(runDenyCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
permit
|
||||
.command('deny')
|
||||
.description('Deny a permission request')
|
||||
.argument('<agent>', 'Agent ID (or prefix)')
|
||||
.argument('[req_id]', 'Permission request ID (optional if --all)')
|
||||
.option('--all', 'Deny all pending permissions for this agent')
|
||||
.option('--message <msg>', 'Denial reason message')
|
||||
.option('--interrupt', 'Stop agent after denial')
|
||||
).action(withOutput(runDenyCommand))
|
||||
|
||||
return permit
|
||||
return permit;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,94 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentPermissionRequest, AgentSnapshotPayload } from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest, AgentSnapshotPayload } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Permission list item for display */
|
||||
export interface PermissionListItem {
|
||||
id: string
|
||||
agentId: string
|
||||
agentShortId: string
|
||||
name: string
|
||||
description: string
|
||||
id: string;
|
||||
agentId: string;
|
||||
agentShortId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Schema for permit ls output */
|
||||
export const permitLsSchema: OutputSchema<PermissionListItem> = {
|
||||
idField: 'id',
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: 'AGENT', field: 'agentShortId', width: 12 },
|
||||
{ header: 'REQ_ID', field: 'id', width: 12 },
|
||||
{ header: 'TOOL', field: 'name', width: 20 },
|
||||
{ header: 'DESCRIPTION', field: 'description', width: 50 },
|
||||
{ header: "AGENT", field: "agentShortId", width: 12 },
|
||||
{ header: "REQ_ID", field: "id", width: 12 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 50 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
/** Transform agent snapshot + permission to list item */
|
||||
function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequest): PermissionListItem {
|
||||
function toListItem(
|
||||
agent: AgentSnapshotPayload,
|
||||
permission: AgentPermissionRequest,
|
||||
): PermissionListItem {
|
||||
return {
|
||||
id: permission.id.slice(0, 8),
|
||||
agentId: agent.id,
|
||||
agentShortId: agent.id.slice(0, 7),
|
||||
name: permission.name,
|
||||
description: permission.description ?? '-',
|
||||
}
|
||||
description: permission.description ?? "-",
|
||||
};
|
||||
}
|
||||
|
||||
export type PermitLsResult = ListResult<PermissionListItem>
|
||||
export type PermitLsResult = ListResult<PermissionListItem>;
|
||||
|
||||
export interface PermitLsOptions extends CommandOptions {
|
||||
host?: string
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runLsCommand(options: PermitLsOptions, _command: Command): Promise<PermitLsResult> {
|
||||
const host = getDaemonHost({ host: options.host })
|
||||
export async function runLsCommand(
|
||||
options: PermitLsOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitLsResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host })
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||
await client.close()
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
await client.close();
|
||||
|
||||
// Collect all pending permissions from all agents
|
||||
const items: PermissionListItem[] = []
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
|
||||
for (const permission of agent.pendingPermissions) {
|
||||
items.push(toListItem(agent, permission))
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'LIST_PERMISSIONS_FAILED',
|
||||
code: "LIST_PERMISSIONS_FAILED",
|
||||
message: `Failed to list permissions: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Command } from 'commander'
|
||||
import { runLsCommand } from './ls.js'
|
||||
import { runModelsCommand } from './models.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runModelsCommand } from "./models.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createProviderCommand(): Command {
|
||||
const provider = new Command('provider').description('Manage agent providers')
|
||||
const provider = new Command("provider").description("Manage agent providers");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider.command("ls").description("List available providers and status"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command('ls')
|
||||
.description('List available providers and status')
|
||||
).action(withOutput(runLsCommand))
|
||||
.command("models")
|
||||
.description("List models for a provider")
|
||||
.argument("<provider>", "Provider name (claude, codex, opencode)")
|
||||
.option("--thinking", "Include thinking option IDs for each model"),
|
||||
).action(withOutput(runModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command('models')
|
||||
.description('List models for a provider')
|
||||
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
||||
.option('--thinking', 'Include thinking option IDs for each model')
|
||||
).action(withOutput(runModelsCommand))
|
||||
|
||||
return provider
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
|
||||
/** Provider list item for display */
|
||||
export interface ProviderListItem {
|
||||
provider: string
|
||||
status: string
|
||||
defaultMode: string
|
||||
modes: string
|
||||
provider: string;
|
||||
status: string;
|
||||
defaultMode: string;
|
||||
modes: string;
|
||||
}
|
||||
|
||||
/** Static provider data - providers are built-in and don't require daemon */
|
||||
const PROVIDERS: ProviderListItem[] = [
|
||||
{
|
||||
provider: 'claude',
|
||||
status: 'available',
|
||||
defaultMode: 'default',
|
||||
modes: 'plan, default, bypass',
|
||||
provider: "claude",
|
||||
status: "available",
|
||||
defaultMode: "default",
|
||||
modes: "plan, default, bypass",
|
||||
},
|
||||
{
|
||||
provider: 'codex',
|
||||
status: 'available',
|
||||
defaultMode: 'auto',
|
||||
modes: 'read-only, auto, full-access',
|
||||
provider: "codex",
|
||||
status: "available",
|
||||
defaultMode: "auto",
|
||||
modes: "read-only, auto, full-access",
|
||||
},
|
||||
{
|
||||
provider: 'opencode',
|
||||
status: 'available',
|
||||
defaultMode: 'default',
|
||||
modes: 'plan, default, bypass',
|
||||
provider: "opencode",
|
||||
status: "available",
|
||||
defaultMode: "default",
|
||||
modes: "plan, default, bypass",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: 'provider',
|
||||
idField: "provider",
|
||||
columns: [
|
||||
{ header: 'PROVIDER', field: 'provider', width: 12 },
|
||||
{ header: "PROVIDER", field: "provider", width: 12 },
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'status',
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
width: 12,
|
||||
color: (value) => {
|
||||
if (value === 'available') return 'green'
|
||||
if (value === 'unavailable') return 'red'
|
||||
return undefined
|
||||
if (value === "available") return "green";
|
||||
if (value === "unavailable") return "red";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{ header: 'DEFAULT MODE', field: 'defaultMode', width: 14 },
|
||||
{ header: 'MODES', field: 'modes', width: 30 },
|
||||
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
|
||||
{ header: "MODES", field: "modes", width: 30 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export type ProviderLsResult = ListResult<ProviderListItem>
|
||||
export type ProviderLsResult = ListResult<ProviderListItem>;
|
||||
|
||||
export interface ProviderLsOptions extends CommandOptions {
|
||||
host?: string
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
_options: ProviderLsOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<ProviderLsResult> {
|
||||
// Provider data is static - no daemon connection needed
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: PROVIDERS,
|
||||
schema: providerLsSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,82 +1,81 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
|
||||
/** Model list item for display */
|
||||
export interface ModelListItem {
|
||||
model: string
|
||||
id: string
|
||||
description: string
|
||||
thinkingOptionIds: string[]
|
||||
defaultThinkingOptionId: string | null
|
||||
thinkingOptions: string
|
||||
model: string;
|
||||
id: string;
|
||||
description: string;
|
||||
thinkingOptionIds: string[];
|
||||
defaultThinkingOptionId: string | null;
|
||||
thinkingOptions: string;
|
||||
}
|
||||
|
||||
/** Schema for provider models output */
|
||||
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
||||
idField: 'id',
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: 'ID', field: 'id', width: 30 },
|
||||
{ header: 'MODEL', field: 'model', width: 30 },
|
||||
{ header: 'DESCRIPTION', field: 'description', width: 40 },
|
||||
{ header: "ID", field: "id", width: 30 },
|
||||
{ header: "MODEL", field: "model", width: 30 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 40 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
const providerModelsWithThinkingSchema: OutputSchema<ModelListItem> = {
|
||||
idField: 'id',
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: 'ID', field: 'id', width: 30 },
|
||||
{ header: 'MODEL', field: 'model', width: 30 },
|
||||
{ header: 'THINKING IDS', field: 'thinkingOptions', width: 40 },
|
||||
{ header: "ID", field: "id", width: 30 },
|
||||
{ header: "MODEL", field: "model", width: 30 },
|
||||
{ header: "THINKING IDS", field: "thinkingOptions", width: 40 },
|
||||
{
|
||||
header: 'DEFAULT THINKING',
|
||||
field: (item) => item.defaultThinkingOptionId ?? 'auto',
|
||||
header: "DEFAULT THINKING",
|
||||
field: (item) => item.defaultThinkingOptionId ?? "auto",
|
||||
width: 18,
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export type ProviderModelsResult = ListResult<ModelListItem>
|
||||
export type ProviderModelsResult = ListResult<ModelListItem>;
|
||||
|
||||
export interface ProviderModelsOptions extends CommandOptions {
|
||||
host?: string
|
||||
thinking?: boolean
|
||||
host?: string;
|
||||
thinking?: boolean;
|
||||
}
|
||||
|
||||
export async function runModelsCommand(
|
||||
provider: string,
|
||||
options: ProviderModelsOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<ProviderModelsResult> {
|
||||
const normalizedProvider = provider.toLowerCase()
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
|
||||
const client = await connectToDaemon({ host: options.host })
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
try {
|
||||
const result = await client.listProviderModels(normalizedProvider)
|
||||
const result = await client.listProviderModels(normalizedProvider);
|
||||
|
||||
if (result.error) {
|
||||
throw {
|
||||
code: 'PROVIDER_ERROR',
|
||||
code: "PROVIDER_ERROR",
|
||||
message: `Failed to fetch models for ${provider}: ${result.error}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const models: ModelListItem[] = (result.models ?? []).map((m) => ({
|
||||
model: m.label,
|
||||
id: m.id,
|
||||
description: m.description ?? '',
|
||||
description: m.description ?? "",
|
||||
thinkingOptionIds: (m.thinkingOptions ?? []).map((option) => option.id),
|
||||
defaultThinkingOptionId: m.defaultThinkingOptionId ?? null,
|
||||
thinkingOptions:
|
||||
(m.thinkingOptions ?? []).map((option) => option.id).join(', ') || 'none',
|
||||
}))
|
||||
thinkingOptions: (m.thinkingOptions ?? []).map((option) => option.id).join(", ") || "none",
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: models,
|
||||
schema: options.thinking ? providerModelsWithThinkingSchema : providerModelsSchema,
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
await client.close()
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
ListResult,
|
||||
OutputSchema,
|
||||
} from "../../output/index.js";
|
||||
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
interface SpeechDownloadRow {
|
||||
@@ -29,7 +24,7 @@ export interface SpeechDownloadOptions extends CommandOptions {
|
||||
|
||||
export async function runSpeechDownloadCommand(
|
||||
options: SpeechDownloadOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<SpeechDownloadResult> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
try {
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { Command } from 'commander'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
import { runSpeechModelsCommand } from './models.js'
|
||||
import { runSpeechDownloadCommand } from './download.js'
|
||||
import { addJsonAndDaemonHostOptions, collectMultiple } from '../../utils/command-options.js'
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { runSpeechModelsCommand } from "./models.js";
|
||||
import { runSpeechDownloadCommand } from "./download.js";
|
||||
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
|
||||
|
||||
export function createSpeechCommand(): Command {
|
||||
const speech = new Command('speech').description('Manage local speech models')
|
||||
const speech = new Command("speech").description("Manage local speech models");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
speech.command("models").description("List local speech model download status"),
|
||||
).action(withOutput(runSpeechModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
speech
|
||||
.command('models')
|
||||
.description('List local speech model download status')
|
||||
).action(withOutput(runSpeechModelsCommand))
|
||||
.command("download")
|
||||
.description("Download local speech models")
|
||||
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
|
||||
).action(withOutput(runSpeechDownloadCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
speech
|
||||
.command('download')
|
||||
.description('Download local speech models')
|
||||
.option('--model <id>', 'Model ID to download (repeatable)', collectMultiple, [])
|
||||
).action(withOutput(runSpeechDownloadCommand))
|
||||
|
||||
return speech
|
||||
return speech;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
ListResult,
|
||||
OutputSchema,
|
||||
} from "../../output/index.js";
|
||||
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
interface SpeechModelListItem {
|
||||
@@ -39,7 +34,7 @@ export interface SpeechModelsOptions extends CommandOptions {
|
||||
|
||||
export async function runSpeechModelsCommand(
|
||||
options: SpeechModelsOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<SpeechModelsResult> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
try {
|
||||
|
||||
@@ -1,129 +1,134 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
|
||||
/** Result type for worktree archive command */
|
||||
export interface WorktreeArchiveResult {
|
||||
name: string
|
||||
status: 'archived'
|
||||
removedAgents: string[]
|
||||
name: string;
|
||||
status: "archived";
|
||||
removedAgents: string[];
|
||||
}
|
||||
|
||||
/** Schema for archive command output */
|
||||
export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
|
||||
idField: 'name',
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: 'NAME', field: 'name' },
|
||||
{ header: 'STATUS', field: 'status' },
|
||||
{ header: "NAME", field: "name" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{
|
||||
header: 'REMOVED AGENTS',
|
||||
field: (item) => item.removedAgents.length > 0 ? item.removedAgents.join(', ') : '-',
|
||||
header: "REMOVED AGENTS",
|
||||
field: (item) => (item.removedAgents.length > 0 ? item.removedAgents.join(", ") : "-"),
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export interface WorktreeArchiveOptions extends CommandOptions {
|
||||
host?: string
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>
|
||||
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
|
||||
|
||||
export async function runArchiveCommand(
|
||||
nameArg: string,
|
||||
options: WorktreeArchiveOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<WorktreeArchiveCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host })
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!nameArg || nameArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: 'MISSING_WORKTREE_NAME',
|
||||
message: 'Worktree name is required',
|
||||
details: 'Usage: paseo worktree archive <name>',
|
||||
}
|
||||
throw error
|
||||
code: "MISSING_WORKTREE_NAME",
|
||||
message: "Worktree name is required",
|
||||
details: "Usage: paseo worktree archive <name>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host })
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the list of worktrees first to resolve the name
|
||||
const listResponse = await client.getPaseoWorktreeList({})
|
||||
const listResponse = await client.getPaseoWorktreeList({});
|
||||
|
||||
if (listResponse.error) {
|
||||
const error: CommandError = {
|
||||
code: 'WORKTREE_LIST_FAILED',
|
||||
code: "WORKTREE_LIST_FAILED",
|
||||
message: `Failed to list worktrees: ${listResponse.error.message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Find the worktree by name or branch
|
||||
const worktree = listResponse.worktrees.find((wt) => {
|
||||
const name = wt.worktreePath.split('/').pop()
|
||||
return name === nameArg || wt.branchName === nameArg
|
||||
})
|
||||
const name = wt.worktreePath.split("/").pop();
|
||||
return name === nameArg || wt.branchName === nameArg;
|
||||
});
|
||||
|
||||
if (!worktree) {
|
||||
const error: CommandError = {
|
||||
code: 'WORKTREE_NOT_FOUND',
|
||||
code: "WORKTREE_NOT_FOUND",
|
||||
message: `Worktree not found: ${nameArg}`,
|
||||
details: 'Use "paseo worktree ls" to list available worktrees',
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Archive the worktree
|
||||
const response = await client.archivePaseoWorktree({
|
||||
worktreePath: worktree.worktreePath,
|
||||
})
|
||||
});
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
if (response.error) {
|
||||
const error: CommandError = {
|
||||
code: 'WORKTREE_ARCHIVE_FAILED',
|
||||
code: "WORKTREE_ARCHIVE_FAILED",
|
||||
message: `Failed to archive worktree: ${response.error.message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const worktreeName = worktree.worktreePath.split('/').pop() ?? nameArg
|
||||
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: {
|
||||
name: worktreeName,
|
||||
status: 'archived',
|
||||
status: "archived",
|
||||
removedAgents: response.removedAgents ?? [],
|
||||
},
|
||||
schema: archiveSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'WORKTREE_ARCHIVE_FAILED',
|
||||
code: "WORKTREE_ARCHIVE_FAILED",
|
||||
message: `Failed to archive worktree: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { Command } from 'commander'
|
||||
import { runLsCommand } from './ls.js'
|
||||
import { runArchiveCommand } from './archive.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createWorktreeCommand(): Command {
|
||||
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
|
||||
const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
worktree.command("ls").description("List Paseo-managed git worktrees"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
worktree
|
||||
.command('ls')
|
||||
.description('List Paseo-managed git worktrees')
|
||||
).action(withOutput(runLsCommand))
|
||||
.command("archive")
|
||||
.description("Archive a worktree (removes worktree and associated branch)")
|
||||
.argument("<name>", "Worktree name or branch name"),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
worktree
|
||||
.command('archive')
|
||||
.description('Archive a worktree (removes worktree and associated branch)')
|
||||
.argument('<name>', 'Worktree name or branch name')
|
||||
).action(withOutput(runArchiveCommand))
|
||||
|
||||
return worktree
|
||||
return worktree;
|
||||
}
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
import type { Command } from 'commander'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, join, sep } from 'node:path'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import type { Command } from "commander";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join, sep } from "node:path";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Worktree list item for display */
|
||||
export interface WorktreeListItem {
|
||||
name: string
|
||||
branch: string
|
||||
cwd: string
|
||||
agent: string
|
||||
name: string;
|
||||
branch: string;
|
||||
cwd: string;
|
||||
agent: string;
|
||||
}
|
||||
|
||||
/** Shorten home directory in path */
|
||||
function shortenPath(path: string): string {
|
||||
const home = process.env.HOME
|
||||
const home = process.env.HOME;
|
||||
if (home && path.startsWith(home)) {
|
||||
return '~' + path.slice(home.length)
|
||||
return "~" + path.slice(home.length);
|
||||
}
|
||||
return path
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Extract worktree name from path */
|
||||
function extractWorktreeName(path: string): string {
|
||||
return basename(path)
|
||||
return basename(path);
|
||||
}
|
||||
|
||||
export function resolvePaseoHomePath(): string {
|
||||
return process.env.PASEO_HOME ?? join(homedir(), '.paseo')
|
||||
return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
||||
}
|
||||
|
||||
export function resolvePaseoWorktreesDir(): string {
|
||||
return join(resolvePaseoHomePath(), 'worktrees')
|
||||
return join(resolvePaseoHomePath(), "worktrees");
|
||||
}
|
||||
|
||||
function isAgentInManagedWorktree(agentCwd: string): boolean {
|
||||
const worktreesDir = resolvePaseoWorktreesDir()
|
||||
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep)
|
||||
const worktreesDir = resolvePaseoWorktreesDir();
|
||||
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep);
|
||||
}
|
||||
|
||||
/** Schema for worktree ls output */
|
||||
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
|
||||
idField: 'name',
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: 'NAME', field: 'name', width: 20 },
|
||||
{ header: 'BRANCH', field: 'branch', width: 25 },
|
||||
{ header: 'CWD', field: 'cwd', width: 45 },
|
||||
{ header: 'AGENT', field: 'agent', width: 10 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "BRANCH", field: "branch", width: 25 },
|
||||
{ header: "CWD", field: "cwd", width: 45 },
|
||||
{ header: "AGENT", field: "agent", width: 10 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export type WorktreeLsResult = ListResult<WorktreeListItem>
|
||||
export type WorktreeLsResult = ListResult<WorktreeListItem>;
|
||||
|
||||
export interface WorktreeLsOptions extends CommandOptions {
|
||||
host?: string
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: WorktreeLsOptions,
|
||||
_command: Command
|
||||
_command: Command,
|
||||
): Promise<WorktreeLsResult> {
|
||||
const host = getDaemonHost({ host: options.host })
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
let client
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host })
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
|
||||
// Get worktree list from daemon
|
||||
const response = await client.getPaseoWorktreeList({})
|
||||
const response = await client.getPaseoWorktreeList({});
|
||||
|
||||
await client.close()
|
||||
await client.close();
|
||||
|
||||
if (response.error) {
|
||||
const error: CommandError = {
|
||||
code: 'WORKTREE_LIST_FAILED',
|
||||
code: "WORKTREE_LIST_FAILED",
|
||||
message: `Failed to list worktrees: ${response.error.message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build a map of worktree paths to agent IDs
|
||||
const worktreeAgentMap = new Map<string, string>()
|
||||
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) => ({
|
||||
name: extractWorktreeName(wt.worktreePath),
|
||||
branch: wt.branchName ?? '-',
|
||||
branch: wt.branchName ?? "-",
|
||||
cwd: shortenPath(wt.worktreePath),
|
||||
agent: worktreeAgentMap.get(wt.worktreePath) ?? '-',
|
||||
}))
|
||||
agent: worktreeAgentMap.get(wt.worktreePath) ?? "-",
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: worktreeLsSchema,
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
await client.close().catch(() => {});
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
throw err
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: 'WORKTREE_LIST_FAILED',
|
||||
code: "WORKTREE_LIST_FAILED",
|
||||
message: `Failed to list worktrees: ${message}`,
|
||||
}
|
||||
throw error
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCli } from './cli.js'
|
||||
import { createCli } from "./cli.js";
|
||||
|
||||
const program = createCli()
|
||||
const program = createCli();
|
||||
if (process.argv.length <= 2) {
|
||||
process.argv.push('onboard')
|
||||
process.argv.push("onboard");
|
||||
}
|
||||
program.parse()
|
||||
program.parse();
|
||||
|
||||
@@ -47,16 +47,16 @@ export type {
|
||||
ListResult,
|
||||
AnyCommandResult,
|
||||
CommandError,
|
||||
} from './types.js'
|
||||
} from "./types.js";
|
||||
|
||||
// Renderers
|
||||
export { renderTable, renderTableHeader, renderTableRow } from './table.js'
|
||||
export { renderJson, renderJsonLine } from './json.js'
|
||||
export { renderYaml, renderYamlDoc } from './yaml.js'
|
||||
export { renderQuiet } from './quiet.js'
|
||||
export { renderTable, renderTableHeader, renderTableRow } from "./table.js";
|
||||
export { renderJson, renderJsonLine } from "./json.js";
|
||||
export { renderYaml, renderYamlDoc } from "./yaml.js";
|
||||
export { renderQuiet } from "./quiet.js";
|
||||
|
||||
// Main render function
|
||||
export { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
|
||||
export { render, renderError, toCommandError, defaultOutputOptions } from "./render.js";
|
||||
|
||||
// Command wrapper
|
||||
export { withOutput, createOutputOptions, type CommandOptions } from './with-output.js'
|
||||
export { withOutput, createOutputOptions, type CommandOptions } from "./with-output.js";
|
||||
|
||||
@@ -4,41 +4,38 @@
|
||||
* Renders structured data as formatted JSON for machine consumption.
|
||||
*/
|
||||
|
||||
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||
import type { AnyCommandResult, OutputOptions } from "./types.js";
|
||||
|
||||
/** Render command result as JSON */
|
||||
export function renderJson<T>(
|
||||
result: AnyCommandResult<T>,
|
||||
_options: OutputOptions
|
||||
): string {
|
||||
const { schema } = result
|
||||
export function renderJson<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
|
||||
const { schema } = result;
|
||||
|
||||
// Apply custom serializer if provided
|
||||
if (schema.serialize) {
|
||||
if (result.type === 'list') {
|
||||
if (result.type === "list") {
|
||||
// If all items serialize to the same object, return just one
|
||||
// This handles the case where a list of key-value rows should serialize
|
||||
// to a single structured object
|
||||
const serialized = result.data.map((item) => schema.serialize!(item))
|
||||
const serialized = result.data.map((item) => schema.serialize!(item));
|
||||
if (serialized.length > 0) {
|
||||
const first = JSON.stringify(serialized[0])
|
||||
const allSame = serialized.every((s) => JSON.stringify(s) === first)
|
||||
const first = JSON.stringify(serialized[0]);
|
||||
const allSame = serialized.every((s) => JSON.stringify(s) === first);
|
||||
if (allSame) {
|
||||
return JSON.stringify(serialized[0], null, 2)
|
||||
return JSON.stringify(serialized[0], null, 2);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(serialized, null, 2)
|
||||
return JSON.stringify(serialized, null, 2);
|
||||
} else {
|
||||
const serialized = schema.serialize(result.data)
|
||||
return JSON.stringify(serialized, null, 2)
|
||||
const serialized = schema.serialize(result.data);
|
||||
return JSON.stringify(serialized, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(result.data, null, 2)
|
||||
return JSON.stringify(result.data, null, 2);
|
||||
}
|
||||
|
||||
/** Render a single item as JSON line (for NDJSON streaming) */
|
||||
export function renderJsonLine<T>(item: T, serialize?: (data: T) => unknown): string {
|
||||
const output = serialize ? serialize(item) : item
|
||||
return JSON.stringify(output)
|
||||
const output = serialize ? serialize(item) : item;
|
||||
return JSON.stringify(output);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,21 @@
|
||||
* Outputs only ID fields, one per line. Useful for scripting and pipelines.
|
||||
*/
|
||||
|
||||
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||
import type { AnyCommandResult, OutputOptions } from "./types.js";
|
||||
|
||||
/** Extract ID from item using schema definition */
|
||||
function getId<T>(item: T, idField: keyof T | ((item: T) => string)): string {
|
||||
if (typeof idField === 'function') {
|
||||
return idField(item)
|
||||
if (typeof idField === "function") {
|
||||
return idField(item);
|
||||
}
|
||||
return String(item[idField])
|
||||
return String(item[idField]);
|
||||
}
|
||||
|
||||
/** Render command result in quiet mode (IDs only) */
|
||||
export function renderQuiet<T>(
|
||||
result: AnyCommandResult<T>,
|
||||
_options: OutputOptions
|
||||
): string {
|
||||
if (result.type === 'single') {
|
||||
return getId(result.data, result.schema.idField)
|
||||
export function renderQuiet<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
|
||||
if (result.type === "single") {
|
||||
return getId(result.data, result.schema.idField);
|
||||
} else {
|
||||
return result.data.map((item) => getId(item, result.schema.idField)).join('\n')
|
||||
return result.data.map((item) => getId(item, result.schema.idField)).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,100 +4,97 @@
|
||||
* Selects the appropriate renderer based on output options.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk'
|
||||
import YAML from 'yaml'
|
||||
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
|
||||
import { renderTable } from './table.js'
|
||||
import { renderJson } from './json.js'
|
||||
import { renderYaml } from './yaml.js'
|
||||
import { renderQuiet } from './quiet.js'
|
||||
import chalk from "chalk";
|
||||
import YAML from "yaml";
|
||||
import type { AnyCommandResult, CommandError, OutputOptions } from "./types.js";
|
||||
import { renderTable } from "./table.js";
|
||||
import { renderJson } from "./json.js";
|
||||
import { renderYaml } from "./yaml.js";
|
||||
import { renderQuiet } from "./quiet.js";
|
||||
|
||||
/** Default output options */
|
||||
export const defaultOutputOptions: OutputOptions = {
|
||||
format: 'table',
|
||||
format: "table",
|
||||
quiet: false,
|
||||
noHeaders: false,
|
||||
noColor: false,
|
||||
}
|
||||
};
|
||||
|
||||
/** Render command result to string based on output options */
|
||||
export function render<T>(
|
||||
result: AnyCommandResult<T>,
|
||||
options: Partial<OutputOptions> = {}
|
||||
options: Partial<OutputOptions> = {},
|
||||
): string {
|
||||
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
|
||||
const opts: OutputOptions = { ...defaultOutputOptions, ...options };
|
||||
|
||||
// Quiet mode takes precedence
|
||||
if (opts.quiet) {
|
||||
return renderQuiet(result, opts)
|
||||
return renderQuiet(result, opts);
|
||||
}
|
||||
|
||||
// Dispatch to format-specific renderer
|
||||
switch (opts.format) {
|
||||
case 'json':
|
||||
return renderJson(result, opts)
|
||||
case 'yaml':
|
||||
return renderYaml(result, opts)
|
||||
case 'table':
|
||||
case "json":
|
||||
return renderJson(result, opts);
|
||||
case "yaml":
|
||||
return renderYaml(result, opts);
|
||||
case "table":
|
||||
default:
|
||||
return renderTable(result, opts)
|
||||
return renderTable(result, opts);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert an unknown error to a CommandError */
|
||||
export function toCommandError(error: unknown): CommandError {
|
||||
if (isCommandError(error)) {
|
||||
return error
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: error.message,
|
||||
details: error.stack,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: String(error),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Type guard for CommandError */
|
||||
function isCommandError(error: unknown): error is CommandError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
'message' in error &&
|
||||
typeof (error as CommandError).code === 'string' &&
|
||||
typeof (error as CommandError).message === 'string'
|
||||
)
|
||||
"code" in error &&
|
||||
"message" in error &&
|
||||
typeof (error as CommandError).code === "string" &&
|
||||
typeof (error as CommandError).message === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** Render an error to string based on output options */
|
||||
export function renderError(
|
||||
error: CommandError,
|
||||
options: Partial<OutputOptions> = {}
|
||||
): string {
|
||||
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
|
||||
export function renderError(error: CommandError, options: Partial<OutputOptions> = {}): string {
|
||||
const opts: OutputOptions = { ...defaultOutputOptions, ...options };
|
||||
|
||||
if (opts.format === 'json') {
|
||||
return JSON.stringify({ error }, null, 2)
|
||||
if (opts.format === "json") {
|
||||
return JSON.stringify({ error }, null, 2);
|
||||
}
|
||||
|
||||
if (opts.format === 'yaml') {
|
||||
return YAML.stringify({ error })
|
||||
if (opts.format === "yaml") {
|
||||
return YAML.stringify({ error });
|
||||
}
|
||||
|
||||
// Table/default format: human-readable error
|
||||
const prefix = opts.noColor ? 'Error: ' : chalk.red('Error: ')
|
||||
const message = error.message
|
||||
const prefix = opts.noColor ? "Error: " : chalk.red("Error: ");
|
||||
const message = error.message;
|
||||
|
||||
if (error.details && typeof error.details === 'string') {
|
||||
return `${prefix}${message}\n${error.details}`
|
||||
if (error.details && typeof error.details === "string") {
|
||||
return `${prefix}${message}\n${error.details}`;
|
||||
}
|
||||
|
||||
return `${prefix}${message}`
|
||||
return `${prefix}${message}`;
|
||||
}
|
||||
|
||||
@@ -4,49 +4,40 @@
|
||||
* Renders structured data as aligned ASCII tables with optional color support.
|
||||
*/
|
||||
|
||||
import chalk, { type ChalkInstance } from 'chalk'
|
||||
import type {
|
||||
AnyCommandResult,
|
||||
ColumnDef,
|
||||
OutputOptions,
|
||||
OutputSchema,
|
||||
} from './types.js'
|
||||
import chalk, { type ChalkInstance } from "chalk";
|
||||
import type { AnyCommandResult, ColumnDef, OutputOptions, OutputSchema } from "./types.js";
|
||||
|
||||
// ANSI escape code regex for stripping colors when measuring width
|
||||
const ANSI_REGEX =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
/** Strip ANSI escape codes from a string */
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(ANSI_REGEX, '')
|
||||
return str.replace(ANSI_REGEX, "");
|
||||
}
|
||||
|
||||
/** Get visible string length (excluding ANSI codes) */
|
||||
function visibleLength(str: string): number {
|
||||
return stripAnsi(str).length
|
||||
return stripAnsi(str).length;
|
||||
}
|
||||
|
||||
/** Pad a cell to the specified width with alignment */
|
||||
function padCell(
|
||||
cell: string,
|
||||
width: number,
|
||||
align: 'left' | 'right' | 'center'
|
||||
): string {
|
||||
const visible = visibleLength(cell)
|
||||
const padding = Math.max(0, width - visible)
|
||||
function padCell(cell: string, width: number, align: "left" | "right" | "center"): string {
|
||||
const visible = visibleLength(cell);
|
||||
const padding = Math.max(0, width - visible);
|
||||
|
||||
switch (align) {
|
||||
case 'right':
|
||||
return ' '.repeat(padding) + cell
|
||||
case 'center': {
|
||||
const left = Math.floor(padding / 2)
|
||||
const right = padding - left
|
||||
return ' '.repeat(left) + cell + ' '.repeat(right)
|
||||
case "right":
|
||||
return " ".repeat(padding) + cell;
|
||||
case "center": {
|
||||
const left = Math.floor(padding / 2);
|
||||
const right = padding - left;
|
||||
return " ".repeat(left) + cell + " ".repeat(right);
|
||||
}
|
||||
case 'left':
|
||||
case "left":
|
||||
default:
|
||||
return cell + ' '.repeat(padding)
|
||||
return cell + " ".repeat(padding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,15 +56,15 @@ function applyColor(str: string, colorName: string): string {
|
||||
grey: chalk.grey,
|
||||
dim: chalk.dim,
|
||||
bold: chalk.bold,
|
||||
}
|
||||
};
|
||||
|
||||
const colorFn = colorMap[colorName]
|
||||
return colorFn ? colorFn(str) : str
|
||||
const colorFn = colorMap[colorName];
|
||||
return colorFn ? colorFn(str) : str;
|
||||
}
|
||||
|
||||
/** Extract value from item using field definition */
|
||||
function getValue<T>(item: T, field: keyof T | ((item: T) => unknown)): unknown {
|
||||
return typeof field === 'function' ? field(item) : item[field]
|
||||
return typeof field === "function" ? field(item) : item[field];
|
||||
}
|
||||
|
||||
/** Render a single table row */
|
||||
@@ -81,106 +72,99 @@ function renderRow<T>(
|
||||
item: T,
|
||||
columns: ColumnDef<T>[],
|
||||
widths: number[],
|
||||
options: OutputOptions
|
||||
options: OutputOptions,
|
||||
): string {
|
||||
return columns
|
||||
.map((col, colIndex) => {
|
||||
const value = getValue(item, col.field)
|
||||
let cell = String(value ?? '')
|
||||
const width = widths[colIndex]
|
||||
const value = getValue(item, col.field);
|
||||
let cell = String(value ?? "");
|
||||
const width = widths[colIndex];
|
||||
|
||||
// Apply color if enabled
|
||||
if (col.color && !options.noColor) {
|
||||
const colorName = col.color(value, item)
|
||||
const colorName = col.color(value, item);
|
||||
if (colorName) {
|
||||
cell = applyColor(cell, colorName)
|
||||
cell = applyColor(cell, colorName);
|
||||
}
|
||||
}
|
||||
|
||||
return padCell(cell, width ?? 0, col.align ?? 'left')
|
||||
return padCell(cell, width ?? 0, col.align ?? "left");
|
||||
})
|
||||
.join(' ')
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Render header row */
|
||||
function renderHeader<T>(
|
||||
columns: ColumnDef<T>[],
|
||||
widths: number[],
|
||||
options: OutputOptions
|
||||
options: OutputOptions,
|
||||
): string {
|
||||
const headerRow = columns
|
||||
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? 'left'))
|
||||
.join(' ')
|
||||
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? "left"))
|
||||
.join(" ");
|
||||
|
||||
return options.noColor ? headerRow : chalk.bold(headerRow)
|
||||
return options.noColor ? headerRow : chalk.bold(headerRow);
|
||||
}
|
||||
|
||||
/** Calculate column widths based on content and hints */
|
||||
function calculateWidths<T>(
|
||||
data: T[],
|
||||
columns: ColumnDef<T>[],
|
||||
includeHeaders: boolean
|
||||
): number[] {
|
||||
function calculateWidths<T>(data: T[], columns: ColumnDef<T>[], includeHeaders: boolean): number[] {
|
||||
return columns.map((col) => {
|
||||
// Start with header width if including headers
|
||||
let maxWidth = includeHeaders ? col.header.length : 0
|
||||
let maxWidth = includeHeaders ? col.header.length : 0;
|
||||
|
||||
// Check all data values
|
||||
for (const item of data) {
|
||||
const value = getValue(item, col.field)
|
||||
const str = String(value ?? '')
|
||||
maxWidth = Math.max(maxWidth, visibleLength(str))
|
||||
const value = getValue(item, col.field);
|
||||
const str = String(value ?? "");
|
||||
maxWidth = Math.max(maxWidth, visibleLength(str));
|
||||
}
|
||||
|
||||
// Apply width hint if specified (minimum width)
|
||||
if (col.width) {
|
||||
maxWidth = Math.max(maxWidth, col.width)
|
||||
maxWidth = Math.max(maxWidth, col.width);
|
||||
}
|
||||
|
||||
return maxWidth
|
||||
})
|
||||
return maxWidth;
|
||||
});
|
||||
}
|
||||
|
||||
/** Render a list result as a table */
|
||||
export function renderTable<T>(
|
||||
result: AnyCommandResult<T>,
|
||||
options: OutputOptions
|
||||
): string {
|
||||
const { schema } = result
|
||||
const data = result.type === 'list' ? result.data : [result.data]
|
||||
export function renderTable<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
|
||||
const { schema } = result;
|
||||
const data = result.type === "list" ? result.data : [result.data];
|
||||
|
||||
if (data.length === 0) {
|
||||
return ''
|
||||
return "";
|
||||
}
|
||||
|
||||
const columns = schema.columns as ColumnDef<T>[]
|
||||
const includeHeaders = !options.noHeaders
|
||||
const widths = calculateWidths(data, columns, includeHeaders)
|
||||
const columns = schema.columns as ColumnDef<T>[];
|
||||
const includeHeaders = !options.noHeaders;
|
||||
const widths = calculateWidths(data, columns, includeHeaders);
|
||||
|
||||
const lines: string[] = []
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add header row
|
||||
if (includeHeaders) {
|
||||
lines.push(renderHeader(columns, widths, options))
|
||||
lines.push(renderHeader(columns, widths, options));
|
||||
}
|
||||
|
||||
// Add data rows
|
||||
for (const item of data) {
|
||||
lines.push(renderRow(item, columns, widths, options))
|
||||
lines.push(renderRow(item, columns, widths, options));
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Render just a table header (for streaming) */
|
||||
export function renderTableHeader<T>(
|
||||
schema: OutputSchema<T>,
|
||||
options: OutputOptions,
|
||||
widths?: number[]
|
||||
widths?: number[],
|
||||
): string {
|
||||
const columns = schema.columns
|
||||
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
|
||||
return renderHeader(columns, actualWidths, options)
|
||||
const columns = schema.columns;
|
||||
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length);
|
||||
return renderHeader(columns, actualWidths, options);
|
||||
}
|
||||
|
||||
/** Render just a table row (for streaming) */
|
||||
@@ -188,9 +172,9 @@ export function renderTableRow<T>(
|
||||
item: T,
|
||||
schema: OutputSchema<T>,
|
||||
options: OutputOptions,
|
||||
widths?: number[]
|
||||
widths?: number[],
|
||||
): string {
|
||||
const columns = schema.columns
|
||||
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
|
||||
return renderRow(item, columns, actualWidths, options)
|
||||
const columns = schema.columns;
|
||||
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length);
|
||||
return renderRow(item, columns, actualWidths, options);
|
||||
}
|
||||
|
||||
@@ -6,74 +6,74 @@
|
||||
*/
|
||||
|
||||
/** Supported output formats */
|
||||
export type OutputFormat = 'table' | 'json' | 'yaml'
|
||||
export type OutputFormat = "table" | "json" | "yaml";
|
||||
|
||||
/** Options controlling output rendering */
|
||||
export interface OutputOptions {
|
||||
/** Output format (table, json, yaml) */
|
||||
format: OutputFormat
|
||||
format: OutputFormat;
|
||||
/** Minimal output - IDs only */
|
||||
quiet: boolean
|
||||
quiet: boolean;
|
||||
/** Omit table headers */
|
||||
noHeaders: boolean
|
||||
noHeaders: boolean;
|
||||
/** Disable color output */
|
||||
noColor: boolean
|
||||
noColor: boolean;
|
||||
}
|
||||
|
||||
/** Column definition for table output */
|
||||
export interface ColumnDef<T> {
|
||||
/** Header text for the column */
|
||||
header: string
|
||||
header: string;
|
||||
/** Field key or accessor function */
|
||||
field: keyof T | ((item: T) => unknown)
|
||||
field: keyof T | ((item: T) => unknown);
|
||||
/** Optional width hint (characters) */
|
||||
width?: number
|
||||
width?: number;
|
||||
/** Optional alignment */
|
||||
align?: 'left' | 'right' | 'center'
|
||||
align?: "left" | "right" | "center";
|
||||
/** Optional color function - returns chalk color name */
|
||||
color?: (value: unknown, item: T) => string | undefined
|
||||
color?: (value: unknown, item: T) => string | undefined;
|
||||
}
|
||||
|
||||
/** Schema describing how to render command output */
|
||||
export interface OutputSchema<T> {
|
||||
/** Field to use for quiet mode (--quiet outputs just this) */
|
||||
idField: keyof T | ((item: T) => string)
|
||||
idField: keyof T | ((item: T) => string);
|
||||
/** Column definitions for table output */
|
||||
columns: ColumnDef<T>[]
|
||||
columns: ColumnDef<T>[];
|
||||
/** Optional: transform data before JSON/YAML output */
|
||||
serialize?: (data: T) => unknown
|
||||
serialize?: (data: T) => unknown;
|
||||
}
|
||||
|
||||
/** Result type for commands returning a single item */
|
||||
export interface SingleResult<T> {
|
||||
type: 'single'
|
||||
type: "single";
|
||||
/** The structured data to render */
|
||||
data: T
|
||||
data: T;
|
||||
/** Schema describing how to render this data (for item type T) */
|
||||
schema: OutputSchema<T>
|
||||
schema: OutputSchema<T>;
|
||||
}
|
||||
|
||||
/** Result type for commands returning a list */
|
||||
export interface ListResult<T> {
|
||||
type: 'list'
|
||||
type: "list";
|
||||
/** The structured data to render */
|
||||
data: T[]
|
||||
data: T[];
|
||||
/** Schema describing how to render this data (for item type T) */
|
||||
schema: OutputSchema<T>
|
||||
schema: OutputSchema<T>;
|
||||
}
|
||||
|
||||
/** Union type for all command results */
|
||||
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
|
||||
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>;
|
||||
|
||||
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
|
||||
export type CommandResult<T> = SingleResult<T> | ListResult<T>
|
||||
export type CommandResult<T> = SingleResult<T> | ListResult<T>;
|
||||
|
||||
/** Structured error for command failures */
|
||||
export interface CommandError {
|
||||
/** Machine-readable error code */
|
||||
code: string
|
||||
code: string;
|
||||
/** Human-readable message */
|
||||
message: string
|
||||
message: string;
|
||||
/** Additional context */
|
||||
details?: unknown
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
@@ -4,51 +4,51 @@
|
||||
* Wraps command handlers to automatically render results and handle errors.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
|
||||
import { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
|
||||
import type { Command } from "commander";
|
||||
import type { AnyCommandResult, CommandError, OutputOptions } from "./types.js";
|
||||
import { render, renderError, toCommandError, defaultOutputOptions } from "./render.js";
|
||||
|
||||
/** Options that include output settings from global options */
|
||||
export interface CommandOptions extends Partial<OutputOptions> {
|
||||
[key: string]: unknown
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function normalizeFormat(raw: unknown): OutputOptions['format'] {
|
||||
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
|
||||
function normalizeFormat(raw: unknown): OutputOptions["format"] {
|
||||
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||
|
||||
// Common user expectation: "cli" means "table/human"
|
||||
if (value === 'cli') return 'table'
|
||||
if (value === "cli") return "table";
|
||||
|
||||
if (value === 'table' || value === 'json' || value === 'yaml') return value
|
||||
if (value === "table" || value === "json" || value === "yaml") return value;
|
||||
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_FORMAT',
|
||||
code: "INVALID_FORMAT",
|
||||
message: `Unsupported output format: ${String(raw)}`,
|
||||
details: 'Supported formats: table, json, yaml',
|
||||
}
|
||||
throw error
|
||||
details: "Supported formats: table, json, yaml",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
/** Extract output options from command options */
|
||||
function extractOutputOptions(options: CommandOptions): OutputOptions {
|
||||
const hasStructuredOutputSchema =
|
||||
typeof options.outputSchema === 'string' && options.outputSchema.trim().length > 0
|
||||
typeof options.outputSchema === "string" && options.outputSchema.trim().length > 0;
|
||||
|
||||
if (hasStructuredOutputSchema) {
|
||||
return {
|
||||
format: 'json',
|
||||
format: "json",
|
||||
quiet: false,
|
||||
noHeaders: options.headers === false,
|
||||
noColor: options.color === false,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
|
||||
format: options.json ? "json" : normalizeFormat(options.format ?? defaultOutputOptions.format),
|
||||
quiet: options.quiet ?? defaultOutputOptions.quiet,
|
||||
noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false
|
||||
noColor: options.color === false, // Commander uses --no-color -> color: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,37 +71,35 @@ function extractOutputOptions(options: CommandOptions): OutputOptions {
|
||||
* ```
|
||||
*/
|
||||
export function withOutput<T, Args extends unknown[]>(
|
||||
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>
|
||||
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>,
|
||||
): (...args: [...Args, CommandOptions, Command]) => Promise<void> {
|
||||
return async (...args) => {
|
||||
// Last two args are options and command
|
||||
const command = args[args.length - 1] as Command
|
||||
const command = args[args.length - 1] as Command;
|
||||
// Use optsWithGlobals() to get both local and global options
|
||||
const options = command.optsWithGlobals() as CommandOptions
|
||||
const outputOptions = extractOutputOptions(options)
|
||||
const options = command.optsWithGlobals() as CommandOptions;
|
||||
const outputOptions = extractOutputOptions(options);
|
||||
|
||||
try {
|
||||
const result = await handler(...args)
|
||||
const output = render(result, outputOptions)
|
||||
const result = await handler(...args);
|
||||
const output = render(result, outputOptions);
|
||||
|
||||
if (output) {
|
||||
process.stdout.write(output + '\n')
|
||||
process.stdout.write(output + "\n");
|
||||
}
|
||||
} catch (error) {
|
||||
const commandError = toCommandError(error)
|
||||
const errorOutput = renderError(commandError, outputOptions)
|
||||
process.stderr.write(errorOutput + '\n')
|
||||
process.exit(1)
|
||||
const commandError = toCommandError(error);
|
||||
const errorOutput = renderError(commandError, outputOptions);
|
||||
process.stderr.write(errorOutput + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create output options from partial input.
|
||||
* Useful for testing or manual rendering.
|
||||
*/
|
||||
export function createOutputOptions(
|
||||
partial: Partial<OutputOptions> = {}
|
||||
): OutputOptions {
|
||||
return { ...defaultOutputOptions, ...partial }
|
||||
export function createOutputOptions(partial: Partial<OutputOptions> = {}): OutputOptions {
|
||||
return { ...defaultOutputOptions, ...partial };
|
||||
}
|
||||
|
||||
@@ -4,42 +4,39 @@
|
||||
* Renders structured data as YAML for machine consumption and human readability.
|
||||
*/
|
||||
|
||||
import YAML from 'yaml'
|
||||
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||
import YAML from "yaml";
|
||||
import type { AnyCommandResult, OutputOptions } from "./types.js";
|
||||
|
||||
/** Render command result as YAML */
|
||||
export function renderYaml<T>(
|
||||
result: AnyCommandResult<T>,
|
||||
_options: OutputOptions
|
||||
): string {
|
||||
const { schema } = result
|
||||
export function renderYaml<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
|
||||
const { schema } = result;
|
||||
|
||||
// Apply custom serializer if provided
|
||||
if (schema.serialize) {
|
||||
if (result.type === 'list') {
|
||||
if (result.type === "list") {
|
||||
// If all items serialize to the same object, return just one
|
||||
// This handles the case where a list of key-value rows should serialize
|
||||
// to a single structured object
|
||||
const serialized = result.data.map((item) => schema.serialize!(item))
|
||||
const serialized = result.data.map((item) => schema.serialize!(item));
|
||||
if (serialized.length > 0) {
|
||||
const first = JSON.stringify(serialized[0])
|
||||
const allSame = serialized.every((s) => JSON.stringify(s) === first)
|
||||
const first = JSON.stringify(serialized[0]);
|
||||
const allSame = serialized.every((s) => JSON.stringify(s) === first);
|
||||
if (allSame) {
|
||||
return YAML.stringify(serialized[0])
|
||||
return YAML.stringify(serialized[0]);
|
||||
}
|
||||
}
|
||||
return YAML.stringify(serialized)
|
||||
return YAML.stringify(serialized);
|
||||
} else {
|
||||
const serialized = schema.serialize(result.data)
|
||||
return YAML.stringify(serialized)
|
||||
const serialized = schema.serialize(result.data);
|
||||
return YAML.stringify(serialized);
|
||||
}
|
||||
}
|
||||
|
||||
return YAML.stringify(result.data)
|
||||
return YAML.stringify(result.data);
|
||||
}
|
||||
|
||||
/** Render a single item as YAML document (for streaming) */
|
||||
export function renderYamlDoc<T>(item: T, serialize?: (data: T) => unknown): string {
|
||||
const output = serialize ? serialize(item) : item
|
||||
return YAML.stringify(output)
|
||||
const output = serialize ? serialize(item) : item;
|
||||
return YAML.stringify(output);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
|
||||
|
||||
const CLIENT_SESSION_KEY_FILE = join(
|
||||
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
|
||||
"cli-client-id"
|
||||
"cli-client-id",
|
||||
);
|
||||
|
||||
let cachedClientId: string | null = null;
|
||||
@@ -25,9 +25,7 @@ export async function getOrCreateCliClientId(): Promise<string> {
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = normalizeClientId(
|
||||
await readFile(CLIENT_SESSION_KEY_FILE, "utf8")
|
||||
);
|
||||
const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8"));
|
||||
if (existing) {
|
||||
cachedClientId = existing;
|
||||
return existing;
|
||||
|
||||
+134
-134
@@ -1,186 +1,188 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { loadConfig, resolvePaseoHome, DaemonClient } from '@getpaseo/server'
|
||||
import path from 'node:path'
|
||||
import WebSocket from 'ws'
|
||||
import { getOrCreateCliClientId } from './client-id.js'
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { loadConfig, resolvePaseoHome, DaemonClient } from "@getpaseo/server";
|
||||
import path from "node:path";
|
||||
import WebSocket from "ws";
|
||||
import { getOrCreateCliClientId } from "./client-id.js";
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string
|
||||
timeout?: number
|
||||
host?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = 'localhost:6767'
|
||||
const DEFAULT_TIMEOUT = 5000
|
||||
const PID_FILENAME = 'paseo.pid'
|
||||
const DEFAULT_HOST = "localhost:6767";
|
||||
const DEFAULT_TIMEOUT = 5000;
|
||||
const PID_FILENAME = "paseo.pid";
|
||||
|
||||
type DaemonTarget =
|
||||
| {
|
||||
type: 'tcp'
|
||||
url: string
|
||||
type: "tcp";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: 'ipc'
|
||||
url: string
|
||||
socketPath: 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
|
||||
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const trimmed = raw.trim()
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.startsWith('unix://') ||
|
||||
trimmed.startsWith('pipe://') ||
|
||||
trimmed.startsWith('\\\\.\\pipe\\')
|
||||
trimmed.startsWith("unix://") ||
|
||||
trimmed.startsWith("pipe://") ||
|
||||
trimmed.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
|
||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
return `unix://${trimmed}`
|
||||
return `unix://${trimmed}`;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return `127.0.0.1:${trimmed}`
|
||||
return `127.0.0.1:${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed.includes(':') ? trimmed : null
|
||||
return trimmed.includes(":") ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
|
||||
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function isIpcDaemonHost(host: string | null): host is string {
|
||||
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
|
||||
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
|
||||
}
|
||||
|
||||
function isTcpDaemonHost(host: string | null): host is string {
|
||||
return host !== null && !isIpcDaemonHost(host)
|
||||
return host !== null && !isIpcDaemonHost(host);
|
||||
}
|
||||
|
||||
function readPidSocketTarget(paseoHome: string): string | null {
|
||||
const pidPath = path.join(paseoHome, PID_FILENAME)
|
||||
const pidPath = path.join(paseoHome, PID_FILENAME);
|
||||
if (!existsSync(pidPath)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { listen?: unknown; sockPath?: unknown }
|
||||
return typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : null
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
|
||||
listen?: unknown;
|
||||
sockPath?: unknown;
|
||||
};
|
||||
return typeof parsed.listen === "string"
|
||||
? parsed.listen
|
||||
: typeof parsed.sockPath === "string"
|
||||
? parsed.sockPath
|
||||
: null;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
|
||||
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
|
||||
if (isIpcDaemonHost(directEnvHost)) {
|
||||
return directEnvHost
|
||||
return directEnvHost;
|
||||
}
|
||||
|
||||
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
|
||||
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
|
||||
if (isIpcDaemonHost(pidHost)) {
|
||||
return pidHost
|
||||
return pidHost;
|
||||
}
|
||||
|
||||
const config = loadConfig(paseoHome, { env })
|
||||
const configuredHost = normalizeDaemonHost(config.listen)
|
||||
return isIpcDaemonHost(configuredHost) ? configuredHost : null
|
||||
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)
|
||||
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
|
||||
if (!isTcpDaemonHost(configuredHost)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
|
||||
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)
|
||||
const paseoHome = resolvePaseoHome(env);
|
||||
const candidates: string[] = [];
|
||||
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
|
||||
if (configuredIpcHost) {
|
||||
candidates.push(configuredIpcHost)
|
||||
candidates.push(configuredIpcHost);
|
||||
}
|
||||
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
|
||||
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
|
||||
if (configuredTcpHost) {
|
||||
candidates.push(configuredTcpHost)
|
||||
candidates.push(configuredTcpHost);
|
||||
}
|
||||
candidates.push(DEFAULT_HOST)
|
||||
return Array.from(new Set(candidates))
|
||||
candidates.push(DEFAULT_HOST);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
|
||||
const explicitHost = options?.host ?? process.env.PASEO_HOST
|
||||
const explicitHost = options?.host ?? process.env.PASEO_HOST;
|
||||
if (explicitHost) {
|
||||
return [explicitHost]
|
||||
return [explicitHost];
|
||||
}
|
||||
|
||||
return resolveDefaultDaemonHosts()
|
||||
return resolveDefaultDaemonHosts();
|
||||
}
|
||||
|
||||
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
const trimmed = host.trim()
|
||||
const trimmed = host.trim();
|
||||
if (
|
||||
trimmed.startsWith('unix://') ||
|
||||
trimmed.startsWith('pipe://') ||
|
||||
trimmed.startsWith('\\\\.\\pipe\\')
|
||||
trimmed.startsWith("unix://") ||
|
||||
trimmed.startsWith("pipe://") ||
|
||||
trimmed.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
const socketPath = trimmed.startsWith('unix://')
|
||||
? trimmed.slice('unix://'.length).trim()
|
||||
: trimmed.startsWith('pipe://')
|
||||
? trimmed.slice('pipe://'.length).trim()
|
||||
: trimmed
|
||||
const socketPath = trimmed.startsWith("unix://")
|
||||
? trimmed.slice("unix://".length).trim()
|
||||
: trimmed.startsWith("pipe://")
|
||||
? trimmed.slice("pipe://".length).trim()
|
||||
: trimmed;
|
||||
if (!socketPath) {
|
||||
throw new Error('Invalid IPC daemon target: missing socket path')
|
||||
throw new Error("Invalid IPC daemon target: missing socket path");
|
||||
}
|
||||
const isUnixSocket = trimmed.startsWith('unix://')
|
||||
const isUnixSocket = trimmed.startsWith("unix://");
|
||||
return {
|
||||
type: 'ipc',
|
||||
url: isUnixSocket
|
||||
? `ws+unix://${socketPath}:/ws`
|
||||
: 'ws://localhost/ws',
|
||||
type: "ipc",
|
||||
url: isUnixSocket ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
|
||||
socketPath,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tcp',
|
||||
type: "tcp",
|
||||
url: `ws://${trimmed}/ws`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
function createNodeWebSocketFactory() {
|
||||
return (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; socketPath?: string }
|
||||
) => {
|
||||
return (url: string, options?: { headers?: Record<string, string>; socketPath?: string }) => {
|
||||
return new WebSocket(url, {
|
||||
headers: options?.headers,
|
||||
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
||||
}) as unknown as {
|
||||
readyState: number
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
binaryType?: string
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => void
|
||||
off: (event: string, listener: (...args: unknown[]) => void) => void
|
||||
}
|
||||
}
|
||||
readyState: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
binaryType?: string;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
off: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,56 +190,54 @@ function createNodeWebSocketFactory() {
|
||||
* Returns the connected client or throws if connection fails
|
||||
*/
|
||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||
const clientId = await getOrCreateCliClientId()
|
||||
const hosts = resolveDaemonHostCandidates(options)
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory()
|
||||
let lastError: unknown = null
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
const clientId = await getOrCreateCliClientId();
|
||||
const hosts = resolveDaemonHostCandidates(options);
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const host of hosts) {
|
||||
const target = resolveDaemonTarget(host)
|
||||
const client = new DaemonClient(
|
||||
{
|
||||
url: target.url,
|
||||
clientId,
|
||||
clientType: 'cli',
|
||||
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
||||
nodeWebSocketFactory(url, {
|
||||
headers: config?.headers,
|
||||
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
|
||||
}),
|
||||
reconnect: { enabled: false },
|
||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
||||
)
|
||||
const target = resolveDaemonTarget(host);
|
||||
const client = new DaemonClient({
|
||||
url: target.url,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
||||
nodeWebSocketFactory(url, {
|
||||
headers: config?.headers,
|
||||
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
||||
}),
|
||||
reconnect: { enabled: false },
|
||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]);
|
||||
|
||||
const connectPromise = client.connect()
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
||||
const connectPromise = client.connect();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`Connection timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
})
|
||||
reject(new Error(`Connection timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([connectPromise, timeoutPromise])
|
||||
await Promise.race([connectPromise, timeoutPromise]);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
return client
|
||||
return client;
|
||||
} catch (err) {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
lastError = err
|
||||
await client.close().catch(() => {})
|
||||
lastError = err;
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError instanceof Error) {
|
||||
throw lastError
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
|
||||
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,16 +245,16 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
||||
*/
|
||||
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
|
||||
try {
|
||||
return await connectToDaemon(options)
|
||||
return await connectToDaemon(options);
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal agent type for ID resolution */
|
||||
interface AgentLike {
|
||||
id: string
|
||||
title?: string | null
|
||||
id: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,40 +268,40 @@ interface AgentLike {
|
||||
*/
|
||||
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
|
||||
if (!idOrName || agents.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = idOrName.toLowerCase()
|
||||
const query = idOrName.toLowerCase();
|
||||
|
||||
// Try exact ID match first
|
||||
const exactMatch = agents.find((a) => a.id === idOrName)
|
||||
const exactMatch = agents.find((a) => a.id === idOrName);
|
||||
if (exactMatch) {
|
||||
return exactMatch.id
|
||||
return exactMatch.id;
|
||||
}
|
||||
|
||||
// Try ID prefix match
|
||||
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query))
|
||||
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query));
|
||||
if (prefixMatches.length === 1 && prefixMatches[0]) {
|
||||
return prefixMatches[0].id
|
||||
return prefixMatches[0].id;
|
||||
}
|
||||
|
||||
// Try title/name match (case-insensitive)
|
||||
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query)
|
||||
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query);
|
||||
if (titleMatches.length === 1 && titleMatches[0]) {
|
||||
return titleMatches[0].id
|
||||
return titleMatches[0].id;
|
||||
}
|
||||
|
||||
// Try partial title match
|
||||
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query))
|
||||
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query));
|
||||
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
|
||||
return partialTitleMatches[0].id
|
||||
return partialTitleMatches[0].id;
|
||||
}
|
||||
|
||||
// If we have multiple prefix matches and no unique title match, return first prefix match
|
||||
const firstPrefixMatch = prefixMatches[0]
|
||||
const firstPrefixMatch = prefixMatches[0];
|
||||
if (firstPrefixMatch) {
|
||||
return firstPrefixMatch.id
|
||||
return firstPrefixMatch.id;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { Command } from "commander";
|
||||
|
||||
const JSON_OPTION_DESCRIPTION = 'Output in JSON format'
|
||||
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
|
||||
const DAEMON_HOST_OPTION_DESCRIPTION =
|
||||
'Daemon host target (default: local socket/pipe, then localhost:6767)'
|
||||
"Daemon host target (default: local socket/pipe, then localhost:6767)";
|
||||
|
||||
export function collectMultiple(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value])
|
||||
return previous.concat([value]);
|
||||
}
|
||||
|
||||
export function addJsonOption<T extends Command>(command: T): T {
|
||||
command.option('--json', JSON_OPTION_DESCRIPTION)
|
||||
return command
|
||||
command.option("--json", JSON_OPTION_DESCRIPTION);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function addDaemonHostOption<T extends Command>(command: T): T {
|
||||
command.option('--host <host>', DAEMON_HOST_OPTION_DESCRIPTION)
|
||||
return command
|
||||
command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function addJsonAndDaemonHostOptions<T extends Command>(command: T): T {
|
||||
return addDaemonHostOption(addJsonOption(command))
|
||||
return addDaemonHostOption(addJsonOption(command));
|
||||
}
|
||||
|
||||
@@ -4,40 +4,40 @@
|
||||
* If no unit is specified, assumes seconds.
|
||||
*/
|
||||
export function parseDuration(input: string): number {
|
||||
const trimmed = input.trim()
|
||||
const trimmed = input.trim();
|
||||
|
||||
// If it's just a number, treat as seconds
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return parseInt(trimmed, 10) * 1000
|
||||
return parseInt(trimmed, 10) * 1000;
|
||||
}
|
||||
|
||||
// Parse duration with units
|
||||
let totalMs = 0
|
||||
const regex = /(\d+)([smh])/g
|
||||
let match
|
||||
let hasMatch = false
|
||||
let totalMs = 0;
|
||||
const regex = /(\d+)([smh])/g;
|
||||
let match;
|
||||
let hasMatch = false;
|
||||
|
||||
while ((match = regex.exec(trimmed)) !== null) {
|
||||
hasMatch = true
|
||||
const value = parseInt(match[1], 10)
|
||||
const unit = match[2]
|
||||
hasMatch = true;
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
totalMs += value * 1000
|
||||
break
|
||||
case 'm':
|
||||
totalMs += value * 60 * 1000
|
||||
break
|
||||
case 'h':
|
||||
totalMs += value * 60 * 60 * 1000
|
||||
break
|
||||
case "s":
|
||||
totalMs += value * 1000;
|
||||
break;
|
||||
case "m":
|
||||
totalMs += value * 60 * 1000;
|
||||
break;
|
||||
case "h":
|
||||
totalMs += value * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMatch) {
|
||||
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`)
|
||||
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
|
||||
}
|
||||
|
||||
return totalMs
|
||||
return totalMs;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error)
|
||||
return String(error);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { AgentTimelineItem, DaemonClient } from '@getpaseo/server'
|
||||
import type { AgentTimelineItem, DaemonClient } from "@getpaseo/server";
|
||||
|
||||
type FetchProjectedTimelineItemsInput = {
|
||||
client: DaemonClient
|
||||
agentId: string
|
||||
}
|
||||
client: DaemonClient;
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export async function fetchProjectedTimelineItems(
|
||||
input: FetchProjectedTimelineItemsInput
|
||||
input: FetchProjectedTimelineItemsInput,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: 'tail',
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: 'projected',
|
||||
})
|
||||
return timeline.entries.map((entry) => entry.item)
|
||||
projection: "projected",
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -8,46 +8,46 @@
|
||||
* - paseo --help shows commands
|
||||
*/
|
||||
|
||||
import { $ } from 'zx'
|
||||
import { $ } from "zx";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('📋 Phase 1: Foundation Tests\n')
|
||||
console.log("📋 Phase 1: Foundation Tests\n");
|
||||
|
||||
// Test 1.1: --version outputs version
|
||||
console.log(' Testing paseo --version...')
|
||||
const versionResult = await $`paseo --version`.nothrow()
|
||||
console.log(" Testing paseo --version...");
|
||||
const versionResult = await $`paseo --version`.nothrow();
|
||||
if (versionResult.exitCode !== 0) {
|
||||
console.error(' ❌ paseo --version failed with exit code', versionResult.exitCode)
|
||||
console.error(' stderr:', versionResult.stderr)
|
||||
process.exit(1)
|
||||
console.error(" ❌ paseo --version failed with exit code", versionResult.exitCode);
|
||||
console.error(" stderr:", versionResult.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
const versionOutput = versionResult.stdout.trim()
|
||||
const versionOutput = versionResult.stdout.trim();
|
||||
if (!versionOutput.match(/\d+\.\d+\.\d+/)) {
|
||||
console.error(' ❌ paseo --version output does not contain version number')
|
||||
console.error(' output:', versionOutput)
|
||||
process.exit(1)
|
||||
console.error(" ❌ paseo --version output does not contain version number");
|
||||
console.error(" output:", versionOutput);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✅ paseo --version outputs:', versionOutput)
|
||||
console.log(" ✅ paseo --version outputs:", versionOutput);
|
||||
|
||||
// Test 1.2: --help shows commands
|
||||
console.log(' Testing paseo --help...')
|
||||
const helpResult = await $`paseo --help`.nothrow()
|
||||
console.log(" Testing paseo --help...");
|
||||
const helpResult = await $`paseo --help`.nothrow();
|
||||
if (helpResult.exitCode !== 0) {
|
||||
console.error(' ❌ paseo --help failed with exit code', helpResult.exitCode)
|
||||
console.error(' stderr:', helpResult.stderr)
|
||||
process.exit(1)
|
||||
console.error(" ❌ paseo --help failed with exit code", helpResult.exitCode);
|
||||
console.error(" stderr:", helpResult.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
const helpOutput = helpResult.stdout
|
||||
const helpOutput = helpResult.stdout;
|
||||
|
||||
// Check for expected sections in help output
|
||||
const expectedTerms = ['agent', 'daemon', 'Usage', 'Options', 'Commands']
|
||||
const missingTerms = expectedTerms.filter(term => !helpOutput.includes(term))
|
||||
const expectedTerms = ["agent", "daemon", "Usage", "Options", "Commands"];
|
||||
const missingTerms = expectedTerms.filter((term) => !helpOutput.includes(term));
|
||||
if (missingTerms.length > 0) {
|
||||
console.error(' ❌ paseo --help missing expected terms:', missingTerms.join(', '))
|
||||
console.error(' output:', helpOutput)
|
||||
process.exit(1)
|
||||
console.error(" ❌ paseo --help missing expected terms:", missingTerms.join(", "));
|
||||
console.error(" output:", helpOutput);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✅ paseo --help shows commands')
|
||||
console.log(" ✅ paseo --help shows commands");
|
||||
|
||||
console.log('\n✅ Phase 1: Foundation Tests PASSED')
|
||||
console.log("\n✅ Phase 1: Foundation Tests PASSED");
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Verifies that renderers correctly format structured data.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
render,
|
||||
renderTable,
|
||||
@@ -19,261 +19,261 @@ import {
|
||||
type SingleResult,
|
||||
type OutputSchema,
|
||||
type CommandError,
|
||||
} from '../src/output/index.js'
|
||||
} from "../src/output/index.js";
|
||||
|
||||
// Test data types
|
||||
interface Agent {
|
||||
id: string
|
||||
title: string
|
||||
status: 'running' | 'idle' | 'error'
|
||||
provider: string
|
||||
id: string;
|
||||
title: string;
|
||||
status: "running" | "idle" | "error";
|
||||
provider: string;
|
||||
}
|
||||
|
||||
// Schema for agents
|
||||
const agentSchema: OutputSchema<Agent> = {
|
||||
idField: 'id',
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: 'ID', field: 'id', width: 8 },
|
||||
{ header: 'TITLE', field: 'title', width: 20 },
|
||||
{ header: "ID", field: "id", width: 8 },
|
||||
{ header: "TITLE", field: "title", width: 20 },
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'status',
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
color: (value) => {
|
||||
switch (value) {
|
||||
case 'running':
|
||||
return 'green'
|
||||
case 'idle':
|
||||
return 'dim'
|
||||
case 'error':
|
||||
return 'red'
|
||||
case "running":
|
||||
return "green";
|
||||
case "idle":
|
||||
return "dim";
|
||||
case "error":
|
||||
return "red";
|
||||
default:
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
{ header: 'PROVIDER', field: 'provider' },
|
||||
{ header: "PROVIDER", field: "provider" },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
// Test data
|
||||
const testAgents: Agent[] = [
|
||||
{ id: 'abc123', title: 'Feature Implementation', status: 'running', provider: 'claude' },
|
||||
{ id: 'def456', title: 'Bug Fix', status: 'idle', provider: 'codex' },
|
||||
{ id: 'ghi789', title: 'Failed Task', status: 'error', provider: 'claude' },
|
||||
]
|
||||
{ id: "abc123", title: "Feature Implementation", status: "running", provider: "claude" },
|
||||
{ id: "def456", title: "Bug Fix", status: "idle", provider: "codex" },
|
||||
{ id: "ghi789", title: "Failed Task", status: "error", provider: "claude" },
|
||||
];
|
||||
|
||||
const listResult: ListResult<Agent> = {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: testAgents,
|
||||
schema: agentSchema,
|
||||
}
|
||||
};
|
||||
|
||||
const singleResult: SingleResult<Agent> = {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: testAgents[0],
|
||||
schema: agentSchema,
|
||||
}
|
||||
};
|
||||
|
||||
// Test utilities
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn()
|
||||
console.log(`✓ ${name}`)
|
||||
passed++
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error(`✗ ${name}`)
|
||||
console.error(` ${error instanceof Error ? error.message : error}`)
|
||||
failed++
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${error instanceof Error ? error.message : error}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Table renderer tests
|
||||
console.log('\n=== Table Renderer ===\n')
|
||||
console.log("\n=== Table Renderer ===\n");
|
||||
|
||||
test('renderTable formats list data with headers', () => {
|
||||
const output = renderTable(listResult, createOutputOptions({ noColor: true }))
|
||||
assert.ok(output.includes('ID'), 'Should include ID header')
|
||||
assert.ok(output.includes('TITLE'), 'Should include TITLE header')
|
||||
assert.ok(output.includes('STATUS'), 'Should include STATUS header')
|
||||
assert.ok(output.includes('abc123'), 'Should include first agent ID')
|
||||
assert.ok(output.includes('Feature Implementation'), 'Should include first agent title')
|
||||
})
|
||||
test("renderTable formats list data with headers", () => {
|
||||
const output = renderTable(listResult, createOutputOptions({ noColor: true }));
|
||||
assert.ok(output.includes("ID"), "Should include ID header");
|
||||
assert.ok(output.includes("TITLE"), "Should include TITLE header");
|
||||
assert.ok(output.includes("STATUS"), "Should include STATUS header");
|
||||
assert.ok(output.includes("abc123"), "Should include first agent ID");
|
||||
assert.ok(output.includes("Feature Implementation"), "Should include first agent title");
|
||||
});
|
||||
|
||||
test('renderTable omits headers when noHeaders is true', () => {
|
||||
const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true }))
|
||||
assert.ok(!output.includes('ID '), 'Should not include header row')
|
||||
assert.ok(output.includes('abc123'), 'Should still include data')
|
||||
})
|
||||
test("renderTable omits headers when noHeaders is true", () => {
|
||||
const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true }));
|
||||
assert.ok(!output.includes("ID "), "Should not include header row");
|
||||
assert.ok(output.includes("abc123"), "Should still include data");
|
||||
});
|
||||
|
||||
test('renderTable handles empty data', () => {
|
||||
test("renderTable handles empty data", () => {
|
||||
const emptyResult: ListResult<Agent> = {
|
||||
type: 'list',
|
||||
type: "list",
|
||||
data: [],
|
||||
schema: agentSchema,
|
||||
}
|
||||
const output = renderTable(emptyResult, createOutputOptions())
|
||||
assert.strictEqual(output, '', 'Should return empty string for empty data')
|
||||
})
|
||||
};
|
||||
const output = renderTable(emptyResult, createOutputOptions());
|
||||
assert.strictEqual(output, "", "Should return empty string for empty data");
|
||||
});
|
||||
|
||||
test('renderTable handles single result', () => {
|
||||
const output = renderTable(singleResult, createOutputOptions({ noColor: true }))
|
||||
assert.ok(output.includes('abc123'), 'Should include agent ID')
|
||||
assert.ok(output.includes('Feature Implementation'), 'Should include agent title')
|
||||
})
|
||||
test("renderTable handles single result", () => {
|
||||
const output = renderTable(singleResult, createOutputOptions({ noColor: true }));
|
||||
assert.ok(output.includes("abc123"), "Should include agent ID");
|
||||
assert.ok(output.includes("Feature Implementation"), "Should include agent title");
|
||||
});
|
||||
|
||||
// JSON renderer tests
|
||||
console.log('\n=== JSON Renderer ===\n')
|
||||
console.log("\n=== JSON Renderer ===\n");
|
||||
|
||||
test('renderJson outputs valid JSON for list', () => {
|
||||
const output = renderJson(listResult, createOutputOptions())
|
||||
const parsed = JSON.parse(output)
|
||||
assert.ok(Array.isArray(parsed), 'Should be an array')
|
||||
assert.strictEqual(parsed.length, 3, 'Should have 3 items')
|
||||
assert.strictEqual(parsed[0].id, 'abc123', 'Should include correct data')
|
||||
})
|
||||
test("renderJson outputs valid JSON for list", () => {
|
||||
const output = renderJson(listResult, createOutputOptions());
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(Array.isArray(parsed), "Should be an array");
|
||||
assert.strictEqual(parsed.length, 3, "Should have 3 items");
|
||||
assert.strictEqual(parsed[0].id, "abc123", "Should include correct data");
|
||||
});
|
||||
|
||||
test('renderJson outputs valid JSON for single item', () => {
|
||||
const output = renderJson(singleResult, createOutputOptions())
|
||||
const parsed = JSON.parse(output)
|
||||
assert.strictEqual(parsed.id, 'abc123', 'Should include correct data')
|
||||
assert.strictEqual(parsed.title, 'Feature Implementation', 'Should include correct title')
|
||||
})
|
||||
test("renderJson outputs valid JSON for single item", () => {
|
||||
const output = renderJson(singleResult, createOutputOptions());
|
||||
const parsed = JSON.parse(output);
|
||||
assert.strictEqual(parsed.id, "abc123", "Should include correct data");
|
||||
assert.strictEqual(parsed.title, "Feature Implementation", "Should include correct title");
|
||||
});
|
||||
|
||||
test('renderJson uses custom serializer when provided', () => {
|
||||
test("renderJson uses custom serializer when provided", () => {
|
||||
const customSchema: OutputSchema<Agent> = {
|
||||
...agentSchema,
|
||||
serialize: (agent) => ({ agentId: agent.id, name: agent.title }),
|
||||
}
|
||||
};
|
||||
const customResult: SingleResult<Agent> = {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: testAgents[0],
|
||||
schema: customSchema,
|
||||
}
|
||||
const output = renderJson(customResult, createOutputOptions())
|
||||
const parsed = JSON.parse(output)
|
||||
assert.ok('agentId' in parsed, 'Should use custom serialization')
|
||||
assert.ok('name' in parsed, 'Should use custom field names')
|
||||
})
|
||||
};
|
||||
const output = renderJson(customResult, createOutputOptions());
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok("agentId" in parsed, "Should use custom serialization");
|
||||
assert.ok("name" in parsed, "Should use custom field names");
|
||||
});
|
||||
|
||||
// YAML renderer tests
|
||||
console.log('\n=== YAML Renderer ===\n')
|
||||
console.log("\n=== YAML Renderer ===\n");
|
||||
|
||||
test('renderYaml outputs valid YAML for list', () => {
|
||||
const output = renderYaml(listResult, createOutputOptions())
|
||||
assert.ok(output.includes('- id: abc123'), 'Should format as YAML list')
|
||||
assert.ok(output.includes('title: Feature Implementation'), 'Should include title')
|
||||
})
|
||||
test("renderYaml outputs valid YAML for list", () => {
|
||||
const output = renderYaml(listResult, createOutputOptions());
|
||||
assert.ok(output.includes("- id: abc123"), "Should format as YAML list");
|
||||
assert.ok(output.includes("title: Feature Implementation"), "Should include title");
|
||||
});
|
||||
|
||||
test('renderYaml outputs valid YAML for single item', () => {
|
||||
const output = renderYaml(singleResult, createOutputOptions())
|
||||
assert.ok(output.includes('id: abc123'), 'Should include ID')
|
||||
assert.ok(!output.startsWith('-'), 'Should not be a list')
|
||||
})
|
||||
test("renderYaml outputs valid YAML for single item", () => {
|
||||
const output = renderYaml(singleResult, createOutputOptions());
|
||||
assert.ok(output.includes("id: abc123"), "Should include ID");
|
||||
assert.ok(!output.startsWith("-"), "Should not be a list");
|
||||
});
|
||||
|
||||
// Quiet renderer tests
|
||||
console.log('\n=== Quiet Renderer ===\n')
|
||||
console.log("\n=== Quiet Renderer ===\n");
|
||||
|
||||
test('renderQuiet outputs only IDs for list', () => {
|
||||
const output = renderQuiet(listResult, createOutputOptions())
|
||||
const lines = output.split('\n')
|
||||
assert.strictEqual(lines.length, 3, 'Should have 3 lines')
|
||||
assert.strictEqual(lines[0], 'abc123', 'First line should be first ID')
|
||||
assert.strictEqual(lines[1], 'def456', 'Second line should be second ID')
|
||||
})
|
||||
test("renderQuiet outputs only IDs for list", () => {
|
||||
const output = renderQuiet(listResult, createOutputOptions());
|
||||
const lines = output.split("\n");
|
||||
assert.strictEqual(lines.length, 3, "Should have 3 lines");
|
||||
assert.strictEqual(lines[0], "abc123", "First line should be first ID");
|
||||
assert.strictEqual(lines[1], "def456", "Second line should be second ID");
|
||||
});
|
||||
|
||||
test('renderQuiet outputs only ID for single item', () => {
|
||||
const output = renderQuiet(singleResult, createOutputOptions())
|
||||
assert.strictEqual(output, 'abc123', 'Should output only the ID')
|
||||
})
|
||||
test("renderQuiet outputs only ID for single item", () => {
|
||||
const output = renderQuiet(singleResult, createOutputOptions());
|
||||
assert.strictEqual(output, "abc123", "Should output only the ID");
|
||||
});
|
||||
|
||||
test('renderQuiet supports function idField', () => {
|
||||
test("renderQuiet supports function idField", () => {
|
||||
const customSchema: OutputSchema<Agent> = {
|
||||
...agentSchema,
|
||||
idField: (agent) => `${agent.provider}/${agent.id}`,
|
||||
}
|
||||
};
|
||||
const customResult: SingleResult<Agent> = {
|
||||
type: 'single',
|
||||
type: "single",
|
||||
data: testAgents[0],
|
||||
schema: customSchema,
|
||||
}
|
||||
const output = renderQuiet(customResult, createOutputOptions())
|
||||
assert.strictEqual(output, 'claude/abc123', 'Should use function to extract ID')
|
||||
})
|
||||
};
|
||||
const output = renderQuiet(customResult, createOutputOptions());
|
||||
assert.strictEqual(output, "claude/abc123", "Should use function to extract ID");
|
||||
});
|
||||
|
||||
// Main render dispatcher tests
|
||||
console.log('\n=== Render Dispatcher ===\n')
|
||||
console.log("\n=== Render Dispatcher ===\n");
|
||||
|
||||
test('render uses table format by default', () => {
|
||||
const output = render(listResult, { noColor: true })
|
||||
assert.ok(output.includes('ID'), 'Should use table format with headers')
|
||||
})
|
||||
test("render uses table format by default", () => {
|
||||
const output = render(listResult, { noColor: true });
|
||||
assert.ok(output.includes("ID"), "Should use table format with headers");
|
||||
});
|
||||
|
||||
test('render uses json format when specified', () => {
|
||||
const output = render(listResult, { format: 'json' })
|
||||
const parsed = JSON.parse(output)
|
||||
assert.ok(Array.isArray(parsed), 'Should be valid JSON array')
|
||||
})
|
||||
test("render uses json format when specified", () => {
|
||||
const output = render(listResult, { format: "json" });
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok(Array.isArray(parsed), "Should be valid JSON array");
|
||||
});
|
||||
|
||||
test('render uses yaml format when specified', () => {
|
||||
const output = render(listResult, { format: 'yaml' })
|
||||
assert.ok(output.includes('- id:'), 'Should be valid YAML')
|
||||
})
|
||||
test("render uses yaml format when specified", () => {
|
||||
const output = render(listResult, { format: "yaml" });
|
||||
assert.ok(output.includes("- id:"), "Should be valid YAML");
|
||||
});
|
||||
|
||||
test('render uses quiet mode when quiet is true', () => {
|
||||
const output = render(listResult, { quiet: true })
|
||||
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Should output only IDs')
|
||||
})
|
||||
test("render uses quiet mode when quiet is true", () => {
|
||||
const output = render(listResult, { quiet: true });
|
||||
assert.strictEqual(output, "abc123\ndef456\nghi789", "Should output only IDs");
|
||||
});
|
||||
|
||||
test('quiet mode takes precedence over format', () => {
|
||||
const output = render(listResult, { format: 'json', quiet: true })
|
||||
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Quiet should override format')
|
||||
})
|
||||
test("quiet mode takes precedence over format", () => {
|
||||
const output = render(listResult, { format: "json", quiet: true });
|
||||
assert.strictEqual(output, "abc123\ndef456\nghi789", "Quiet should override format");
|
||||
});
|
||||
|
||||
// Error rendering tests
|
||||
console.log('\n=== Error Rendering ===\n')
|
||||
console.log("\n=== Error Rendering ===\n");
|
||||
|
||||
test('renderError formats error for table format', () => {
|
||||
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
|
||||
const output = renderError(error, { noColor: true })
|
||||
assert.ok(output.includes('Error:'), 'Should include Error prefix')
|
||||
assert.ok(output.includes('Agent not found'), 'Should include message')
|
||||
})
|
||||
test("renderError formats error for table format", () => {
|
||||
const error: CommandError = { code: "NOT_FOUND", message: "Agent not found" };
|
||||
const output = renderError(error, { noColor: true });
|
||||
assert.ok(output.includes("Error:"), "Should include Error prefix");
|
||||
assert.ok(output.includes("Agent not found"), "Should include message");
|
||||
});
|
||||
|
||||
test('renderError formats error as JSON', () => {
|
||||
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
|
||||
const output = renderError(error, { format: 'json' })
|
||||
const parsed = JSON.parse(output)
|
||||
assert.ok('error' in parsed, 'Should have error property')
|
||||
assert.strictEqual(parsed.error.code, 'NOT_FOUND', 'Should include error code')
|
||||
})
|
||||
test("renderError formats error as JSON", () => {
|
||||
const error: CommandError = { code: "NOT_FOUND", message: "Agent not found" };
|
||||
const output = renderError(error, { format: "json" });
|
||||
const parsed = JSON.parse(output);
|
||||
assert.ok("error" in parsed, "Should have error property");
|
||||
assert.strictEqual(parsed.error.code, "NOT_FOUND", "Should include error code");
|
||||
});
|
||||
|
||||
test('renderError formats error as YAML', () => {
|
||||
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
|
||||
const output = renderError(error, { format: 'yaml' })
|
||||
assert.ok(output.includes('error:'), 'Should have error key')
|
||||
assert.ok(output.includes('code: NOT_FOUND'), 'Should include error code')
|
||||
})
|
||||
test("renderError formats error as YAML", () => {
|
||||
const error: CommandError = { code: "NOT_FOUND", message: "Agent not found" };
|
||||
const output = renderError(error, { format: "yaml" });
|
||||
assert.ok(output.includes("error:"), "Should have error key");
|
||||
assert.ok(output.includes("code: NOT_FOUND"), "Should include error code");
|
||||
});
|
||||
|
||||
test('toCommandError converts Error to CommandError', () => {
|
||||
const error = new Error('Something went wrong')
|
||||
const commandError = toCommandError(error)
|
||||
assert.strictEqual(commandError.code, 'UNKNOWN_ERROR', 'Should use UNKNOWN_ERROR code')
|
||||
assert.strictEqual(commandError.message, 'Something went wrong', 'Should preserve message')
|
||||
})
|
||||
test("toCommandError converts Error to CommandError", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
const commandError = toCommandError(error);
|
||||
assert.strictEqual(commandError.code, "UNKNOWN_ERROR", "Should use UNKNOWN_ERROR code");
|
||||
assert.strictEqual(commandError.message, "Something went wrong", "Should preserve message");
|
||||
});
|
||||
|
||||
test('toCommandError passes through CommandError', () => {
|
||||
const error: CommandError = { code: 'CUSTOM', message: 'Custom error' }
|
||||
const commandError = toCommandError(error)
|
||||
assert.strictEqual(commandError.code, 'CUSTOM', 'Should preserve code')
|
||||
assert.strictEqual(commandError.message, 'Custom error', 'Should preserve message')
|
||||
})
|
||||
test("toCommandError passes through CommandError", () => {
|
||||
const error: CommandError = { code: "CUSTOM", message: "Custom error" };
|
||||
const commandError = toCommandError(error);
|
||||
assert.strictEqual(commandError.code, "CUSTOM", "Should preserve code");
|
||||
assert.strictEqual(commandError.message, "Custom error", "Should preserve message");
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Summary ===\n')
|
||||
console.log(`Passed: ${passed}`)
|
||||
console.log(`Failed: ${failed}`)
|
||||
console.log("\n=== Summary ===\n");
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -14,112 +14,116 @@
|
||||
* - daemon restart starts the daemon and can be cleaned up
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { runLocalPaseo } from './helpers/local-cli.ts'
|
||||
import assert from "node:assert";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
|
||||
console.log('=== Daemon Commands ===\n')
|
||||
console.log("=== Daemon Commands ===\n");
|
||||
|
||||
// Keep restart off default 6767 to avoid collisions with any existing daemon.
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
function daemonCommand(args: string[]) {
|
||||
return runLocalPaseo(['daemon', ...args], { PASEO_HOME: paseoHome })
|
||||
return runLocalPaseo(["daemon", ...args], { PASEO_HOME: paseoHome });
|
||||
}
|
||||
|
||||
try {
|
||||
// Test 1: daemon --help shows subcommands
|
||||
{
|
||||
console.log('Test 1: daemon --help shows subcommands')
|
||||
const result = await runLocalPaseo(['daemon', '--help'])
|
||||
assert.strictEqual(result.exitCode, 0, 'daemon --help should exit 0')
|
||||
assert(result.stdout.includes('start'), 'help should mention start')
|
||||
assert(result.stdout.includes('status'), 'help should mention status')
|
||||
assert(result.stdout.includes('stop'), 'help should mention stop')
|
||||
assert(result.stdout.includes('restart'), 'help should mention restart')
|
||||
assert(result.stdout.includes('pair'), 'help should mention pair')
|
||||
console.log('✓ daemon --help shows subcommands\n')
|
||||
console.log("Test 1: daemon --help shows subcommands");
|
||||
const result = await runLocalPaseo(["daemon", "--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "daemon --help should exit 0");
|
||||
assert(result.stdout.includes("start"), "help should mention start");
|
||||
assert(result.stdout.includes("status"), "help should mention status");
|
||||
assert(result.stdout.includes("stop"), "help should mention stop");
|
||||
assert(result.stdout.includes("restart"), "help should mention restart");
|
||||
assert(result.stdout.includes("pair"), "help should mention pair");
|
||||
console.log("✓ daemon --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: daemon pair works without daemon process
|
||||
{
|
||||
console.log('Test 2: daemon pair prints local pairing URL')
|
||||
const result = await daemonCommand(['pair'])
|
||||
assert.strictEqual(result.exitCode, 0, 'daemon pair should succeed')
|
||||
assert(result.stdout.includes('Scan to pair:'), 'output should include scan header')
|
||||
assert(result.stdout.includes('#offer='), 'output should include pairing offer fragment')
|
||||
console.log('✓ daemon pair prints local pairing URL\n')
|
||||
console.log("Test 2: daemon pair prints local pairing URL");
|
||||
const result = await daemonCommand(["pair"]);
|
||||
assert.strictEqual(result.exitCode, 0, "daemon pair should succeed");
|
||||
assert(result.stdout.includes("Scan to pair:"), "output should include scan header");
|
||||
assert(result.stdout.includes("#offer="), "output should include pairing offer fragment");
|
||||
console.log("✓ daemon pair prints local pairing URL\n");
|
||||
}
|
||||
|
||||
// Test 3: daemon status reports stopped when daemon not running
|
||||
{
|
||||
console.log('Test 3: daemon status reports stopped when not running')
|
||||
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('status'), 'status table should include Status row')
|
||||
assert(output.includes('stopped'), 'status should report stopped')
|
||||
console.log('✓ daemon status reports stopped when not running\n')
|
||||
console.log("Test 3: daemon status reports stopped when not running");
|
||||
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("status"), "status table should include Status row");
|
||||
assert(output.includes("stopped"), "status should report stopped");
|
||||
console.log("✓ daemon status reports stopped when not running\n");
|
||||
}
|
||||
|
||||
// Test 4: daemon pair --json outputs valid JSON
|
||||
{
|
||||
console.log('Test 4: daemon pair --json outputs JSON')
|
||||
const result = await daemonCommand(['pair', '--json'])
|
||||
assert.strictEqual(result.exitCode, 0, 'daemon pair --json should succeed')
|
||||
const pairing = JSON.parse(result.stdout)
|
||||
assert.strictEqual(pairing.relayEnabled, true, 'pairing should report relay enabled')
|
||||
assert.match(pairing.url, /#offer=/, 'pairing URL should include offer fragment')
|
||||
assert.strictEqual(typeof pairing.qr, 'string', 'pairing should include QR content')
|
||||
console.log('✓ daemon pair --json outputs valid JSON\n')
|
||||
console.log("Test 4: daemon pair --json outputs JSON");
|
||||
const result = await daemonCommand(["pair", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "daemon pair --json should succeed");
|
||||
const pairing = JSON.parse(result.stdout);
|
||||
assert.strictEqual(pairing.relayEnabled, true, "pairing should report relay enabled");
|
||||
assert.match(pairing.url, /#offer=/, "pairing URL should include offer fragment");
|
||||
assert.strictEqual(typeof pairing.qr, "string", "pairing should include QR content");
|
||||
console.log("✓ daemon pair --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 5: daemon status --json outputs valid JSON
|
||||
{
|
||||
console.log('Test 5: daemon status --json outputs JSON')
|
||||
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.status, 'stopped', 'json status should report stopped')
|
||||
assert.strictEqual(status.home, paseoHome, 'json status should reflect the isolated home')
|
||||
assert.strictEqual(status.hostname, null, 'json status should include hostname when unavailable')
|
||||
console.log('✓ daemon status --json outputs valid JSON\n')
|
||||
console.log("Test 5: daemon status --json outputs JSON");
|
||||
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.status, "stopped", "json status should report stopped");
|
||||
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
|
||||
assert.strictEqual(
|
||||
status.hostname,
|
||||
null,
|
||||
"json status should include hostname when unavailable",
|
||||
);
|
||||
console.log("✓ daemon status --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 6: daemon stop handles daemon not running gracefully
|
||||
{
|
||||
console.log('Test 6: daemon stop handles daemon not running')
|
||||
const result = await daemonCommand(['stop'])
|
||||
console.log("Test 6: daemon stop handles daemon not running");
|
||||
const result = await daemonCommand(["stop"]);
|
||||
// Stop should succeed even if daemon is not running (idempotent).
|
||||
assert.strictEqual(result.exitCode, 0, 'stop should succeed when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.strictEqual(result.exitCode, 0, "stop should succeed when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const mentionsNotRunning =
|
||||
output.toLowerCase().includes('not running') ||
|
||||
output.toLowerCase().includes('was not running')
|
||||
assert(mentionsNotRunning, 'output should mention daemon was not running')
|
||||
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
|
||||
output.toLowerCase().includes("not running") ||
|
||||
output.toLowerCase().includes("was not running");
|
||||
assert(mentionsNotRunning, "output should mention daemon was not running");
|
||||
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')
|
||||
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 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");
|
||||
}
|
||||
} finally {
|
||||
// Best-effort daemon cleanup in case assertions fail before explicit stop.
|
||||
await daemonCommand(['stop', '--force'])
|
||||
await daemonCommand(["stop", "--force"]);
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All daemon tests passed ===')
|
||||
console.log("=== All daemon tests passed ===");
|
||||
|
||||
@@ -19,153 +19,153 @@
|
||||
* - paseo ls does not support --ui
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { runLocalPaseo } from './helpers/local-cli.ts'
|
||||
import assert from "node:assert";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
|
||||
console.log('=== LS Command Tests ===\n')
|
||||
console.log("=== LS Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: paseo --help shows ls command
|
||||
{
|
||||
console.log('Test 1: paseo --help shows ls command')
|
||||
const result = await runLocalPaseo(['--help'])
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('ls'), 'help should mention ls command')
|
||||
console.log('✓ paseo --help shows ls command\n')
|
||||
console.log("Test 1: paseo --help shows ls command");
|
||||
const result = await runLocalPaseo(["--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls command");
|
||||
console.log("✓ paseo --help shows ls command\n");
|
||||
}
|
||||
|
||||
// Test 2: paseo ls --help shows options
|
||||
{
|
||||
console.log('Test 2: paseo ls --help shows options')
|
||||
const result = await runLocalPaseo(['ls', '--help'])
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo ls --help should exit 0')
|
||||
assert(result.stdout.includes('-a'), 'help should mention -a flag')
|
||||
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||
assert(result.stdout.includes('-g'), 'help should mention -g flag')
|
||||
assert(result.stdout.includes('--global'), 'help should mention --global flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(!result.stdout.includes('--ui'), 'help should not mention --ui')
|
||||
console.log('✓ paseo ls --help shows options\n')
|
||||
console.log("Test 2: paseo ls --help shows options");
|
||||
const result = await runLocalPaseo(["ls", "--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "paseo ls --help should exit 0");
|
||||
assert(result.stdout.includes("-a"), "help should mention -a flag");
|
||||
assert(result.stdout.includes("--all"), "help should mention --all flag");
|
||||
assert(result.stdout.includes("-g"), "help should mention -g flag");
|
||||
assert(result.stdout.includes("--global"), "help should mention --global flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(!result.stdout.includes("--ui"), "help should not mention --ui");
|
||||
console.log("✓ paseo ls --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 3: paseo ls returns error when no daemon running
|
||||
{
|
||||
console.log('Test 3: paseo ls handles daemon not running')
|
||||
const result = await runLocalPaseo(['ls'], {
|
||||
console.log("Test 3: paseo ls handles daemon not running");
|
||||
const result = await runLocalPaseo(["ls"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
});
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ paseo ls handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ paseo ls handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: paseo ls --json returns valid JSON error
|
||||
{
|
||||
console.log('Test 4: paseo ls --json handles errors')
|
||||
const result = await runLocalPaseo(['ls', '--json'], {
|
||||
console.log("Test 4: paseo ls --json handles errors");
|
||||
const result = await runLocalPaseo(["ls", "--json"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
});
|
||||
// Should still fail (daemon not running)
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
// But output should be valid JSON if present
|
||||
const output = result.stdout.trim()
|
||||
const output = result.stdout.trim();
|
||||
if (output.length > 0) {
|
||||
try {
|
||||
JSON.parse(output)
|
||||
console.log('✓ paseo ls --json outputs valid JSON error\n')
|
||||
JSON.parse(output);
|
||||
console.log("✓ paseo ls --json outputs valid JSON error\n");
|
||||
} catch {
|
||||
// Empty or stderr-only output is acceptable
|
||||
console.log('✓ paseo ls --json handled error (output may be in stderr)\n')
|
||||
console.log("✓ paseo ls --json handled error (output may be in stderr)\n");
|
||||
}
|
||||
} else {
|
||||
console.log('✓ paseo ls --json handled error gracefully\n')
|
||||
console.log("✓ paseo ls --json handled error gracefully\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: paseo ls -a flag is accepted
|
||||
{
|
||||
console.log('Test 5: paseo ls -a flag is accepted')
|
||||
const result = await runLocalPaseo(['ls', '-a'], {
|
||||
console.log("Test 5: paseo ls -a flag is accepted");
|
||||
const result = await runLocalPaseo(["ls", "-a"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
});
|
||||
// Will fail due to no daemon, but flag should be parsed without error
|
||||
// (no "unknown option" error)
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -a flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ paseo ls -a flag is accepted\n')
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -a flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ paseo ls -a flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: paseo ls -g flag is accepted
|
||||
{
|
||||
console.log('Test 6: paseo ls -g flag is accepted')
|
||||
const result = await runLocalPaseo(['ls', '-g'], {
|
||||
console.log("Test 6: paseo ls -g flag is accepted");
|
||||
const result = await runLocalPaseo(["ls", "-g"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -g flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ paseo ls -g flag is accepted\n')
|
||||
});
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -g flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ paseo ls -g flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 7: paseo ls -ag combined flags are accepted
|
||||
{
|
||||
console.log('Test 7: paseo ls -ag combined flags are accepted')
|
||||
const result = await runLocalPaseo(['ls', '-ag'], {
|
||||
console.log("Test 7: paseo ls -ag combined flags are accepted");
|
||||
const result = await runLocalPaseo(["ls", "-ag"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -ag flags')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ paseo ls -ag combined flags are accepted\n')
|
||||
});
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -ag flags");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ paseo ls -ag combined flags are accepted\n");
|
||||
}
|
||||
|
||||
// Test 8: -q (quiet) flag is accepted globally
|
||||
{
|
||||
console.log('Test 8: -q (quiet) flag is accepted')
|
||||
const result = await runLocalPaseo(['-q', 'ls'], {
|
||||
console.log("Test 8: -q (quiet) flag is accepted");
|
||||
const result = await runLocalPaseo(["-q", "ls"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted\n')
|
||||
});
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 9: paseo ls --ui is rejected (flag removed)
|
||||
{
|
||||
console.log('Test 9: paseo ls --ui is rejected')
|
||||
const result = await runLocalPaseo(['ls', '--ui'], {
|
||||
console.log("Test 9: paseo ls --ui is rejected");
|
||||
const result = await runLocalPaseo(["ls", "--ui"], {
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
})
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag')
|
||||
const output = result.stdout + result.stderr
|
||||
assert(output.includes('unknown option'), 'should report unknown option for --ui')
|
||||
console.log('✓ paseo ls --ui is rejected\n')
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(output.includes("unknown option"), "should report unknown option for --ui");
|
||||
console.log("✓ paseo ls --ui is rejected\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All ls tests passed ===')
|
||||
console.log("=== All ls tests passed ===");
|
||||
|
||||
@@ -20,229 +20,232 @@
|
||||
* - run --cwd flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Run Command Tests ===\n')
|
||||
console.log("=== Run Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const schemaPath = join(paseoHome, 'run-output-schema.json')
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
const schemaPath = join(paseoHome, "run-output-schema.json");
|
||||
await writeFile(
|
||||
schemaPath,
|
||||
JSON.stringify({
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
status: { type: "string" },
|
||||
},
|
||||
required: ['status'],
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
// Test 1: run --help shows options
|
||||
{
|
||||
console.log('Test 1: run --help shows options')
|
||||
const result = await $`npx paseo run --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'run --help should exit 0')
|
||||
assert(result.stdout.includes('-d'), 'help should mention -d flag')
|
||||
assert(result.stdout.includes('--detach'), 'help should mention --detach flag')
|
||||
assert(result.stdout.includes('--name'), 'help should mention --name option')
|
||||
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
|
||||
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
|
||||
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
|
||||
assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
|
||||
assert(!result.stdout.includes('--ui'), 'help should not mention removed --ui option')
|
||||
console.log('✓ run --help shows options\n')
|
||||
console.log("Test 1: run --help shows options");
|
||||
const result = await $`npx paseo run --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
|
||||
assert(result.stdout.includes("-d"), "help should mention -d flag");
|
||||
assert(result.stdout.includes("--detach"), "help should mention --detach flag");
|
||||
assert(result.stdout.includes("--name"), "help should mention --name option");
|
||||
assert(result.stdout.includes("--provider"), "help should mention --provider option");
|
||||
assert(result.stdout.includes("--mode"), "help should mention --mode option");
|
||||
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
|
||||
assert(result.stdout.includes("--output-schema"), "help should mention --output-schema option");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<prompt>"), "help should mention prompt argument");
|
||||
assert(!result.stdout.includes("--ui"), "help should not mention removed --ui option");
|
||||
console.log("✓ run --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: run requires prompt argument
|
||||
{
|
||||
console.log('Test 2: run requires prompt argument')
|
||||
console.log("Test 2: run requires prompt argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without prompt");
|
||||
const output = result.stdout + result.stderr;
|
||||
// Commander should complain about missing argument
|
||||
const hasMissingArg =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasMissingArg, 'error should mention missing argument')
|
||||
console.log('✓ run requires prompt argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasMissingArg, "error should mention missing argument");
|
||||
console.log("✓ run requires prompt argument\n");
|
||||
}
|
||||
|
||||
// Test 3: run handles daemon not running
|
||||
{
|
||||
console.log('Test 3: run handles daemon not running')
|
||||
console.log("Test 3: run handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run "test prompt"`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run "test prompt"`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ run handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ run handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: run -d flag is accepted
|
||||
{
|
||||
console.log('Test 4: run -d flag is accepted')
|
||||
console.log("Test 4: run -d flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -d flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run -d flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -d flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run -d flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: run --name flag is accepted
|
||||
{
|
||||
console.log('Test 5: run --name flag is accepted')
|
||||
console.log("Test 5: run --name flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --name flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --name flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --name flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run --name flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: run --provider flag is accepted
|
||||
{
|
||||
console.log('Test 6: run --provider flag is accepted')
|
||||
console.log("Test 6: run --provider flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --provider flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --provider flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --provider flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run --provider flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6b: run --provider provider/model syntax is accepted
|
||||
{
|
||||
console.log('Test 6b: run --provider provider/model syntax is accepted')
|
||||
console.log("Test 6b: run --provider provider/model syntax is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept provider/model syntax')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --provider provider/model syntax is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept provider/model syntax");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run --provider provider/model syntax is accepted\n");
|
||||
}
|
||||
|
||||
// Test 7: run --mode flag is accepted
|
||||
{
|
||||
console.log('Test 7: run --mode flag is accepted')
|
||||
console.log("Test 7: run --mode flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --mode flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --mode flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --mode flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run --mode flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 8: run --cwd flag is accepted
|
||||
{
|
||||
console.log('Test 8: run --cwd flag is accepted')
|
||||
console.log("Test 8: run --cwd flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --cwd flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --cwd flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --cwd flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run --cwd flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 9: run --output-schema flag is accepted
|
||||
{
|
||||
console.log('Test 9: run --output-schema flag is accepted')
|
||||
console.log("Test 9: run --output-schema flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --output-schema flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ run --output-schema flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --output-schema flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ run --output-schema flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 10: run --output-schema cannot be used with --detach
|
||||
{
|
||||
console.log('Test 10: run --output-schema cannot be used with --detach')
|
||||
console.log("Test 10: run --output-schema cannot be used with --detach");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail with --detach and --output-schema')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail with --detach and --output-schema");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes('--output-schema cannot be used with --detach'),
|
||||
'error should explain detach incompatibility'
|
||||
)
|
||||
console.log('✓ run --output-schema cannot be used with --detach\n')
|
||||
output.includes("--output-schema cannot be used with --detach"),
|
||||
"error should explain detach incompatibility",
|
||||
);
|
||||
console.log("✓ run --output-schema cannot be used with --detach\n");
|
||||
}
|
||||
|
||||
// Test 11: -q (quiet) flag is accepted with run
|
||||
{
|
||||
console.log('Test 11: -q (quiet) flag is accepted with run')
|
||||
console.log("Test 11: -q (quiet) flag is accepted with run");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with run\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with run\n");
|
||||
}
|
||||
|
||||
// Test 12: Combined flags work together
|
||||
{
|
||||
console.log('Test 12: Combined flags work together')
|
||||
console.log("Test 12: Combined flags work together");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept all combined flags')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ Combined flags work together\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept all combined flags");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ Combined flags work together\n");
|
||||
}
|
||||
|
||||
// Test 12b: conflicting provider/model syntax is rejected before connect
|
||||
{
|
||||
console.log('Test 12b: conflicting provider/model syntax is rejected')
|
||||
console.log("Test 12b: conflicting provider/model syntax is rejected");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for conflicting model inputs')
|
||||
const output = result.stdout + result.stderr
|
||||
assert(output.includes('Conflicting model values provided'), 'should explain conflicting model inputs')
|
||||
console.log('✓ conflicting provider/model syntax is rejected\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting model inputs");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("Conflicting model values provided"),
|
||||
"should explain conflicting model inputs",
|
||||
);
|
||||
console.log("✓ conflicting provider/model syntax is rejected\n");
|
||||
}
|
||||
|
||||
// Test 13: paseo --help shows run command
|
||||
{
|
||||
console.log('Test 13: paseo --help shows run command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('run'), 'help should mention run command')
|
||||
console.log('✓ paseo --help shows run command\n')
|
||||
console.log("Test 13: paseo --help shows run command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("run"), "help should mention run command");
|
||||
console.log("✓ paseo --help shows run command\n");
|
||||
}
|
||||
|
||||
// Test 14: run --ui is rejected (flag removed)
|
||||
{
|
||||
console.log('Test 14: run --ui is rejected')
|
||||
console.log("Test 14: run --ui is rejected");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag')
|
||||
const output = result.stdout + result.stderr
|
||||
assert(output.includes('unknown option'), 'should report unknown option for --ui')
|
||||
console.log('✓ run --ui is rejected\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(output.includes("unknown option"), "should report unknown option for --ui");
|
||||
console.log("✓ run --ui is rejected\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All run tests passed ===')
|
||||
console.log("=== All run tests passed ===");
|
||||
|
||||
@@ -17,194 +17,193 @@
|
||||
* - agent shows send in subcommands
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Send Command Tests ===\n')
|
||||
console.log("=== Send Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const promptFilePath = join(paseoHome, 'send-prompt.txt')
|
||||
await writeFile(promptFilePath, 'prompt from file')
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
const promptFilePath = join(paseoHome, "send-prompt.txt");
|
||||
await writeFile(promptFilePath, "prompt from file");
|
||||
|
||||
try {
|
||||
// Test 1: send --help shows options
|
||||
{
|
||||
console.log('Test 1: send --help shows options')
|
||||
const result = await $`npx paseo send --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
|
||||
assert(result.stdout.includes('--prompt'), 'help should mention --prompt option')
|
||||
assert(result.stdout.includes('--prompt-file'), 'help should mention --prompt-file option')
|
||||
assert(result.stdout.includes('--no-wait'), 'help should mention --no-wait flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||
assert(result.stdout.includes('[prompt]'), 'help should mention optional prompt argument')
|
||||
console.log(' help should mention --prompt option')
|
||||
console.log(' help should mention --prompt-file option')
|
||||
console.log(' help should mention --no-wait flag')
|
||||
console.log(' help should mention --host option')
|
||||
console.log(' help should mention <id> argument')
|
||||
console.log(' help should mention [prompt] argument')
|
||||
console.log('✓ send --help shows options\n')
|
||||
console.log("Test 1: send --help shows options");
|
||||
const result = await $`npx paseo send --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "send --help should exit 0");
|
||||
assert(result.stdout.includes("--prompt"), "help should mention --prompt option");
|
||||
assert(result.stdout.includes("--prompt-file"), "help should mention --prompt-file option");
|
||||
assert(result.stdout.includes("--no-wait"), "help should mention --no-wait flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention id argument");
|
||||
assert(result.stdout.includes("[prompt]"), "help should mention optional prompt argument");
|
||||
console.log(" help should mention --prompt option");
|
||||
console.log(" help should mention --prompt-file option");
|
||||
console.log(" help should mention --no-wait flag");
|
||||
console.log(" help should mention --host option");
|
||||
console.log(" help should mention <id> argument");
|
||||
console.log(" help should mention [prompt] argument");
|
||||
console.log("✓ send --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: send requires id argument
|
||||
{
|
||||
console.log('Test 2: send requires id argument')
|
||||
console.log("Test 2: send requires id argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
// Commander should complain about missing argument
|
||||
const hasMissingArg =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasMissingArg, 'error should mention missing argument')
|
||||
console.log('✓ send requires id argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasMissingArg, "error should mention missing argument");
|
||||
console.log("✓ send requires id argument\n");
|
||||
}
|
||||
|
||||
// Test 3: send requires prompt argument
|
||||
{
|
||||
console.log('Test 3: send requires prompt argument')
|
||||
console.log("Test 3: send requires prompt argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without prompt");
|
||||
const output = result.stdout + result.stderr;
|
||||
// Commander should complain about missing argument
|
||||
const hasMissingArg =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasMissingArg, 'error should mention missing argument')
|
||||
console.log('✓ send requires prompt argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasMissingArg, "error should mention missing argument");
|
||||
console.log("✓ send requires prompt argument\n");
|
||||
}
|
||||
|
||||
// Test 4: send handles daemon not running
|
||||
{
|
||||
console.log('Test 4: send handles daemon not running')
|
||||
console.log("Test 4: send handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ send handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ send handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 5: send --no-wait flag is accepted
|
||||
{
|
||||
console.log('Test 5: send --no-wait flag is accepted')
|
||||
console.log("Test 5: send --no-wait flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --no-wait flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ send --no-wait flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --no-wait flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ send --no-wait flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5b: send --prompt flag is accepted
|
||||
{
|
||||
console.log('Test 5b: send --prompt flag is accepted')
|
||||
console.log("Test 5b: send --prompt flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --prompt flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ send --prompt flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --prompt flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ send --prompt flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5c: send --prompt-file flag is accepted
|
||||
{
|
||||
console.log('Test 5c: send --prompt-file flag is accepted')
|
||||
console.log("Test 5c: send --prompt-file flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --prompt-file flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ send --prompt-file flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --prompt-file flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ send --prompt-file flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: send --host flag is accepted
|
||||
{
|
||||
console.log('Test 6: send --host flag is accepted')
|
||||
console.log("Test 6: send --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo send --host localhost:${port} abc123 "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ send --host flag is accepted\n')
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo send --host localhost:${port} abc123 "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ send --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 7: -q (quiet) flag is accepted with send
|
||||
{
|
||||
console.log('Test 7: -q (quiet) flag is accepted with send')
|
||||
console.log("Test 7: -q (quiet) flag is accepted with send");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with send\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with send\n");
|
||||
}
|
||||
|
||||
// Test 8: Combined flags work together
|
||||
{
|
||||
console.log('Test 8: Combined flags work together')
|
||||
console.log("Test 8: Combined flags work together");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept all combined flags')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ Combined flags work together\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept all combined flags");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ Combined flags work together\n");
|
||||
}
|
||||
|
||||
// Test 8b: conflicting prompt sources are rejected
|
||||
{
|
||||
console.log('Test 8b: conflicting prompt sources are rejected')
|
||||
console.log("Test 8b: conflicting prompt sources are rejected");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for conflicting prompt sources')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting prompt sources");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes('Provide exactly one of prompt argument, --prompt, or --prompt-file'),
|
||||
'should explain conflicting prompt sources'
|
||||
)
|
||||
console.log('✓ conflicting prompt sources are rejected\n')
|
||||
output.includes("Provide exactly one of prompt argument, --prompt, or --prompt-file"),
|
||||
"should explain conflicting prompt sources",
|
||||
);
|
||||
console.log("✓ conflicting prompt sources are rejected\n");
|
||||
}
|
||||
|
||||
// Test 9: paseo --help shows send command
|
||||
{
|
||||
console.log('Test 9: paseo --help shows send command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('send'), 'help should mention send command')
|
||||
console.log('✓ paseo --help shows send command\n')
|
||||
console.log("Test 9: paseo --help shows send command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("send"), "help should mention send command");
|
||||
console.log("✓ paseo --help shows send command\n");
|
||||
}
|
||||
|
||||
// Test 10: ID prefix syntax is mentioned in help
|
||||
{
|
||||
console.log('Test 10: send command description mentions ID')
|
||||
const result = await $`npx paseo send --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
|
||||
console.log("Test 10: send command description mentions ID");
|
||||
const result = await $`npx paseo send --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "send --help should exit 0");
|
||||
const hasIdMention =
|
||||
result.stdout.toLowerCase().includes('id') ||
|
||||
result.stdout.toLowerCase().includes('prefix')
|
||||
assert(hasIdMention, 'help should mention ID or prefix')
|
||||
console.log('✓ send command description mentions ID\n')
|
||||
result.stdout.toLowerCase().includes("id") || result.stdout.toLowerCase().includes("prefix");
|
||||
assert(hasIdMention, "help should mention ID or prefix");
|
||||
console.log("✓ send command description mentions ID\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All send tests passed ===')
|
||||
console.log("=== All send tests passed ===");
|
||||
|
||||
@@ -17,120 +17,120 @@
|
||||
* - stop --cwd flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Stop Command Tests ===\n')
|
||||
console.log("=== Stop Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: stop --help shows options
|
||||
{
|
||||
console.log('Test 1: stop --help shows options')
|
||||
const result = await $`npx paseo stop --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'stop --help should exit 0')
|
||||
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('[id]'), 'help should mention optional id argument')
|
||||
console.log('✓ stop --help shows options\n')
|
||||
console.log("Test 1: stop --help shows options");
|
||||
const result = await $`npx paseo stop --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "stop --help should exit 0");
|
||||
assert(result.stdout.includes("--all"), "help should mention --all flag");
|
||||
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("[id]"), "help should mention optional id argument");
|
||||
console.log("✓ stop --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: stop requires ID, --all, or --cwd
|
||||
{
|
||||
console.log('Test 2: stop requires ID, --all, or --cwd')
|
||||
console.log("Test 2: stop requires ID, --all, or --cwd");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ stop requires ID, --all, or --cwd\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument") ||
|
||||
output.toLowerCase().includes("id");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ stop requires ID, --all, or --cwd\n");
|
||||
}
|
||||
|
||||
// Test 3: stop handles daemon not running
|
||||
{
|
||||
console.log('Test 3: stop handles daemon not running')
|
||||
console.log("Test 3: stop handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ stop handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ stop handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: stop --all flag is accepted
|
||||
{
|
||||
console.log('Test 4: stop --all flag is accepted')
|
||||
console.log("Test 4: stop --all flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ stop --all flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --all flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ stop --all flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: stop --cwd flag is accepted
|
||||
{
|
||||
console.log('Test 5: stop --cwd flag is accepted')
|
||||
console.log("Test 5: stop --cwd flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --cwd flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ stop --cwd flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --cwd flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ stop --cwd flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: stop with ID and --host flag is accepted
|
||||
{
|
||||
console.log('Test 6: stop with ID and --host flag is accepted')
|
||||
console.log("Test 6: stop with ID and --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ stop with ID and --host flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ stop with ID and --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 7: paseo --help shows stop command
|
||||
{
|
||||
console.log('Test 7: paseo --help shows stop command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('stop'), 'help should mention stop command')
|
||||
console.log('✓ paseo --help shows stop command\n')
|
||||
console.log("Test 7: paseo --help shows stop command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("stop"), "help should mention stop command");
|
||||
console.log("✓ paseo --help shows stop command\n");
|
||||
}
|
||||
|
||||
// Test 8: -q (quiet) flag is accepted with stop
|
||||
{
|
||||
console.log('Test 8: -q (quiet) flag is accepted with stop')
|
||||
console.log("Test 8: -q (quiet) flag is accepted with stop");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with stop\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with stop\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All stop tests passed ===')
|
||||
console.log("=== All stop tests passed ===");
|
||||
|
||||
@@ -17,132 +17,135 @@
|
||||
* - logs --tail flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Logs Command Tests ===\n')
|
||||
console.log("=== Logs Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: logs --help shows options
|
||||
{
|
||||
console.log('Test 1: logs --help shows options')
|
||||
const result = await $`npx paseo logs --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'logs --help should exit 0')
|
||||
assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag')
|
||||
assert(result.stdout.includes('--tail'), 'help should mention --tail option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||
console.log('✓ logs --help shows options\n')
|
||||
console.log("Test 1: logs --help shows options");
|
||||
const result = await $`npx paseo logs --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "logs --help should exit 0");
|
||||
assert(
|
||||
result.stdout.includes("-f") || result.stdout.includes("--follow"),
|
||||
"help should mention -f/--follow flag",
|
||||
);
|
||||
assert(result.stdout.includes("--tail"), "help should mention --tail option");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention required id argument");
|
||||
console.log("✓ logs --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: logs requires ID argument
|
||||
{
|
||||
console.log('Test 2: logs requires ID argument')
|
||||
console.log("Test 2: logs requires ID argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ logs requires ID argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument") ||
|
||||
output.toLowerCase().includes("id");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ logs requires ID argument\n");
|
||||
}
|
||||
|
||||
// Test 3: logs handles daemon not running
|
||||
{
|
||||
console.log('Test 3: logs handles daemon not running')
|
||||
console.log("Test 3: logs handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ logs handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ logs handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: logs -f (follow) flag is accepted
|
||||
{
|
||||
console.log('Test 4: logs -f (follow) flag is accepted')
|
||||
console.log("Test 4: logs -f (follow) flag is accepted");
|
||||
// Use timeout to avoid hanging on follow mode
|
||||
const result =
|
||||
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -f flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ logs -f (follow) flag is accepted\n')
|
||||
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -f flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ logs -f (follow) flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: logs --follow flag is accepted
|
||||
{
|
||||
console.log('Test 5: logs --follow flag is accepted')
|
||||
console.log("Test 5: logs --follow flag is accepted");
|
||||
const result =
|
||||
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --follow flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ logs --follow flag is accepted\n')
|
||||
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --follow flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ logs --follow flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: logs --tail flag is accepted
|
||||
{
|
||||
console.log('Test 6: logs --tail flag is accepted')
|
||||
console.log("Test 6: logs --tail flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --tail flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ logs --tail flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --tail flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ logs --tail flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 7: logs with ID and --host flag is accepted
|
||||
{
|
||||
console.log('Test 7: logs with ID and --host flag is accepted')
|
||||
console.log("Test 7: logs with ID and --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ logs with ID and --host flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ logs with ID and --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 8: paseo --help shows logs command
|
||||
{
|
||||
console.log('Test 8: paseo --help shows logs command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('logs'), 'help should mention logs command')
|
||||
console.log('✓ paseo --help shows logs command\n')
|
||||
console.log("Test 8: paseo --help shows logs command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("logs"), "help should mention logs command");
|
||||
console.log("✓ paseo --help shows logs command\n");
|
||||
}
|
||||
|
||||
// Test 9: -q (quiet) flag is accepted with logs
|
||||
{
|
||||
console.log('Test 9: -q (quiet) flag is accepted with logs')
|
||||
console.log("Test 9: -q (quiet) flag is accepted with logs");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with logs\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with logs\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All logs tests passed ===')
|
||||
console.log("=== All logs tests passed ===");
|
||||
|
||||
@@ -17,145 +17,144 @@
|
||||
* - agent shows inspect in subcommands
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Inspect Command Tests ===\n')
|
||||
console.log("=== Inspect Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: inspect --help shows options
|
||||
{
|
||||
console.log('Test 1: inspect --help shows options')
|
||||
const result = await $`npx paseo inspect --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||
console.log(' help should mention --host option')
|
||||
console.log(' help should mention <id> argument')
|
||||
console.log('inspect --help shows options\n')
|
||||
console.log("Test 1: inspect --help shows options");
|
||||
const result = await $`npx paseo inspect --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "inspect --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention id argument");
|
||||
console.log(" help should mention --host option");
|
||||
console.log(" help should mention <id> argument");
|
||||
console.log("inspect --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: inspect requires id argument
|
||||
{
|
||||
console.log('Test 2: inspect requires id argument')
|
||||
console.log("Test 2: inspect requires id argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
// Commander should complain about missing argument
|
||||
const hasMissingArg =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasMissingArg, 'error should mention missing argument')
|
||||
console.log('inspect requires id argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasMissingArg, "error should mention missing argument");
|
||||
console.log("inspect requires id argument\n");
|
||||
}
|
||||
|
||||
// Test 3: inspect handles daemon not running
|
||||
{
|
||||
console.log('Test 3: inspect handles daemon not running')
|
||||
console.log("Test 3: inspect handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('inspect handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("inspect handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: inspect --host flag is accepted
|
||||
{
|
||||
console.log('Test 4: inspect --host flag is accepted')
|
||||
console.log("Test 4: inspect --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo inspect --host localhost:${port} abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('inspect --host flag is accepted\n')
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo inspect --host localhost:${port} abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("inspect --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: -q (quiet) flag is accepted with inspect
|
||||
{
|
||||
console.log('Test 5: -q (quiet) flag is accepted with inspect')
|
||||
console.log("Test 5: -q (quiet) flag is accepted with inspect");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('-q (quiet) flag is accepted with inspect\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("-q (quiet) flag is accepted with inspect\n");
|
||||
}
|
||||
|
||||
// Test 6: --json flag is accepted with inspect
|
||||
{
|
||||
console.log('Test 6: --json flag is accepted with inspect')
|
||||
console.log("Test 6: --json flag is accepted with inspect");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --json flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('--json flag is accepted with inspect\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --json flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("--json flag is accepted with inspect\n");
|
||||
}
|
||||
|
||||
// Test 7: --format yaml flag is accepted with inspect
|
||||
{
|
||||
console.log('Test 7: --format yaml flag is accepted with inspect')
|
||||
console.log("Test 7: --format yaml flag is accepted with inspect");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('--format yaml flag is accepted with inspect\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --format yaml flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("--format yaml flag is accepted with inspect\n");
|
||||
}
|
||||
|
||||
// Test 8: paseo --help shows inspect command
|
||||
{
|
||||
console.log('Test 8: paseo --help shows inspect command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('inspect'), 'help should mention inspect command')
|
||||
console.log('paseo --help shows inspect command\n')
|
||||
console.log("Test 8: paseo --help shows inspect command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("inspect"), "help should mention inspect command");
|
||||
console.log("paseo --help shows inspect command\n");
|
||||
}
|
||||
|
||||
// Test 9: inspect command description is helpful
|
||||
{
|
||||
console.log('Test 9: inspect command description is helpful')
|
||||
const result = await $`npx paseo inspect --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
|
||||
console.log("Test 9: inspect command description is helpful");
|
||||
const result = await $`npx paseo inspect --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "inspect --help should exit 0");
|
||||
const hasDescription =
|
||||
result.stdout.toLowerCase().includes('detail') ||
|
||||
result.stdout.toLowerCase().includes('information') ||
|
||||
result.stdout.toLowerCase().includes('show')
|
||||
assert(hasDescription, 'help should describe what inspect does')
|
||||
console.log('inspect command description is helpful\n')
|
||||
result.stdout.toLowerCase().includes("detail") ||
|
||||
result.stdout.toLowerCase().includes("information") ||
|
||||
result.stdout.toLowerCase().includes("show");
|
||||
assert(hasDescription, "help should describe what inspect does");
|
||||
console.log("inspect command description is helpful\n");
|
||||
}
|
||||
|
||||
// Test 10: ID prefix syntax is mentioned in help
|
||||
{
|
||||
console.log('Test 10: inspect command mentions ID')
|
||||
const result = await $`npx paseo inspect --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
|
||||
console.log("Test 10: inspect command mentions ID");
|
||||
const result = await $`npx paseo inspect --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "inspect --help should exit 0");
|
||||
const hasIdMention =
|
||||
result.stdout.toLowerCase().includes('id') ||
|
||||
result.stdout.toLowerCase().includes('prefix')
|
||||
assert(hasIdMention, 'help should mention ID or prefix')
|
||||
console.log('inspect command mentions ID\n')
|
||||
result.stdout.toLowerCase().includes("id") || result.stdout.toLowerCase().includes("prefix");
|
||||
assert(hasIdMention, "help should mention ID or prefix");
|
||||
console.log("inspect command mentions ID\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All inspect tests passed ===')
|
||||
console.log("=== All inspect tests passed ===");
|
||||
|
||||
@@ -16,124 +16,124 @@
|
||||
* - agent mode --list flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Agent Mode Command Tests ===\n')
|
||||
console.log("=== Agent Mode Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: agent mode --help shows options
|
||||
{
|
||||
console.log('Test 1: agent mode --help shows options')
|
||||
const result = await $`npx paseo agent mode --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent mode --help should exit 0')
|
||||
assert(result.stdout.includes('--list'), 'help should mention --list flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||
assert(result.stdout.includes('[mode]'), 'help should mention optional mode argument')
|
||||
console.log('✓ agent mode --help shows options\n')
|
||||
console.log("Test 1: agent mode --help shows options");
|
||||
const result = await $`npx paseo agent mode --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "agent mode --help should exit 0");
|
||||
assert(result.stdout.includes("--list"), "help should mention --list flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention id argument");
|
||||
assert(result.stdout.includes("[mode]"), "help should mention optional mode argument");
|
||||
console.log("✓ agent mode --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: agent mode requires id argument
|
||||
{
|
||||
console.log('Test 2: agent mode requires id argument')
|
||||
console.log("Test 2: agent mode requires id argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ agent mode requires id argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument") ||
|
||||
output.toLowerCase().includes("id");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ agent mode requires id argument\n");
|
||||
}
|
||||
|
||||
// Test 3: agent mode handles daemon not running
|
||||
{
|
||||
console.log('Test 3: agent mode handles daemon not running')
|
||||
console.log("Test 3: agent mode handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ agent mode handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ agent mode handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: agent mode --list flag is accepted
|
||||
{
|
||||
console.log('Test 4: agent mode --list flag is accepted')
|
||||
console.log("Test 4: agent mode --list flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --list flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent mode --list flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --list flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ agent mode --list flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: agent mode with ID and --host flag is accepted
|
||||
{
|
||||
console.log('Test 5: agent mode with ID and --host flag is accepted')
|
||||
console.log("Test 5: agent mode with ID and --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent mode with ID and --host flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ agent mode with ID and --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: agent shows mode in subcommands
|
||||
{
|
||||
console.log('Test 6: agent --help shows mode subcommand')
|
||||
const result = await $`npx paseo agent --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||
assert(result.stdout.includes('mode'), 'help should mention mode subcommand')
|
||||
console.log('✓ agent --help shows mode subcommand\n')
|
||||
console.log("Test 6: agent --help shows mode subcommand");
|
||||
const result = await $`npx paseo agent --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "agent --help should exit 0");
|
||||
assert(result.stdout.includes("mode"), "help should mention mode subcommand");
|
||||
console.log("✓ agent --help shows mode subcommand\n");
|
||||
}
|
||||
|
||||
// Test 7: -q (quiet) flag is accepted with agent mode
|
||||
{
|
||||
console.log('Test 7: -q (quiet) flag is accepted with agent mode')
|
||||
console.log("Test 7: -q (quiet) flag is accepted with agent mode");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with agent mode\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with agent mode\n");
|
||||
}
|
||||
|
||||
// Test 8: agent mode requires mode argument when not using --list
|
||||
{
|
||||
console.log('Test 8: agent mode requires mode argument when not using --list')
|
||||
console.log("Test 8: agent mode requires mode argument when not using --list");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow();
|
||||
// Should fail because mode is required unless --list is specified
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without mode argument')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without mode argument");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes('Mode argument required unless --list is specified'),
|
||||
'error should mention missing mode argument'
|
||||
)
|
||||
console.log('✓ agent mode requires mode argument when not using --list\n')
|
||||
output.includes("Mode argument required unless --list is specified"),
|
||||
"error should mention missing mode argument",
|
||||
);
|
||||
console.log("✓ agent mode requires mode argument when not using --list\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All agent mode tests passed ===')
|
||||
console.log("=== All agent mode tests passed ===");
|
||||
|
||||
@@ -16,107 +16,107 @@
|
||||
* - agent archive --force flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Agent Archive Command Tests ===\n')
|
||||
console.log("=== Agent Archive Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: agent archive --help shows options
|
||||
{
|
||||
console.log('Test 1: agent archive --help shows options')
|
||||
const result = await $`npx paseo agent archive --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent archive --help should exit 0')
|
||||
assert(result.stdout.includes('--force'), 'help should mention --force flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||
console.log('✓ agent archive --help shows options\n')
|
||||
console.log("Test 1: agent archive --help shows options");
|
||||
const result = await $`npx paseo agent archive --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "agent archive --help should exit 0");
|
||||
assert(result.stdout.includes("--force"), "help should mention --force flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention required id argument");
|
||||
console.log("✓ agent archive --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: agent archive requires ID argument
|
||||
{
|
||||
console.log('Test 2: agent archive requires ID argument')
|
||||
console.log("Test 2: agent archive requires ID argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ agent archive requires ID argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ agent archive requires ID argument\n");
|
||||
}
|
||||
|
||||
// Test 3: agent archive handles daemon not running
|
||||
{
|
||||
console.log('Test 3: agent archive handles daemon not running')
|
||||
console.log("Test 3: agent archive handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ agent archive handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ agent archive handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: agent archive --force flag is accepted
|
||||
{
|
||||
console.log('Test 4: agent archive --force flag is accepted')
|
||||
console.log("Test 4: agent archive --force flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --force flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent archive --force flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --force flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ agent archive --force flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: agent archive with ID and --host flag is accepted
|
||||
{
|
||||
console.log('Test 5: agent archive with ID and --host flag is accepted')
|
||||
console.log("Test 5: agent archive with ID and --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent archive with ID and --host flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ agent archive with ID and --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: agent shows archive in subcommands
|
||||
{
|
||||
console.log('Test 6: agent --help shows archive subcommand')
|
||||
const result = await $`npx paseo agent --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
|
||||
console.log('✓ agent --help shows archive subcommand\n')
|
||||
console.log("Test 6: agent --help shows archive subcommand");
|
||||
const result = await $`npx paseo agent --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "agent --help should exit 0");
|
||||
assert(result.stdout.includes("archive"), "help should mention archive subcommand");
|
||||
console.log("✓ agent --help shows archive subcommand\n");
|
||||
}
|
||||
|
||||
// Test 7: -q (quiet) flag is accepted with agent archive
|
||||
{
|
||||
console.log('Test 7: -q (quiet) flag is accepted with agent archive')
|
||||
console.log("Test 7: -q (quiet) flag is accepted with agent archive");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with agent archive\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with agent archive\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All agent archive tests passed ===')
|
||||
console.log("=== All agent archive tests passed ===");
|
||||
|
||||
@@ -18,169 +18,167 @@
|
||||
* - agent shows wait in subcommands
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Wait Command Tests ===\n')
|
||||
console.log("=== Wait Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: wait --help shows options
|
||||
{
|
||||
console.log('Test 1: wait --help shows options')
|
||||
const result = await $`npx paseo wait --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('--timeout'), 'help should mention --timeout option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||
console.log(' help should mention --host option')
|
||||
console.log(' help should mention --timeout option')
|
||||
console.log(' help should mention <id> argument')
|
||||
console.log('wait --help shows options\n')
|
||||
console.log("Test 1: wait --help shows options");
|
||||
const result = await $`npx paseo wait --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("--timeout"), "help should mention --timeout option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention id argument");
|
||||
console.log(" help should mention --host option");
|
||||
console.log(" help should mention --timeout option");
|
||||
console.log(" help should mention <id> argument");
|
||||
console.log("wait --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: wait requires id argument
|
||||
{
|
||||
console.log('Test 2: wait requires id argument')
|
||||
console.log("Test 2: wait requires id argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
// Commander should complain about missing argument
|
||||
const hasMissingArg =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasMissingArg, 'error should mention missing argument')
|
||||
console.log('wait requires id argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasMissingArg, "error should mention missing argument");
|
||||
console.log("wait requires id argument\n");
|
||||
}
|
||||
|
||||
// Test 3: wait handles daemon not running
|
||||
{
|
||||
console.log('Test 3: wait handles daemon not running')
|
||||
console.log("Test 3: wait handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('wait handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("wait handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: wait --timeout flag is accepted
|
||||
{
|
||||
console.log('Test 4: wait --timeout flag is accepted')
|
||||
console.log("Test 4: wait --timeout flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo wait --timeout 30 --host localhost:${port} abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --timeout flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('wait --timeout flag is accepted\n')
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo wait --timeout 30 --host localhost:${port} abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --timeout flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("wait --timeout flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: wait --host flag is accepted
|
||||
{
|
||||
console.log('Test 5: wait --host flag is accepted')
|
||||
console.log("Test 5: wait --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo wait --host localhost:${port} abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('wait --host flag is accepted\n')
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo wait --host localhost:${port} abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("wait --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: -q (quiet) flag is accepted with wait
|
||||
{
|
||||
console.log('Test 6: -q (quiet) flag is accepted with wait')
|
||||
console.log("Test 6: -q (quiet) flag is accepted with wait");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('-q (quiet) flag is accepted with wait\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("-q (quiet) flag is accepted with wait\n");
|
||||
}
|
||||
|
||||
// Test 7: --json flag is accepted with wait
|
||||
{
|
||||
console.log('Test 7: --json flag is accepted with wait')
|
||||
console.log("Test 7: --json flag is accepted with wait");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --json flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('--json flag is accepted with wait\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --json flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("--json flag is accepted with wait\n");
|
||||
}
|
||||
|
||||
// Test 8: --format yaml flag is accepted with wait
|
||||
{
|
||||
console.log('Test 8: --format yaml flag is accepted with wait')
|
||||
console.log("Test 8: --format yaml flag is accepted with wait");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('--format yaml flag is accepted with wait\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --format yaml flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("--format yaml flag is accepted with wait\n");
|
||||
}
|
||||
|
||||
// Test 9: paseo --help shows wait command
|
||||
{
|
||||
console.log('Test 9: paseo --help shows wait command')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('wait'), 'help should mention wait command')
|
||||
console.log('paseo --help shows wait command\n')
|
||||
console.log("Test 9: paseo --help shows wait command");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("wait"), "help should mention wait command");
|
||||
console.log("paseo --help shows wait command\n");
|
||||
}
|
||||
|
||||
// Test 10: wait command description is helpful
|
||||
{
|
||||
console.log('Test 10: wait command description is helpful')
|
||||
const result = await $`npx paseo wait --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||
console.log("Test 10: wait command description is helpful");
|
||||
const result = await $`npx paseo wait --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
|
||||
const hasDescription =
|
||||
result.stdout.toLowerCase().includes('wait') ||
|
||||
result.stdout.toLowerCase().includes('idle')
|
||||
assert(hasDescription, 'help should describe what wait does')
|
||||
console.log('wait command description is helpful\n')
|
||||
result.stdout.toLowerCase().includes("wait") || result.stdout.toLowerCase().includes("idle");
|
||||
assert(hasDescription, "help should describe what wait does");
|
||||
console.log("wait command description is helpful\n");
|
||||
}
|
||||
|
||||
// Test 11: ID prefix syntax is mentioned in help
|
||||
{
|
||||
console.log('Test 11: wait command mentions ID')
|
||||
const result = await $`npx paseo wait --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||
console.log("Test 11: wait command mentions ID");
|
||||
const result = await $`npx paseo wait --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
|
||||
const hasIdMention =
|
||||
result.stdout.toLowerCase().includes('id') ||
|
||||
result.stdout.toLowerCase().includes('prefix')
|
||||
assert(hasIdMention, 'help should mention ID or prefix')
|
||||
console.log('wait command mentions ID\n')
|
||||
result.stdout.toLowerCase().includes("id") || result.stdout.toLowerCase().includes("prefix");
|
||||
assert(hasIdMention, "help should mention ID or prefix");
|
||||
console.log("wait command mentions ID\n");
|
||||
}
|
||||
|
||||
// Test 12: timeout option documents no default limit
|
||||
{
|
||||
console.log('Test 12: timeout option documents no default limit')
|
||||
const result = await $`npx paseo wait --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||
console.log("Test 12: timeout option documents no default limit");
|
||||
const result = await $`npx paseo wait --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
|
||||
assert(
|
||||
result.stdout.toLowerCase().includes('default: no limit'),
|
||||
'help should mention timeout default is no limit'
|
||||
)
|
||||
console.log('timeout option documents no default limit\n')
|
||||
result.stdout.toLowerCase().includes("default: no limit"),
|
||||
"help should mention timeout default is no limit",
|
||||
);
|
||||
console.log("timeout option documents no default limit\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All wait tests passed ===')
|
||||
console.log("=== All wait tests passed ===");
|
||||
|
||||
@@ -16,92 +16,92 @@
|
||||
* - permit ls --json handles errors
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Permit LS Command Tests ===\n')
|
||||
console.log("=== Permit LS Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: permit --help shows subcommands
|
||||
{
|
||||
console.log('Test 1: permit --help shows subcommands')
|
||||
const result = await $`npx paseo permit --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'permit --help should exit 0')
|
||||
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
|
||||
assert(result.stdout.includes('allow'), 'help should mention allow subcommand')
|
||||
assert(result.stdout.includes('deny'), 'help should mention deny subcommand')
|
||||
console.log('✓ permit --help shows subcommands\n')
|
||||
console.log("Test 1: permit --help shows subcommands");
|
||||
const result = await $`npx paseo permit --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "permit --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls subcommand");
|
||||
assert(result.stdout.includes("allow"), "help should mention allow subcommand");
|
||||
assert(result.stdout.includes("deny"), "help should mention deny subcommand");
|
||||
console.log("✓ permit --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: permit ls --help shows options
|
||||
{
|
||||
console.log('Test 2: permit ls --help shows options')
|
||||
const result = await $`npx paseo permit ls --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'permit ls --help should exit 0')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
console.log('✓ permit ls --help shows options\n')
|
||||
console.log("Test 2: permit ls --help shows options");
|
||||
const result = await $`npx paseo permit ls --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "permit ls --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
console.log("✓ permit ls --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 3: permit ls returns error when no daemon running
|
||||
{
|
||||
console.log('Test 3: permit ls handles daemon not running')
|
||||
console.log("Test 3: permit ls handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ permit ls handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ permit ls handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: permit ls --json handles errors
|
||||
{
|
||||
console.log('Test 4: permit ls --json handles errors')
|
||||
console.log("Test 4: permit ls --json handles errors");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow();
|
||||
// Should still fail (daemon not running)
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
// But output should be valid JSON if present
|
||||
const output = result.stdout.trim()
|
||||
const output = result.stdout.trim();
|
||||
if (output.length > 0) {
|
||||
try {
|
||||
JSON.parse(output)
|
||||
console.log('✓ permit ls --json outputs valid JSON error\n')
|
||||
JSON.parse(output);
|
||||
console.log("✓ permit ls --json outputs valid JSON error\n");
|
||||
} catch {
|
||||
// Empty or stderr-only output is acceptable
|
||||
console.log('✓ permit ls --json handled error (output may be in stderr)\n')
|
||||
console.log("✓ permit ls --json handled error (output may be in stderr)\n");
|
||||
}
|
||||
} else {
|
||||
console.log('✓ permit ls --json handled error gracefully\n')
|
||||
console.log("✓ permit ls --json handled error gracefully\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: -q (quiet) flag is accepted globally
|
||||
{
|
||||
console.log('Test 5: -q (quiet) flag is accepted')
|
||||
console.log("Test 5: -q (quiet) flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All permit ls tests passed ===')
|
||||
console.log("=== All permit ls tests passed ===");
|
||||
|
||||
@@ -21,155 +21,155 @@
|
||||
* - permit allow --input flag is accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Permit Allow/Deny Command Tests ===\n')
|
||||
console.log("=== Permit Allow/Deny Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: permit allow --help shows options
|
||||
{
|
||||
console.log('Test 1: permit allow --help shows options')
|
||||
const result = await $`npx paseo permit allow --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'permit allow --help should exit 0')
|
||||
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||
assert(result.stdout.includes('--input'), 'help should mention --input option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
|
||||
console.log('✓ permit allow --help shows options\n')
|
||||
console.log("Test 1: permit allow --help shows options");
|
||||
const result = await $`npx paseo permit allow --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "permit allow --help should exit 0");
|
||||
assert(result.stdout.includes("--all"), "help should mention --all flag");
|
||||
assert(result.stdout.includes("--input"), "help should mention --input option");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<agent>"), "help should mention agent argument");
|
||||
console.log("✓ permit allow --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: permit deny --help shows options
|
||||
{
|
||||
console.log('Test 2: permit deny --help shows options')
|
||||
const result = await $`npx paseo permit deny --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'permit deny --help should exit 0')
|
||||
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||
assert(result.stdout.includes('--message'), 'help should mention --message option')
|
||||
assert(result.stdout.includes('--interrupt'), 'help should mention --interrupt flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
|
||||
console.log('✓ permit deny --help shows options\n')
|
||||
console.log("Test 2: permit deny --help shows options");
|
||||
const result = await $`npx paseo permit deny --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "permit deny --help should exit 0");
|
||||
assert(result.stdout.includes("--all"), "help should mention --all flag");
|
||||
assert(result.stdout.includes("--message"), "help should mention --message option");
|
||||
assert(result.stdout.includes("--interrupt"), "help should mention --interrupt flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<agent>"), "help should mention agent argument");
|
||||
console.log("✓ permit deny --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 3: permit allow handles daemon not running
|
||||
{
|
||||
console.log('Test 3: permit allow handles daemon not running')
|
||||
console.log("Test 3: permit allow handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ permit allow handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ permit allow handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: permit deny handles daemon not running
|
||||
{
|
||||
console.log('Test 4: permit deny handles daemon not running')
|
||||
console.log("Test 4: permit deny handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ permit deny handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ permit deny handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 5: permit allow --all flag is accepted
|
||||
{
|
||||
console.log('Test 5: permit allow --all flag is accepted')
|
||||
console.log("Test 5: permit allow --all flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ permit allow --all flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --all flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ permit allow --all flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 6: permit deny --all flag is accepted
|
||||
{
|
||||
console.log('Test 6: permit deny --all flag is accepted')
|
||||
console.log("Test 6: permit deny --all flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ permit deny --all flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --all flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ permit deny --all flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 7: permit deny --message flag is accepted
|
||||
{
|
||||
console.log('Test 7: permit deny --message flag is accepted')
|
||||
console.log("Test 7: permit deny --message flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --message flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ permit deny --message flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --message flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ permit deny --message flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 8: permit deny --interrupt flag is accepted
|
||||
{
|
||||
console.log('Test 8: permit deny --interrupt flag is accepted')
|
||||
console.log("Test 8: permit deny --interrupt flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --interrupt flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ permit deny --interrupt flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --interrupt flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ permit deny --interrupt flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 9: permit allow --input flag is accepted
|
||||
{
|
||||
console.log('Test 9: permit allow --input flag is accepted')
|
||||
console.log("Test 9: permit allow --input flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --input flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ permit allow --input flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --input flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ permit allow --input flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 10: permit allow without req_id and without --all fails gracefully
|
||||
{
|
||||
console.log('Test 10: permit allow requires req_id or --all')
|
||||
console.log("Test 10: permit allow requires req_id or --all");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow();
|
||||
// This might fail due to daemon not running first, or due to missing argument
|
||||
// The important thing is it doesn't crash with an unhandled error
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
|
||||
console.log('✓ permit allow requires req_id or --all\n')
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all");
|
||||
console.log("✓ permit allow requires req_id or --all\n");
|
||||
}
|
||||
|
||||
// Test 11: permit deny without req_id and without --all fails gracefully
|
||||
{
|
||||
console.log('Test 11: permit deny requires req_id or --all')
|
||||
console.log("Test 11: permit deny requires req_id or --all");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
|
||||
console.log('✓ permit deny requires req_id or --all\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all");
|
||||
console.log("✓ permit deny requires req_id or --all\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All permit allow/deny tests passed ===')
|
||||
console.log("=== All permit allow/deny tests passed ===");
|
||||
|
||||
@@ -18,152 +18,152 @@
|
||||
* - worktree archive handles daemon not running
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Worktree Command Tests ===\n')
|
||||
console.log("=== Worktree Command Tests ===\n");
|
||||
|
||||
// Get random port that's definitely not in use (never 6767)
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: worktree --help shows subcommands
|
||||
{
|
||||
console.log('Test 1: worktree --help shows subcommands')
|
||||
const result = await $`npx paseo worktree --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'worktree --help should exit 0')
|
||||
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
|
||||
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
|
||||
console.log('✓ worktree --help shows subcommands\n')
|
||||
console.log("Test 1: worktree --help shows subcommands");
|
||||
const result = await $`npx paseo worktree --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "worktree --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls subcommand");
|
||||
assert(result.stdout.includes("archive"), "help should mention archive subcommand");
|
||||
console.log("✓ worktree --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: worktree ls --help shows options
|
||||
{
|
||||
console.log('Test 2: worktree ls --help shows options')
|
||||
const result = await $`npx paseo worktree ls --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'worktree ls --help should exit 0')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
console.log('✓ worktree ls --help shows options\n')
|
||||
console.log("Test 2: worktree ls --help shows options");
|
||||
const result = await $`npx paseo worktree ls --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "worktree ls --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
console.log("✓ worktree ls --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 3: worktree ls handles daemon not running
|
||||
{
|
||||
console.log('Test 3: worktree ls handles daemon not running')
|
||||
console.log("Test 3: worktree ls handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ worktree ls handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ worktree ls handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 4: worktree ls with --host flag is accepted
|
||||
{
|
||||
console.log('Test 4: worktree ls with --host flag is accepted')
|
||||
console.log("Test 4: worktree ls with --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ worktree ls with --host flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ worktree ls with --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 5: worktree archive --help shows options
|
||||
{
|
||||
console.log('Test 5: worktree archive --help shows options')
|
||||
const result = await $`npx paseo worktree archive --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'worktree archive --help should exit 0')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<name>'), 'help should mention required name argument')
|
||||
console.log('✓ worktree archive --help shows options\n')
|
||||
console.log("Test 5: worktree archive --help shows options");
|
||||
const result = await $`npx paseo worktree archive --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "worktree archive --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<name>"), "help should mention required name argument");
|
||||
console.log("✓ worktree archive --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 6: worktree archive requires name argument
|
||||
{
|
||||
console.log('Test 6: worktree archive requires name argument')
|
||||
console.log("Test 6: worktree archive requires name argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without name')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without name");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ worktree archive requires name argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ worktree archive requires name argument\n");
|
||||
}
|
||||
|
||||
// Test 7: worktree archive handles daemon not running
|
||||
{
|
||||
console.log('Test 7: worktree archive handles daemon not running')
|
||||
console.log("Test 7: worktree archive handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow()
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow();
|
||||
// Should fail because daemon not running
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ worktree archive handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ worktree archive handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 8: worktree archive with name and --host flag is accepted
|
||||
{
|
||||
console.log('Test 8: worktree archive with name and --host flag is accepted')
|
||||
console.log("Test 8: worktree archive with name and --host flag is accepted");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ worktree archive with name and --host flag is accepted\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ worktree archive with name and --host flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 9: -q (quiet) flag is accepted with worktree ls
|
||||
{
|
||||
console.log('Test 9: -q (quiet) flag is accepted with worktree ls')
|
||||
console.log("Test 9: -q (quiet) flag is accepted with worktree ls");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with worktree ls\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with worktree ls\n");
|
||||
}
|
||||
|
||||
// Test 10: --json flag is accepted with worktree ls
|
||||
{
|
||||
console.log('Test 10: --json flag is accepted with worktree ls')
|
||||
console.log("Test 10: --json flag is accepted with worktree ls");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --json flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ --json flag is accepted with worktree ls\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --json flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ --json flag is accepted with worktree ls\n");
|
||||
}
|
||||
|
||||
// Test 11: paseo --help shows worktree subcommand
|
||||
{
|
||||
console.log('Test 11: paseo --help shows worktree subcommand')
|
||||
const result = await $`npx paseo --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('worktree'), 'help should mention worktree subcommand')
|
||||
console.log('✓ paseo --help shows worktree subcommand\n')
|
||||
console.log("Test 11: paseo --help shows worktree subcommand");
|
||||
const result = await $`npx paseo --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("worktree"), "help should mention worktree subcommand");
|
||||
console.log("✓ paseo --help shows worktree subcommand\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All worktree tests passed ===')
|
||||
console.log("=== All worktree tests passed ===");
|
||||
|
||||
@@ -19,230 +19,263 @@
|
||||
* - provider models --json outputs valid JSON
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext } from './helpers/test-daemon.ts'
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log('=== Provider Commands ===\n')
|
||||
console.log("=== Provider Commands ===\n");
|
||||
|
||||
type ProviderModel = {
|
||||
model: string
|
||||
id: string
|
||||
description?: string
|
||||
}
|
||||
model: string;
|
||||
id: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
model: 'Sonnet 4.5',
|
||||
descriptionFragment: 'Best for everyday tasks',
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
model: "Sonnet 4.5",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
model: 'Sonnet 4.6',
|
||||
descriptionFragment: 'Best for everyday tasks',
|
||||
id: "claude-sonnet-4-6",
|
||||
model: "Sonnet 4.6",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-6',
|
||||
model: 'Opus 4.6',
|
||||
descriptionFragment: 'Most capable',
|
||||
id: "claude-opus-4-6",
|
||||
model: "Opus 4.6",
|
||||
descriptionFragment: "Most capable",
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
model: 'Haiku 4.5',
|
||||
descriptionFragment: 'Fastest',
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
model: "Haiku 4.5",
|
||||
descriptionFragment: "Fastest",
|
||||
},
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
let claudeModelIdsFromJson: string[] = []
|
||||
let claudeModelsFromJson: ProviderModel[] = []
|
||||
let claudeModelIdsFromJson: string[] = [];
|
||||
let claudeModelsFromJson: ProviderModel[] = [];
|
||||
|
||||
const ctx = await createE2ETestContext({ timeout: 120000 })
|
||||
const ctx = await createE2ETestContext({ timeout: 120000 });
|
||||
|
||||
async function runProviderModelsJson(
|
||||
provider: 'claude' | 'codex' | 'opencode'
|
||||
provider: "claude" | "codex" | "opencode",
|
||||
): Promise<ProviderModel[]> {
|
||||
const transientNeedles = ['transport closed', 'timed out', 'timeout', 'socket', 'econn']
|
||||
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const result = await ctx.paseo(['provider', 'models', provider, '--json'])
|
||||
const result = await ctx.paseo(["provider", "models", provider, "--json"]);
|
||||
if (result.exitCode === 0) {
|
||||
return JSON.parse(result.stdout.trim()) as ProviderModel[]
|
||||
return JSON.parse(result.stdout.trim()) as ProviderModel[];
|
||||
}
|
||||
|
||||
const combined = `${result.stdout}\n${result.stderr}`
|
||||
const normalized = combined.toLowerCase()
|
||||
const isTransient = transientNeedles.some((needle) => normalized.includes(needle))
|
||||
const combined = `${result.stdout}\n${result.stderr}`;
|
||||
const normalized = combined.toLowerCase();
|
||||
const isTransient = transientNeedles.some((needle) => normalized.includes(needle));
|
||||
|
||||
if (!isTransient || attempt === 3) {
|
||||
assert.fail(`provider models ${provider} should exit 0\n${combined}`)
|
||||
assert.fail(`provider models ${provider} should exit 0\n${combined}`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
||||
}
|
||||
|
||||
assert.fail(`provider models ${provider} exhausted retries`)
|
||||
assert.fail(`provider models ${provider} exhausted retries`);
|
||||
}
|
||||
|
||||
function assertClaudeModels(data: ProviderModel[]): void {
|
||||
assert.strictEqual(data.length, EXPECTED_CLAUDE_MODELS.length, 'claude output should match the current catalog size')
|
||||
assert.strictEqual(
|
||||
data.length,
|
||||
EXPECTED_CLAUDE_MODELS.length,
|
||||
"claude output should match the current catalog size",
|
||||
);
|
||||
|
||||
const byId = new Map(data.map((model) => [model.id, model]))
|
||||
const ids = [...byId.keys()].sort()
|
||||
const expectedIds = EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort()
|
||||
const byId = new Map(data.map((model) => [model.id, model]));
|
||||
const ids = [...byId.keys()].sort();
|
||||
const expectedIds = EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort();
|
||||
|
||||
assert.strictEqual(byId.size, data.length, 'claude model IDs should be unique')
|
||||
assert.deepStrictEqual(ids, expectedIds, 'claude IDs should match the current catalog')
|
||||
assert.strictEqual(byId.size, data.length, "claude model IDs should be unique");
|
||||
assert.deepStrictEqual(ids, expectedIds, "claude IDs should match the current catalog");
|
||||
|
||||
for (const expectedModel of EXPECTED_CLAUDE_MODELS) {
|
||||
const actualModel = byId.get(expectedModel.id)
|
||||
assert(actualModel, `claude output should include ${expectedModel.id}`)
|
||||
assert.strictEqual(actualModel.model, expectedModel.model, `${expectedModel.id} should keep its display name`)
|
||||
const actualModel = byId.get(expectedModel.id);
|
||||
assert(actualModel, `claude output should include ${expectedModel.id}`);
|
||||
assert.strictEqual(
|
||||
actualModel.model,
|
||||
expectedModel.model,
|
||||
`${expectedModel.id} should keep its display name`,
|
||||
);
|
||||
assert(
|
||||
(actualModel.description ?? '').includes(expectedModel.descriptionFragment),
|
||||
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`
|
||||
)
|
||||
(actualModel.description ?? "").includes(expectedModel.descriptionFragment),
|
||||
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Test 1: provider --help shows subcommands
|
||||
{
|
||||
console.log('Test 1: provider --help shows subcommands')
|
||||
const result = await ctx.paseo(['provider', '--help'])
|
||||
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
|
||||
assert(result.stdout.includes('ls'), 'help should mention ls')
|
||||
assert(result.stdout.includes('models'), 'help should mention models')
|
||||
console.log('✓ provider --help shows subcommands\n')
|
||||
console.log("Test 1: provider --help shows subcommands");
|
||||
const result = await ctx.paseo(["provider", "--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls");
|
||||
assert(result.stdout.includes("models"), "help should mention models");
|
||||
console.log("✓ provider --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: provider ls lists all providers
|
||||
{
|
||||
console.log('Test 2: provider ls lists all providers')
|
||||
const result = await ctx.paseo(['provider', 'ls'])
|
||||
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
|
||||
assert(result.stdout.includes('claude'), 'output should include claude')
|
||||
assert(result.stdout.includes('codex'), 'output should include codex')
|
||||
assert(result.stdout.includes('opencode'), 'output should include opencode')
|
||||
assert(result.stdout.includes('available'), 'output should show available status')
|
||||
console.log('✓ provider ls lists all providers\n')
|
||||
console.log("Test 2: provider ls lists all providers");
|
||||
const result = await ctx.paseo(["provider", "ls"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("available"), "output should show available status");
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
}
|
||||
|
||||
// Test 3: provider ls --json outputs valid JSON
|
||||
{
|
||||
console.log('Test 3: provider ls --json outputs valid JSON')
|
||||
const result = await ctx.paseo(['provider', 'ls', '--json'])
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||
const data = JSON.parse(result.stdout.trim())
|
||||
assert(Array.isArray(data), 'output should be an array')
|
||||
assert.strictEqual(data.length, 3, 'should have 3 providers')
|
||||
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
|
||||
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
|
||||
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
|
||||
console.log('✓ provider ls --json outputs valid JSON\n')
|
||||
console.log("Test 3: provider ls --json outputs valid JSON");
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert.strictEqual(data.length, 3, "should have 3 providers");
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "codex"),
|
||||
"should include codex",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 4: provider ls --quiet outputs provider names only
|
||||
{
|
||||
console.log('Test 4: provider ls --quiet outputs provider names only')
|
||||
const result = await ctx.paseo(['provider', 'ls', '--quiet'])
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||
const lines = result.stdout.trim().split('\n')
|
||||
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
||||
assert(lines.includes('claude'), 'should include claude')
|
||||
assert(lines.includes('codex'), 'should include codex')
|
||||
assert(lines.includes('opencode'), 'should include opencode')
|
||||
console.log('✓ provider ls --quiet outputs provider names only\n')
|
||||
console.log("Test 4: provider ls --quiet outputs provider names only");
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert.strictEqual(lines.length, 3, "should have 3 lines");
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
// Test 5: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log('Test 5: provider models claude lists canonical model aliases')
|
||||
const data = await runProviderModelsJson('claude')
|
||||
assertClaudeModels(data)
|
||||
console.log('✓ provider models claude lists canonical model aliases\n')
|
||||
console.log("Test 5: provider models claude lists canonical model aliases");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assertClaudeModels(data);
|
||||
console.log("✓ provider models claude lists canonical model aliases\n");
|
||||
}
|
||||
|
||||
// Test 6: provider models codex includes concrete codex model IDs
|
||||
{
|
||||
console.log('Test 6: provider models codex includes concrete codex model IDs')
|
||||
const data = await runProviderModelsJson('codex')
|
||||
assert(data.length >= 6, 'codex model list should include current codex lineup')
|
||||
const ids = data.map((m) => m.id)
|
||||
assert.strictEqual(new Set(ids).size, ids.length, 'codex model IDs should be unique')
|
||||
assert(ids.includes('gpt-5.3-codex'), 'codex output should include gpt-5.3-codex')
|
||||
assert(ids.includes('gpt-5.3-codex-spark'), 'codex output should include gpt-5.3-codex-spark')
|
||||
assert(ids.includes('gpt-5.1-codex-max'), 'codex output should include gpt-5.1-codex-max')
|
||||
assert(ids.includes('gpt-5.1-codex-mini'), 'codex output should include gpt-5.1-codex-mini')
|
||||
console.log('✓ provider models codex includes concrete codex model IDs\n')
|
||||
console.log("Test 6: provider models codex includes concrete codex model IDs");
|
||||
const data = await runProviderModelsJson("codex");
|
||||
assert(data.length >= 6, "codex model list should include current codex lineup");
|
||||
const ids = data.map((m) => m.id);
|
||||
assert.strictEqual(new Set(ids).size, ids.length, "codex model IDs should be unique");
|
||||
assert(ids.includes("gpt-5.3-codex"), "codex output should include gpt-5.3-codex");
|
||||
assert(ids.includes("gpt-5.3-codex-spark"), "codex output should include gpt-5.3-codex-spark");
|
||||
assert(ids.includes("gpt-5.1-codex-max"), "codex output should include gpt-5.1-codex-max");
|
||||
assert(ids.includes("gpt-5.1-codex-mini"), "codex output should include gpt-5.1-codex-mini");
|
||||
console.log("✓ provider models codex includes concrete codex model IDs\n");
|
||||
}
|
||||
|
||||
// Test 7: provider models opencode returns namespaced model IDs
|
||||
{
|
||||
console.log('Test 7: provider models opencode returns namespaced model IDs')
|
||||
const data = await runProviderModelsJson('opencode')
|
||||
assert(data.length >= 3, 'opencode model list should not be empty')
|
||||
const ids = data.map((m) => m.id)
|
||||
assert(data.every((m) => m.id.includes('/')), 'opencode model IDs should be provider-namespaced')
|
||||
assert(ids.includes('opencode/gpt-5-nano'), 'opencode output should include opencode/gpt-5-nano')
|
||||
assert(ids.includes('openai/o3-mini'), 'opencode output should include openai/o3-mini')
|
||||
console.log("Test 7: provider models opencode returns namespaced model IDs");
|
||||
const data = await runProviderModelsJson("opencode");
|
||||
assert(data.length >= 3, "opencode model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
assert(
|
||||
ids.includes('openai/gpt-5.3-codex-spark'),
|
||||
'opencode output should include openai/gpt-5.3-codex-spark'
|
||||
)
|
||||
console.log('✓ provider models opencode returns namespaced model IDs\n')
|
||||
data.every((m) => m.id.includes("/")),
|
||||
"opencode model IDs should be provider-namespaced",
|
||||
);
|
||||
assert(
|
||||
ids.includes("opencode/gpt-5-nano"),
|
||||
"opencode output should include opencode/gpt-5-nano",
|
||||
);
|
||||
assert(ids.includes("openai/o3-mini"), "opencode output should include openai/o3-mini");
|
||||
assert(
|
||||
ids.includes("openai/gpt-5.3-codex-spark"),
|
||||
"opencode output should include openai/gpt-5.3-codex-spark",
|
||||
);
|
||||
console.log("✓ provider models opencode returns namespaced model IDs\n");
|
||||
}
|
||||
|
||||
// Test 8: provider models unknown fails with error
|
||||
{
|
||||
console.log('Test 8: provider models unknown fails with error')
|
||||
const result = await ctx.paseo(['provider', 'models', 'unknown'])
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
|
||||
const output = result.stdout + result.stderr
|
||||
console.log("Test 8: provider models unknown fails with error");
|
||||
const result = await ctx.paseo(["provider", "models", "unknown"]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
|
||||
'error should mention unknown provider'
|
||||
)
|
||||
console.log('✓ provider models unknown fails with error\n')
|
||||
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
|
||||
"error should mention unknown provider",
|
||||
);
|
||||
console.log("✓ provider models unknown fails with error\n");
|
||||
}
|
||||
|
||||
// Test 9: provider models --json outputs valid JSON
|
||||
{
|
||||
console.log('Test 9: provider models --json outputs valid JSON')
|
||||
const data = await runProviderModelsJson('claude')
|
||||
assert(Array.isArray(data), 'output should be an array')
|
||||
assert(data.every((m) => m.model && m.id), 'each model should have name and id')
|
||||
assertClaudeModels(data)
|
||||
claudeModelIdsFromJson = data.map((m) => m.id)
|
||||
claudeModelsFromJson = data
|
||||
console.log('✓ provider models --json outputs valid JSON\n')
|
||||
console.log("Test 9: provider models --json outputs valid JSON");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(
|
||||
data.every((m) => m.model && m.id),
|
||||
"each model should have name and id",
|
||||
);
|
||||
assertClaudeModels(data);
|
||||
claudeModelIdsFromJson = data.map((m) => m.id);
|
||||
claudeModelsFromJson = data;
|
||||
console.log("✓ provider models --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 10: provider models --quiet outputs model IDs only
|
||||
{
|
||||
console.log('Test 10: provider models --quiet outputs model IDs only')
|
||||
assert(claudeModelIdsFromJson.length > 0, 'claude model IDs should be captured from --json output')
|
||||
const result = await ctx.paseo(['provider', 'models', 'claude', '--quiet'])
|
||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||
assert.strictEqual(lines.length, EXPECTED_CLAUDE_MODELS.length, 'should have one line per Claude catalog model')
|
||||
console.log("Test 10: provider models --quiet outputs model IDs only");
|
||||
assert(
|
||||
claudeModelIdsFromJson.length > 0,
|
||||
"claude model IDs should be captured from --json output",
|
||||
);
|
||||
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||
assert.strictEqual(
|
||||
lines.length,
|
||||
EXPECTED_CLAUDE_MODELS.length,
|
||||
"should have one line per Claude catalog model",
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
[...lines].sort(),
|
||||
[...claudeModelIdsFromJson].sort(),
|
||||
'--quiet should print the same model IDs returned by --json'
|
||||
)
|
||||
"--quiet should print the same model IDs returned by --json",
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
[...lines].sort(),
|
||||
EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort(),
|
||||
'--quiet should print the current Claude catalog IDs'
|
||||
)
|
||||
"--quiet should print the current Claude catalog IDs",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === 'claude-sonnet-4-5-20250929'),
|
||||
'captured --json output should still include the Claude default model id'
|
||||
)
|
||||
console.log('✓ provider models --quiet outputs model IDs only\n')
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-5-20250929"),
|
||||
"captured --json output should still include the Claude default model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
}
|
||||
} finally {
|
||||
await ctx.stop()
|
||||
await ctx.stop();
|
||||
}
|
||||
|
||||
console.log('=== All provider tests passed ===')
|
||||
console.log("=== All provider tests passed ===");
|
||||
|
||||
@@ -10,103 +10,102 @@
|
||||
* - Graceful daemon connection errors
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { getAvailablePort } from './helpers/network.ts'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Agent Update Command Tests ===\n')
|
||||
console.log("=== Agent Update Command Tests ===\n");
|
||||
|
||||
// Resolve an available local port so daemon-not-running checks stay deterministic.
|
||||
const port = await getAvailablePort()
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
|
||||
try {
|
||||
// Test 1: agent update --help shows options
|
||||
{
|
||||
console.log('Test 1: agent update --help shows options')
|
||||
const result = await $`npx paseo agent update --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent update --help should exit 0')
|
||||
assert(result.stdout.includes('--name'), 'help should mention --name flag')
|
||||
assert(result.stdout.includes('--label'), 'help should mention --label flag')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||
console.log('✓ agent update --help shows options\n')
|
||||
console.log("Test 1: agent update --help shows options");
|
||||
const result = await $`npx paseo agent update --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
|
||||
assert(result.stdout.includes("--name"), "help should mention --name flag");
|
||||
assert(result.stdout.includes("--label"), "help should mention --label flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention required id argument");
|
||||
console.log("✓ agent update --help shows options\n");
|
||||
}
|
||||
|
||||
// Test 2: agent update requires ID argument
|
||||
{
|
||||
console.log('Test 2: agent update requires ID argument')
|
||||
console.log("Test 2: agent update requires ID argument");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ agent update requires ID argument\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument") ||
|
||||
output.toLowerCase().includes("id");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ agent update requires ID argument\n");
|
||||
}
|
||||
|
||||
// Test 3: agent update requires at least one update field
|
||||
{
|
||||
console.log('Test 3: agent update requires update field')
|
||||
console.log("Test 3: agent update requires update field");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without --name/--label')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without --name/--label");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('nothing to update') ||
|
||||
output.toLowerCase().includes('name') ||
|
||||
output.toLowerCase().includes('label')
|
||||
assert(hasError, 'error should mention missing update fields')
|
||||
console.log('✓ agent update requires update field\n')
|
||||
output.toLowerCase().includes("nothing to update") ||
|
||||
output.toLowerCase().includes("name") ||
|
||||
output.toLowerCase().includes("label");
|
||||
assert(hasError, "error should mention missing update fields");
|
||||
console.log("✓ agent update requires update field\n");
|
||||
}
|
||||
|
||||
// Test 4: agent update handles daemon not running
|
||||
{
|
||||
console.log('Test 4: agent update handles daemon not running')
|
||||
console.log("Test 4: agent update handles daemon not running");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow()
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error should mention connection issue')
|
||||
console.log('✓ agent update handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error should mention connection issue");
|
||||
console.log("✓ agent update handles daemon not running\n");
|
||||
}
|
||||
|
||||
// Test 5: agent update accepts multiple/comma-separated labels
|
||||
{
|
||||
console.log('Test 5: agent update accepts multi-label syntax')
|
||||
console.log("Test 5: agent update accepts multi-label syntax");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --label flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ agent update accepts multi-label syntax\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --label flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ agent update accepts multi-label syntax\n");
|
||||
}
|
||||
|
||||
// Test 6: agent --help shows update subcommand
|
||||
{
|
||||
console.log('Test 6: agent --help shows update subcommand')
|
||||
const result = await $`npx paseo agent --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||
assert(result.stdout.includes('update'), 'help should mention update subcommand')
|
||||
console.log('✓ agent --help shows update subcommand\n')
|
||||
console.log("Test 6: agent --help shows update subcommand");
|
||||
const result = await $`npx paseo agent --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "agent --help should exit 0");
|
||||
assert(result.stdout.includes("update"), "help should mention update subcommand");
|
||||
console.log("✓ agent --help shows update subcommand\n");
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All agent update tests passed ===')
|
||||
console.log("=== All agent update tests passed ===");
|
||||
|
||||
@@ -1,65 +1,85 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { $ } from 'zx'
|
||||
import { getAvailablePort } from './helpers/network.ts'
|
||||
import assert from "node:assert";
|
||||
import { readFile, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Onboarding Command ===\n')
|
||||
console.log("=== Onboarding Command ===\n");
|
||||
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
|
||||
const port = await getAvailablePort()
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
try {
|
||||
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
|
||||
console.log("Test 1: `paseo` runs blocking onboarding and prints pairing info");
|
||||
const onboard =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow()
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow();
|
||||
|
||||
assert.strictEqual(
|
||||
onboard.exitCode,
|
||||
0,
|
||||
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`
|
||||
)
|
||||
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
|
||||
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
|
||||
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
|
||||
assert(onboard.stdout.includes('CLI quick reference'), 'onboard output should include CLI quick reference')
|
||||
assert(onboard.stdout.includes('paseo --help'), 'onboard output should include --help shortcut')
|
||||
assert(onboard.stdout.includes('paseo ls'), 'onboard output should include ls shortcut')
|
||||
assert(onboard.stdout.includes('paseo run "your prompt"'), 'onboard output should include run shortcut')
|
||||
assert(onboard.stdout.includes('paseo status'), 'onboard output should include status shortcut')
|
||||
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
|
||||
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`,
|
||||
);
|
||||
assert(onboard.stdout.includes("Scan to pair"), "onboard output should include scan header");
|
||||
assert(
|
||||
onboard.stdout.includes("Pairing link"),
|
||||
"onboard output should include pairing link header",
|
||||
);
|
||||
assert(onboard.stdout.includes("#offer="), "onboard output should include pairing offer URL");
|
||||
assert(
|
||||
onboard.stdout.includes("CLI quick reference"),
|
||||
"onboard output should include CLI quick reference",
|
||||
);
|
||||
assert(onboard.stdout.includes("paseo --help"), "onboard output should include --help shortcut");
|
||||
assert(onboard.stdout.includes("paseo ls"), "onboard output should include ls shortcut");
|
||||
assert(
|
||||
onboard.stdout.includes('paseo run "your prompt"'),
|
||||
"onboard output should include run shortcut",
|
||||
);
|
||||
assert(onboard.stdout.includes("paseo status"), "onboard output should include status shortcut");
|
||||
assert(
|
||||
onboard.stdout.includes(join(paseoHome, "daemon.log")),
|
||||
"onboard output should include daemon log path",
|
||||
);
|
||||
|
||||
const status =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow()
|
||||
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
|
||||
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
|
||||
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow();
|
||||
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`);
|
||||
assert(status.stdout.includes("running"), "daemon should be running when onboarding exits");
|
||||
console.log("✓ onboarding prints pairing info and waits for daemon readiness\n");
|
||||
|
||||
console.log('Test 2: non-interactive onboarding persists voice disabled config')
|
||||
const configRaw = await readFile(join(paseoHome, 'config.json'), 'utf-8')
|
||||
console.log("Test 2: non-interactive onboarding persists voice disabled config");
|
||||
const configRaw = await readFile(join(paseoHome, "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw) as {
|
||||
features?: {
|
||||
dictation?: { enabled?: boolean }
|
||||
voiceMode?: { enabled?: boolean }
|
||||
}
|
||||
}
|
||||
dictation?: { enabled?: boolean };
|
||||
voiceMode?: { enabled?: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
|
||||
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
|
||||
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
|
||||
assert.strictEqual(
|
||||
config.features?.dictation?.enabled,
|
||||
false,
|
||||
"dictation.enabled should be false",
|
||||
);
|
||||
assert.strictEqual(
|
||||
config.features?.voiceMode?.enabled,
|
||||
false,
|
||||
"voiceMode.enabled should be false",
|
||||
);
|
||||
const daemonLog = await readFile(join(paseoHome, "daemon.log"), "utf-8");
|
||||
assert(
|
||||
!daemonLog.includes('Ensuring local speech models'),
|
||||
'daemon should not attempt local speech model setup when voice is disabled'
|
||||
)
|
||||
console.log('✓ non-interactive run persisted voice disabled choices\n')
|
||||
!daemonLog.includes("Ensuring local speech models"),
|
||||
"daemon should not attempt local speech model setup when voice is disabled",
|
||||
);
|
||||
console.log("✓ non-interactive run persisted voice disabled choices\n");
|
||||
} finally {
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== Onboarding tests passed ===')
|
||||
console.log("=== Onboarding tests passed ===");
|
||||
|
||||
@@ -6,40 +6,40 @@
|
||||
* Tests pure helpers that do not require a running daemon.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { resolveTcpHostFromListen } from '../src/commands/daemon/local-daemon.js'
|
||||
import assert from "node:assert";
|
||||
import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js";
|
||||
|
||||
console.log('=== Local Daemon Utility Helpers ===\n')
|
||||
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 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 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 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 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('=== All local daemon utility tests passed ===')
|
||||
console.log("=== All local daemon utility tests passed ===");
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { getErrorMessage } from '../src/utils/errors.js'
|
||||
import assert from "node:assert";
|
||||
import { getErrorMessage } from "../src/utils/errors.js";
|
||||
|
||||
console.log('=== Error Utils ===\n')
|
||||
console.log("=== Error Utils ===\n");
|
||||
|
||||
{
|
||||
console.log('Test 1: returns Error.message for Error instances')
|
||||
assert.strictEqual(getErrorMessage(new Error('boom')), 'boom')
|
||||
console.log('✓ returns Error.message\n')
|
||||
console.log("Test 1: returns Error.message for Error instances");
|
||||
assert.strictEqual(getErrorMessage(new Error("boom")), "boom");
|
||||
console.log("✓ returns Error.message\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 2: stringifies non-Error values')
|
||||
assert.strictEqual(getErrorMessage('plain string'), 'plain string')
|
||||
assert.strictEqual(getErrorMessage(42), '42')
|
||||
assert.strictEqual(getErrorMessage(null), 'null')
|
||||
console.log('✓ stringifies non-Error values\n')
|
||||
console.log("Test 2: stringifies non-Error values");
|
||||
assert.strictEqual(getErrorMessage("plain string"), "plain string");
|
||||
assert.strictEqual(getErrorMessage(42), "42");
|
||||
assert.strictEqual(getErrorMessage(null), "null");
|
||||
console.log("✓ stringifies non-Error values\n");
|
||||
}
|
||||
|
||||
console.log('=== All error utility tests passed ===')
|
||||
console.log("=== All error utility tests passed ===");
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
#!/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'
|
||||
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')
|
||||
console.log("=== Worktree LS Path Helper Tests ===\n");
|
||||
|
||||
const originalPaseoHome = process.env.PASEO_HOME
|
||||
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'
|
||||
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')
|
||||
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
|
||||
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')
|
||||
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
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== All worktree ls path helper tests passed ===')
|
||||
console.log("=== All worktree ls path helper tests passed ===");
|
||||
|
||||
@@ -1,126 +1,126 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
resolveProviderAndModel,
|
||||
resolveStructuredResponseMessage,
|
||||
type StructuredResponseTimelineClient,
|
||||
} from '../src/commands/agent/run.ts'
|
||||
} from "../src/commands/agent/run.ts";
|
||||
|
||||
type TimelineEntry = {
|
||||
item: {
|
||||
type: string
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
type: string;
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function createClient(options: {
|
||||
entries?: TimelineEntry[]
|
||||
throwOnFetch?: boolean
|
||||
onFetch?: () => void
|
||||
entries?: TimelineEntry[];
|
||||
throwOnFetch?: boolean;
|
||||
onFetch?: () => void;
|
||||
}): StructuredResponseTimelineClient {
|
||||
return {
|
||||
fetchAgentTimeline: async () => {
|
||||
options.onFetch?.()
|
||||
options.onFetch?.();
|
||||
if (options.throwOnFetch) {
|
||||
throw new Error('timeline unavailable')
|
||||
throw new Error("timeline unavailable");
|
||||
}
|
||||
return {
|
||||
entries: options.entries ?? [],
|
||||
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
|
||||
} as Awaited<ReturnType<StructuredResponseTimelineClient["fetchAgentTimeline"]>>;
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
console.log('=== Run Output Schema Helper Tests ===\n')
|
||||
console.log("=== Run Output Schema Helper Tests ===\n");
|
||||
|
||||
// Test 1: Direct lastMessage is returned without timeline fetch.
|
||||
{
|
||||
let fetchCount = 0
|
||||
const client = createClient({ onFetch: () => fetchCount += 1 })
|
||||
let fetchCount = 0;
|
||||
const client = createClient({ onFetch: () => (fetchCount += 1) });
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-1',
|
||||
agentId: "agent-1",
|
||||
lastMessage: ' {"summary":"ok"} ',
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"ok"}')
|
||||
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
|
||||
console.log('✓ returns direct lastMessage when present')
|
||||
});
|
||||
assert.strictEqual(result, '{"summary":"ok"}');
|
||||
assert.strictEqual(fetchCount, 0, "should not fetch timeline when lastMessage exists");
|
||||
console.log("✓ returns direct lastMessage when present");
|
||||
}
|
||||
|
||||
// Test 2: Falls back to latest assistant message from timeline.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'assistant_message', text: '' } },
|
||||
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
|
||||
{ item: { type: "user_message", text: "prompt" } },
|
||||
{ item: { type: "assistant_message", text: "" } },
|
||||
{ item: { type: "assistant_message", text: ' {"summary":"from timeline"} ' } },
|
||||
],
|
||||
})
|
||||
});
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-2',
|
||||
agentId: "agent-2",
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"from timeline"}')
|
||||
console.log('✓ falls back to latest assistant timeline entry')
|
||||
});
|
||||
assert.strictEqual(result, '{"summary":"from timeline"}');
|
||||
console.log("✓ falls back to latest assistant timeline entry");
|
||||
}
|
||||
|
||||
// Test 3: Returns null when timeline has no assistant messages.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'reasoning', text: 'thinking' } },
|
||||
{ item: { type: "user_message", text: "prompt" } },
|
||||
{ item: { type: "reasoning", text: "thinking" } },
|
||||
],
|
||||
})
|
||||
});
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-3',
|
||||
agentId: "agent-3",
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when no assistant messages exist')
|
||||
});
|
||||
assert.strictEqual(result, null);
|
||||
console.log("✓ returns null when no assistant messages exist");
|
||||
}
|
||||
|
||||
// Test 4: Returns null if timeline fetch fails.
|
||||
{
|
||||
const client = createClient({ throwOnFetch: true })
|
||||
const client = createClient({ throwOnFetch: true });
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-4',
|
||||
agentId: "agent-4",
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when timeline fetch throws')
|
||||
});
|
||||
assert.strictEqual(result, null);
|
||||
console.log("✓ returns null when timeline fetch throws");
|
||||
}
|
||||
|
||||
// Test 5: Provider/model slash syntax resolves both values.
|
||||
{
|
||||
const result = resolveProviderAndModel({ provider: 'codex/gpt-5.4' })
|
||||
const result = resolveProviderAndModel({ provider: "codex/gpt-5.4" });
|
||||
assert.deepStrictEqual(result, {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
})
|
||||
console.log('✓ resolves provider/model slash syntax')
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
console.log("✓ resolves provider/model slash syntax");
|
||||
}
|
||||
|
||||
// Test 6: Explicit matching --model coexists with slash syntax.
|
||||
{
|
||||
const result = resolveProviderAndModel({ provider: 'codex/gpt-5.4', model: 'gpt-5.4' })
|
||||
const result = resolveProviderAndModel({ provider: "codex/gpt-5.4", model: "gpt-5.4" });
|
||||
assert.deepStrictEqual(result, {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
})
|
||||
console.log('✓ accepts matching explicit model with slash syntax')
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
console.log("✓ accepts matching explicit model with slash syntax");
|
||||
}
|
||||
|
||||
// Test 7: Conflicting --model is rejected.
|
||||
{
|
||||
assert.throws(() => resolveProviderAndModel({ provider: 'codex/gpt-5.4', model: 'gpt-5.5' }), {
|
||||
message: 'Conflicting model values provided',
|
||||
})
|
||||
console.log('✓ rejects conflicting explicit model with slash syntax')
|
||||
assert.throws(() => resolveProviderAndModel({ provider: "codex/gpt-5.4", model: "gpt-5.5" }), {
|
||||
message: "Conflicting model values provided",
|
||||
});
|
||||
console.log("✓ rejects conflicting explicit model with slash syntax");
|
||||
}
|
||||
|
||||
console.log('\n=== All helper tests passed ===')
|
||||
console.log("\n=== All helper tests passed ===");
|
||||
|
||||
@@ -5,223 +5,235 @@
|
||||
* without allowing daemon-runner to respawn a new worker process.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { $ } from 'zx'
|
||||
import { getAvailablePort } from './helpers/network.ts'
|
||||
import assert from "node:assert";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
const pollIntervalMs = 100
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type PidLockState = {
|
||||
pid: number | null
|
||||
}
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
async function readPidLockState(paseoHome: string): Promise<PidLockState> {
|
||||
const pidPath = join(paseoHome, 'paseo.pid')
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
|
||||
try {
|
||||
const content = await readFile(pidPath, 'utf-8')
|
||||
const parsed = JSON.parse(content) as { pid?: unknown }
|
||||
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null
|
||||
return { pid }
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { pid };
|
||||
} catch {
|
||||
return { pid: null }
|
||||
return { pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
function readProcessCommand(pid: number): string | null {
|
||||
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' })
|
||||
const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
|
||||
if (result.status !== 0 || result.error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const command = result.stdout.trim()
|
||||
return command.length > 0 ? command : null
|
||||
const command = result.stdout.trim();
|
||||
return command.length > 0 ? command : null;
|
||||
}
|
||||
|
||||
type DaemonStatus = {
|
||||
status: string | null
|
||||
pid: number | null
|
||||
}
|
||||
status: string | null;
|
||||
pid: 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()
|
||||
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 { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
|
||||
const status = typeof parsed.status === 'string' ? parsed.status : null
|
||||
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null
|
||||
return { status, pid }
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
|
||||
const status = typeof parsed.status === "string" ? parsed.status : null;
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { status, pid };
|
||||
} catch {
|
||||
return { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await sleep(pollIntervalMs)
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
console.log('=== Daemon Stop (supervisor regression) ===\n')
|
||||
console.log("=== Daemon Stop (supervisor regression) ===\n");
|
||||
|
||||
const port = await getAvailablePort()
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-supervisor-'))
|
||||
const cliRoot = join(import.meta.dirname, '..')
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-supervisor-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
|
||||
let supervisorProcess: ChildProcess | null = null
|
||||
let recentSupervisorLogs = ''
|
||||
let supervisorProcess: ChildProcess | null = null;
|
||||
let recentSupervisorLogs = "";
|
||||
|
||||
try {
|
||||
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
|
||||
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
|
||||
|
||||
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
|
||||
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
CI: 'true',
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
supervisorProcess.stdout?.on('data', (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
|
||||
})
|
||||
supervisorProcess.stderr?.on('data', (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
|
||||
})
|
||||
supervisorProcess.stdout?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
supervisorProcess.stderr?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
|
||||
},
|
||||
120000,
|
||||
'daemon did not become running in time'
|
||||
)
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
const statusBeforeStop = await readDaemonStatus(paseoHome)
|
||||
const daemonPid = statusBeforeStop.pid
|
||||
assert.strictEqual(statusBeforeStop.status, 'running', 'daemon should be running before stop')
|
||||
assert(daemonPid !== null, 'daemon pid should exist once daemon starts')
|
||||
assert(isProcessRunning(daemonPid), 'daemon process should be running')
|
||||
const pidLockBeforeStop = await readPidLockState(paseoHome)
|
||||
assert.strictEqual(pidLockBeforeStop.pid, daemonPid, 'pid lock should match status pid')
|
||||
const command = readProcessCommand(daemonPid)
|
||||
assert(command !== null, 'pid lock pid should resolve to a running process command')
|
||||
const statusBeforeStop = await readDaemonStatus(paseoHome);
|
||||
const daemonPid = statusBeforeStop.pid;
|
||||
assert.strictEqual(statusBeforeStop.status, "running", "daemon should be running before stop");
|
||||
assert(daemonPid !== null, "daemon pid should exist once daemon starts");
|
||||
assert(isProcessRunning(daemonPid), "daemon process should be running");
|
||||
const pidLockBeforeStop = await readPidLockState(paseoHome);
|
||||
assert.strictEqual(pidLockBeforeStop.pid, daemonPid, "pid lock should match status pid");
|
||||
const command = readProcessCommand(daemonPid);
|
||||
assert(command !== null, "pid lock pid should resolve to a running process command");
|
||||
assert(
|
||||
command.includes('daemon-runner.ts') || command.includes('daemon-runner.js'),
|
||||
`pid lock pid should be daemon-runner process, got: ${command}`
|
||||
)
|
||||
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`)
|
||||
command.includes("daemon-runner.ts") || command.includes("daemon-runner.js"),
|
||||
`pid lock pid should be daemon-runner process, got: ${command}`,
|
||||
);
|
||||
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`);
|
||||
|
||||
console.log('Test 2: `paseo daemon stop` should stop without respawn')
|
||||
console.log("Test 2: `paseo daemon stop` should stop without respawn");
|
||||
const stopResult =
|
||||
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 stop --home ${paseoHome} --json`.nothrow()
|
||||
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
|
||||
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown }
|
||||
assert.strictEqual(stopJson.action, 'stopped', 'stop should report stopped action')
|
||||
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 stop --home ${paseoHome} --json`.nothrow();
|
||||
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
|
||||
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown };
|
||||
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
|
||||
|
||||
await waitFor(async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'stopped'
|
||||
}, 15000, 'daemon status did not transition to stopped after stop')
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "stopped";
|
||||
},
|
||||
15000,
|
||||
"daemon status did not transition to stopped after stop",
|
||||
);
|
||||
|
||||
if (supervisorProcess.pid) {
|
||||
await waitFor(
|
||||
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
|
||||
15000,
|
||||
'daemon-runner supervisor remained running after stop'
|
||||
)
|
||||
"daemon-runner supervisor remained running after stop",
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(1000)
|
||||
await sleep(1000);
|
||||
|
||||
const pidAfterStop = await readPidLockState(paseoHome)
|
||||
const respawned = pidAfterStop.pid !== null && isProcessRunning(pidAfterStop.pid)
|
||||
const pidAfterStop = await readPidLockState(paseoHome);
|
||||
const respawned = pidAfterStop.pid !== null && isProcessRunning(pidAfterStop.pid);
|
||||
assert.strictEqual(
|
||||
respawned,
|
||||
false,
|
||||
`daemon respawned after stop (pid: ${pidAfterStop.pid ?? 'unknown'})`
|
||||
)
|
||||
`daemon respawned after stop (pid: ${pidAfterStop.pid ?? "unknown"})`,
|
||||
);
|
||||
|
||||
const statusAfterStop = await readDaemonStatus(paseoHome)
|
||||
assert.strictEqual(statusAfterStop.status, 'stopped', 'daemon should remain stopped after stop command')
|
||||
const statusAfterStop = await readDaemonStatus(paseoHome);
|
||||
assert.strictEqual(
|
||||
statusAfterStop.status,
|
||||
"stopped",
|
||||
"daemon should remain stopped after stop command",
|
||||
);
|
||||
assert(
|
||||
recentSupervisorLogs.includes('Shutdown requested by worker. Stopping worker...'),
|
||||
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`
|
||||
)
|
||||
recentSupervisorLogs.includes("Shutdown requested by worker. Stopping worker..."),
|
||||
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
!recentSupervisorLogs.includes('cli_shutdown'),
|
||||
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`
|
||||
)
|
||||
console.log('✓ stop leaves supervised daemon stopped (no respawn)\n')
|
||||
!recentSupervisorLogs.includes("cli_shutdown"),
|
||||
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`,
|
||||
);
|
||||
console.log("✓ stop leaves supervised daemon stopped (no respawn)\n");
|
||||
} finally {
|
||||
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
|
||||
supervisorProcess.kill('SIGTERM')
|
||||
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
|
||||
() => {
|
||||
supervisorProcess?.kill('SIGKILL')
|
||||
}
|
||||
)
|
||||
supervisorProcess.kill("SIGTERM");
|
||||
await waitFor(
|
||||
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
|
||||
5000,
|
||||
"supervisor cleanup timed out",
|
||||
).catch(() => {
|
||||
supervisorProcess?.kill("SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
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 stop --home ${paseoHome} --force`.nothrow()
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
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 stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (recentSupervisorLogs.trim().length === 0) {
|
||||
// Keep output stable while still surfacing that logs were captured when needed.
|
||||
console.log('(no supervisor logs captured)')
|
||||
console.log("(no supervisor logs captured)");
|
||||
}
|
||||
|
||||
console.log('=== Supervisor stop regression test passed ===')
|
||||
console.log("=== Supervisor stop regression test passed ===");
|
||||
|
||||
@@ -5,213 +5,224 @@
|
||||
* graceful daemon lifecycle shutdown to complete (no early forced exit path).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { $ } from 'zx'
|
||||
import { getAvailablePort } from './helpers/network.ts'
|
||||
import assert from "node:assert";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
const pollIntervalMs = 100
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type DaemonStatus = {
|
||||
status: string | null
|
||||
pid: number | null
|
||||
}
|
||||
status: string | null;
|
||||
pid: 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()
|
||||
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 { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
|
||||
const status = typeof parsed.status === 'string' ? parsed.status : null
|
||||
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null
|
||||
return { status, pid }
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
|
||||
const status = typeof parsed.status === "string" ? parsed.status : null;
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { status, pid };
|
||||
} catch {
|
||||
return { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await sleep(pollIntervalMs)
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
type ExitResult = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
}
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('timed out waiting for process exit'))
|
||||
}, timeoutMs)
|
||||
reject(new Error("timed out waiting for process exit"));
|
||||
}, timeoutMs);
|
||||
|
||||
processRef.once('exit', (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ code, signal })
|
||||
})
|
||||
})
|
||||
processRef.once("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('=== Daemon SIGINT (supervisor regression) ===\n')
|
||||
console.log("=== Daemon SIGINT (supervisor regression) ===\n");
|
||||
|
||||
const port = await getAvailablePort()
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-sigint-supervisor-'))
|
||||
const cliRoot = join(import.meta.dirname, '..')
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-sigint-supervisor-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
|
||||
let supervisorProcess: ChildProcess | null = null
|
||||
let recentSupervisorLogs = ''
|
||||
let supervisorProcess: ChildProcess | null = null;
|
||||
let recentSupervisorLogs = "";
|
||||
|
||||
try {
|
||||
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
|
||||
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
|
||||
|
||||
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
|
||||
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
CI: 'true',
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
})
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
supervisorProcess.stdout?.on('data', (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
|
||||
})
|
||||
supervisorProcess.stderr?.on('data', (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
|
||||
})
|
||||
supervisorProcess.stdout?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
supervisorProcess.stderr?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
|
||||
},
|
||||
120000,
|
||||
'daemon did not become running in time'
|
||||
)
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
console.log('✓ supervised daemon started\n')
|
||||
console.log("✓ supervised daemon started\n");
|
||||
|
||||
console.log('Test 2: single SIGINT should shutdown gracefully without forced exit')
|
||||
const exitPromise = waitForProcessExit(supervisorProcess, 30000)
|
||||
const signaledGroup = signalProcessGroup(supervisorProcess.pid ?? -1, 'SIGINT')
|
||||
console.log("Test 2: single SIGINT should shutdown gracefully without forced exit");
|
||||
const exitPromise = waitForProcessExit(supervisorProcess, 30000);
|
||||
const signaledGroup = signalProcessGroup(supervisorProcess.pid ?? -1, "SIGINT");
|
||||
if (!signaledGroup) {
|
||||
supervisorProcess.kill('SIGINT')
|
||||
supervisorProcess.kill("SIGINT");
|
||||
}
|
||||
|
||||
const exit = await exitPromise
|
||||
assert.strictEqual(exit.signal, null, `supervisor should exit cleanly, got signal=${exit.signal}`)
|
||||
assert.strictEqual(exit.code, 0, `supervisor should exit with status 0, got code=${exit.code}`)
|
||||
const exit = await exitPromise;
|
||||
assert.strictEqual(
|
||||
exit.signal,
|
||||
null,
|
||||
`supervisor should exit cleanly, got signal=${exit.signal}`,
|
||||
);
|
||||
assert.strictEqual(exit.code, 0, `supervisor should exit with status 0, got code=${exit.code}`);
|
||||
|
||||
await waitFor(async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'stopped'
|
||||
}, 15000, 'daemon status did not transition to stopped after SIGINT')
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "stopped";
|
||||
},
|
||||
15000,
|
||||
"daemon status did not transition to stopped after SIGINT",
|
||||
);
|
||||
|
||||
assert(
|
||||
!recentSupervisorLogs.includes('Forcing exit...'),
|
||||
`worker entered forced-exit path during single SIGINT:\n${recentSupervisorLogs}`
|
||||
)
|
||||
!recentSupervisorLogs.includes("Forcing exit..."),
|
||||
`worker entered forced-exit path during single SIGINT:\n${recentSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
!recentSupervisorLogs.includes("Forcing shutdown - HTTP server didn't close in time"),
|
||||
`worker hit shutdown timeout during single SIGINT:\n${recentSupervisorLogs}`
|
||||
)
|
||||
`worker hit shutdown timeout during single SIGINT:\n${recentSupervisorLogs}`,
|
||||
);
|
||||
|
||||
console.log('✓ single SIGINT completed graceful shutdown without forced exit\n')
|
||||
console.log("✓ single SIGINT completed graceful shutdown without forced exit\n");
|
||||
} finally {
|
||||
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
|
||||
const signaledGroup = signalProcessGroup(supervisorProcess.pid, 'SIGTERM')
|
||||
const signaledGroup = signalProcessGroup(supervisorProcess.pid, "SIGTERM");
|
||||
if (!signaledGroup) {
|
||||
supervisorProcess.kill('SIGTERM')
|
||||
supervisorProcess.kill("SIGTERM");
|
||||
}
|
||||
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
|
||||
() => {
|
||||
const killedGroup = signalProcessGroup(supervisorProcess!.pid ?? -1, 'SIGKILL')
|
||||
if (!killedGroup) {
|
||||
supervisorProcess?.kill('SIGKILL')
|
||||
}
|
||||
await waitFor(
|
||||
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
|
||||
5000,
|
||||
"supervisor cleanup timed out",
|
||||
).catch(() => {
|
||||
const killedGroup = signalProcessGroup(supervisorProcess!.pid ?? -1, "SIGKILL");
|
||||
if (!killedGroup) {
|
||||
supervisorProcess?.kill("SIGKILL");
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
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 stop --home ${paseoHome} --force`.nothrow()
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
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 stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (recentSupervisorLogs.trim().length === 0) {
|
||||
console.log('(no supervisor logs captured)')
|
||||
console.log("(no supervisor logs captured)");
|
||||
}
|
||||
|
||||
console.log('=== Supervisor SIGINT regression test passed ===')
|
||||
console.log("=== Supervisor SIGINT regression test passed ===");
|
||||
|
||||
@@ -5,111 +5,115 @@
|
||||
* must not discover/kill processes via home-scoped `ps` command heuristics.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { $ } from 'zx'
|
||||
import assert from "node:assert";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunning(pid: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (isProcessRunning(pid)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await sleep(50)
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(`Process ${pid} did not become running in time`)
|
||||
throw new Error(`Process ${pid} did not become running in time`);
|
||||
}
|
||||
|
||||
console.log('=== Daemon Stop Ownership Regression ===\n')
|
||||
console.log("=== Daemon Stop Ownership Regression ===\n");
|
||||
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-ownership-'))
|
||||
let decoyProcess: ChildProcess | null = null
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-ownership-"));
|
||||
let decoyProcess: ChildProcess | null = null;
|
||||
|
||||
try {
|
||||
console.log('Test 1: start decoy process with daemon-like command markers')
|
||||
console.log("Test 1: start decoy process with daemon-like command markers");
|
||||
|
||||
decoyProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
"-e",
|
||||
// Keep the process alive long enough for stop command assertions.
|
||||
'setInterval(() => {}, 1000)',
|
||||
'daemon-runner.ts',
|
||||
"setInterval(() => {}, 1000)",
|
||||
"daemon-runner.ts",
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
},
|
||||
stdio: 'ignore',
|
||||
detached: process.platform !== 'win32',
|
||||
}
|
||||
)
|
||||
decoyProcess.unref()
|
||||
stdio: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
},
|
||||
);
|
||||
decoyProcess.unref();
|
||||
|
||||
const decoyPid = decoyProcess.pid
|
||||
assert(Number.isInteger(decoyPid) && (decoyPid ?? 0) > 0, 'decoy pid should exist')
|
||||
await waitForRunning(decoyPid!, 5000)
|
||||
console.log(`✓ decoy process started (${decoyPid})\n`)
|
||||
const decoyPid = decoyProcess.pid;
|
||||
assert(Number.isInteger(decoyPid) && (decoyPid ?? 0) > 0, "decoy pid should exist");
|
||||
await waitForRunning(decoyPid!, 5000);
|
||||
console.log(`✓ decoy process started (${decoyPid})\n`);
|
||||
|
||||
console.log('Test 2: daemon stop should report not_running and leave decoy untouched')
|
||||
console.log("Test 2: daemon stop should report not_running and leave decoy untouched");
|
||||
|
||||
const stopResult =
|
||||
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 stop --home ${paseoHome} --json`.nothrow()
|
||||
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
|
||||
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 stop --home ${paseoHome} --json`.nothrow();
|
||||
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
|
||||
|
||||
const parsed = JSON.parse(stopResult.stdout) as { action?: unknown }
|
||||
assert.strictEqual(parsed.action, 'not_running', `stop should not target decoy process: ${stopResult.stdout}`)
|
||||
assert(isProcessRunning(decoyPid!), 'decoy process must remain alive after stop')
|
||||
const parsed = JSON.parse(stopResult.stdout) as { action?: unknown };
|
||||
assert.strictEqual(
|
||||
parsed.action,
|
||||
"not_running",
|
||||
`stop should not target decoy process: ${stopResult.stdout}`,
|
||||
);
|
||||
assert(isProcessRunning(decoyPid!), "decoy process must remain alive after stop");
|
||||
|
||||
console.log('✓ stop is ownership-driven and does not kill decoy process\n')
|
||||
console.log("✓ stop is ownership-driven and does not kill decoy process\n");
|
||||
} finally {
|
||||
if (decoyProcess?.pid && isProcessRunning(decoyProcess.pid)) {
|
||||
try {
|
||||
process.kill(decoyProcess.pid, 'SIGTERM')
|
||||
process.kill(decoyProcess.pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await sleep(100)
|
||||
await sleep(100);
|
||||
if (isProcessRunning(decoyProcess.pid)) {
|
||||
try {
|
||||
process.kill(decoyProcess.pid, 'SIGKILL')
|
||||
process.kill(decoyProcess.pid, "SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== Daemon stop ownership regression test passed ===')
|
||||
console.log("=== Daemon stop ownership regression test passed ===");
|
||||
|
||||
@@ -5,210 +5,240 @@
|
||||
* (worker PID changes) while keeping the daemon healthy.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { $ } from 'zx'
|
||||
import { tryConnectToDaemon } from '../src/utils/client.ts'
|
||||
import { getAvailablePort } from './helpers/network.ts'
|
||||
import assert from "node:assert";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { tryConnectToDaemon } from "../src/utils/client.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
const pollIntervalMs = 100
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkerPid(supervisorPid: number): number | null {
|
||||
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = spawnSync('ps', ['ax', '-o', 'pid=,ppid='], { encoding: 'utf8' })
|
||||
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (result.status !== 0 || result.error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const [pidToken, ppidToken] = trimmed.split(/\s+/)
|
||||
const pid = Number.parseInt(pidToken ?? '', 10)
|
||||
const ppid = Number.parseInt(ppidToken ?? '', 10)
|
||||
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
|
||||
continue;
|
||||
}
|
||||
if (ppid === supervisorPid && pid > 0) {
|
||||
return pid
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
type DaemonStatus = {
|
||||
status: string | null
|
||||
pid: number | null
|
||||
}
|
||||
status: string | null;
|
||||
pid: 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()
|
||||
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 { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
|
||||
const status = typeof parsed.status === 'string' ? parsed.status : null
|
||||
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null
|
||||
return { status, pid }
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
|
||||
const status = typeof parsed.status === "string" ? parsed.status : null;
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { status, pid };
|
||||
} catch {
|
||||
return { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await sleep(pollIntervalMs)
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
console.log('=== Daemon Restart (supervisor regression) ===\n')
|
||||
console.log("=== Daemon Restart (supervisor regression) ===\n");
|
||||
|
||||
const port = await getAvailablePort()
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-supervisor-'))
|
||||
const cliRoot = join(import.meta.dirname, '..')
|
||||
const host = `127.0.0.1:${port}`
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-supervisor-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
const host = `127.0.0.1:${port}`;
|
||||
|
||||
let supervisorProcess: ChildProcess | null = null
|
||||
let recentSupervisorLogs = ''
|
||||
let supervisorProcess: ChildProcess | null = null;
|
||||
let recentSupervisorLogs = "";
|
||||
|
||||
try {
|
||||
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
|
||||
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
|
||||
|
||||
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
|
||||
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: host,
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
CI: 'true',
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
supervisorProcess.stdout?.on('data', (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
|
||||
})
|
||||
supervisorProcess.stderr?.on('data', (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
|
||||
})
|
||||
supervisorProcess.stdout?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
supervisorProcess.stderr?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
|
||||
},
|
||||
120000,
|
||||
'daemon did not become running in time'
|
||||
)
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
const statusBeforeRestart = await readDaemonStatus(paseoHome)
|
||||
const supervisorPid = statusBeforeRestart.pid
|
||||
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
|
||||
assert(supervisorPid !== null, 'supervisor pid should exist once daemon starts')
|
||||
assert(isProcessRunning(supervisorPid), 'supervisor process should be running')
|
||||
const workerPidBeforeRestart = readWorkerPid(supervisorPid)
|
||||
assert(workerPidBeforeRestart !== null, 'supervisor should have a worker process before restart')
|
||||
assert(isProcessRunning(workerPidBeforeRestart), 'worker process should be running before restart')
|
||||
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`)
|
||||
const statusBeforeRestart = await readDaemonStatus(paseoHome);
|
||||
const supervisorPid = statusBeforeRestart.pid;
|
||||
assert.strictEqual(
|
||||
statusBeforeRestart.status,
|
||||
"running",
|
||||
"daemon should be running before restart",
|
||||
);
|
||||
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
|
||||
assert(isProcessRunning(supervisorPid), "supervisor process should be running");
|
||||
const workerPidBeforeRestart = readWorkerPid(supervisorPid);
|
||||
assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart");
|
||||
assert(
|
||||
isProcessRunning(workerPidBeforeRestart),
|
||||
"worker process should be running before restart",
|
||||
);
|
||||
console.log(
|
||||
`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`,
|
||||
);
|
||||
|
||||
console.log('Test 2: app-style restart request should restart worker and keep daemon healthy')
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 })
|
||||
assert(client, 'daemon client should connect')
|
||||
console.log("Test 2: app-style restart request should restart worker and keep daemon healthy");
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 });
|
||||
assert(client, "daemon client should connect");
|
||||
try {
|
||||
const restartAck = await client.restartServer('settings_update')
|
||||
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
|
||||
const restartAck = await client.restartServer("settings_update");
|
||||
assert.strictEqual(
|
||||
restartAck.status,
|
||||
"restart_requested",
|
||||
"restart request should be acknowledged",
|
||||
);
|
||||
} finally {
|
||||
await client?.close().catch(() => undefined)
|
||||
await client?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const workerPid = readWorkerPid(supervisorPid)
|
||||
return workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
|
||||
}, 20000, 'worker pid did not change after restart request')
|
||||
await waitFor(
|
||||
() => {
|
||||
const workerPid = readWorkerPid(supervisorPid);
|
||||
return (
|
||||
workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
|
||||
);
|
||||
},
|
||||
20000,
|
||||
"worker pid did not change after restart request",
|
||||
);
|
||||
|
||||
const workerPidAfterRestart = readWorkerPid(supervisorPid)
|
||||
assert(workerPidAfterRestart !== null, 'worker process should exist after restart')
|
||||
const workerPidAfterRestart = readWorkerPid(supervisorPid);
|
||||
assert(workerPidAfterRestart !== null, "worker process should exist after restart");
|
||||
assert.notStrictEqual(
|
||||
workerPidAfterRestart,
|
||||
workerPidBeforeRestart,
|
||||
'worker pid should change after restart'
|
||||
)
|
||||
"worker pid should change after restart",
|
||||
);
|
||||
|
||||
const statusAfterRestart = await readDaemonStatus(paseoHome)
|
||||
assert.strictEqual(statusAfterRestart.status, 'running', 'daemon should stay running after restart')
|
||||
assert.strictEqual(statusAfterRestart.pid, supervisorPid, 'supervisor pid should remain stable across restart')
|
||||
const statusAfterRestart = await readDaemonStatus(paseoHome);
|
||||
assert.strictEqual(
|
||||
statusAfterRestart.status,
|
||||
"running",
|
||||
"daemon should stay running after restart",
|
||||
);
|
||||
assert.strictEqual(
|
||||
statusAfterRestart.pid,
|
||||
supervisorPid,
|
||||
"supervisor pid should remain stable across restart",
|
||||
);
|
||||
assert(
|
||||
recentSupervisorLogs.includes('Restart requested by worker. Stopping worker for restart...'),
|
||||
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`
|
||||
)
|
||||
console.log('✓ app-style restart keeps daemon healthy and restarts worker\n')
|
||||
recentSupervisorLogs.includes("Restart requested by worker. Stopping worker for restart..."),
|
||||
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`,
|
||||
);
|
||||
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
|
||||
} finally {
|
||||
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
|
||||
supervisorProcess.kill('SIGTERM')
|
||||
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
|
||||
() => {
|
||||
supervisorProcess?.kill('SIGKILL')
|
||||
}
|
||||
)
|
||||
supervisorProcess.kill("SIGTERM");
|
||||
await waitFor(
|
||||
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
|
||||
5000,
|
||||
"supervisor cleanup timed out",
|
||||
).catch(() => {
|
||||
supervisorProcess?.kill("SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
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 stop --home ${paseoHome} --force`.nothrow()
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
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 stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (recentSupervisorLogs.trim().length === 0) {
|
||||
console.log('(no supervisor logs captured)')
|
||||
console.log("(no supervisor logs captured)");
|
||||
}
|
||||
|
||||
console.log('=== Supervisor restart regression test passed ===')
|
||||
console.log("=== Supervisor restart regression test passed ===");
|
||||
|
||||
@@ -5,198 +5,217 @@
|
||||
* so an external owner can decide whether to respawn.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
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'
|
||||
import { $ } from 'zx'
|
||||
import { tryConnectToDaemon } from '../src/utils/client.ts'
|
||||
import { getAvailablePort } from './helpers/network.ts'
|
||||
import assert from "node:assert";
|
||||
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";
|
||||
import { $ } from "zx";
|
||||
import { tryConnectToDaemon } from "../src/utils/client.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
const pollIntervalMs = 100
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type DaemonStatus = {
|
||||
status: string | null
|
||||
pid: number | null
|
||||
}
|
||||
status: string | null;
|
||||
pid: 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()
|
||||
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 { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
|
||||
const status = typeof parsed.status === 'string' ? parsed.status : null
|
||||
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null
|
||||
return { status, pid }
|
||||
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
|
||||
const status = typeof parsed.status === "string" ? parsed.status : null;
|
||||
const pid =
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null;
|
||||
return { status, pid };
|
||||
} catch {
|
||||
return { status: null, pid: null }
|
||||
return { status: null, pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await sleep(pollIntervalMs)
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
type ExitResult = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
}
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('timed out waiting for process exit'))
|
||||
}, timeoutMs)
|
||||
reject(new Error("timed out waiting for process exit"));
|
||||
}, timeoutMs);
|
||||
|
||||
processRef.once('exit', (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ code, signal })
|
||||
})
|
||||
})
|
||||
processRef.once("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readPidLockPid(paseoHome: string): Promise<number | null> {
|
||||
const pidPath = join(paseoHome, 'paseo.pid')
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
try {
|
||||
const content = await readFile(pidPath, 'utf-8')
|
||||
const parsed = JSON.parse(content) as { pid?: unknown }
|
||||
if (typeof parsed.pid !== 'number' || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
|
||||
return null
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed.pid
|
||||
return parsed.pid;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Daemon Restart (unsupervised regression) ===\n')
|
||||
console.log("=== Daemon Restart (unsupervised regression) ===\n");
|
||||
|
||||
const port = await getAvailablePort()
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-unsupervised-'))
|
||||
const cliRoot = join(import.meta.dirname, '..')
|
||||
const host = `127.0.0.1:${port}`
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
const host = `127.0.0.1:${port}`;
|
||||
|
||||
let daemonProcess: ChildProcess | null = null
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
|
||||
try {
|
||||
console.log('Test 1: start unsupervised daemon worker directly')
|
||||
console.log("Test 1: start unsupervised daemon worker directly");
|
||||
|
||||
daemonProcess = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, '--import', 'tsx', '../server/src/server/index.ts'],
|
||||
[...process.execArgv, "--import", "tsx", "../server/src/server/index.ts"],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
// This test validates direct unsupervised worker ownership semantics.
|
||||
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
|
||||
// which would delegate lock ownership away from this process and make
|
||||
// daemon status checks fail to observe a running owner PID.
|
||||
PASEO_PID_LOCK_MODE: 'self',
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: host,
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
CI: 'true',
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
// This test validates direct unsupervised worker ownership semantics.
|
||||
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
|
||||
// which would delegate lock ownership away from this process and make
|
||||
// daemon status checks fail to observe a running owner PID.
|
||||
PASEO_PID_LOCK_MODE: "self",
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: host,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
|
||||
},
|
||||
120000,
|
||||
'daemon did not become running in time'
|
||||
)
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
const statusBeforeRestart = await readDaemonStatus(paseoHome)
|
||||
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
|
||||
assert(daemonProcess.pid, 'unsupervised daemon process pid should exist')
|
||||
assert.strictEqual(statusBeforeRestart.pid, daemonProcess.pid, 'status pid should match daemon process pid')
|
||||
const lockPid = await readPidLockPid(paseoHome)
|
||||
assert.strictEqual(lockPid, daemonProcess.pid, 'unsupervised worker should own pid lock')
|
||||
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`)
|
||||
const statusBeforeRestart = await readDaemonStatus(paseoHome);
|
||||
assert.strictEqual(
|
||||
statusBeforeRestart.status,
|
||||
"running",
|
||||
"daemon should be running before restart",
|
||||
);
|
||||
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
|
||||
assert.strictEqual(
|
||||
statusBeforeRestart.pid,
|
||||
daemonProcess.pid,
|
||||
"status pid should match daemon process pid",
|
||||
);
|
||||
const lockPid = await readPidLockPid(paseoHome);
|
||||
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
|
||||
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
|
||||
|
||||
console.log('Test 2: restart request should gracefully stop and exit code 0')
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 })
|
||||
assert(client, 'daemon client should connect')
|
||||
console.log("Test 2: restart request should gracefully stop and exit code 0");
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 });
|
||||
assert(client, "daemon client should connect");
|
||||
|
||||
const exitPromise = waitForProcessExit(daemonProcess, 30000)
|
||||
const exitPromise = waitForProcessExit(daemonProcess, 30000);
|
||||
try {
|
||||
const restartAck = await client.restartServer('settings_update')
|
||||
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
|
||||
const restartAck = await client.restartServer("settings_update");
|
||||
assert.strictEqual(
|
||||
restartAck.status,
|
||||
"restart_requested",
|
||||
"restart request should be acknowledged",
|
||||
);
|
||||
} finally {
|
||||
await client?.close().catch(() => undefined)
|
||||
await client?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const exit = await exitPromise
|
||||
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`)
|
||||
assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`)
|
||||
const exit = await exitPromise;
|
||||
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
|
||||
assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`);
|
||||
|
||||
await waitFor(async () => {
|
||||
const status = await readDaemonStatus(paseoHome)
|
||||
return status.status === 'stopped'
|
||||
}, 15000, 'daemon status did not transition to stopped after unsupervised restart request')
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.status === "stopped";
|
||||
},
|
||||
15000,
|
||||
"daemon status did not transition to stopped after unsupervised restart request",
|
||||
);
|
||||
|
||||
console.log('✓ unsupervised restart exited cleanly with code 0\n')
|
||||
console.log("✓ unsupervised restart exited cleanly with code 0\n");
|
||||
} finally {
|
||||
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
|
||||
daemonProcess.kill('SIGTERM')
|
||||
await waitFor(() => !isProcessRunning(daemonProcess!.pid ?? -1), 5000, 'daemon cleanup timed out').catch(
|
||||
() => {
|
||||
daemonProcess?.kill('SIGKILL')
|
||||
}
|
||||
)
|
||||
daemonProcess.kill("SIGTERM");
|
||||
await waitFor(
|
||||
() => !isProcessRunning(daemonProcess!.pid ?? -1),
|
||||
5000,
|
||||
"daemon cleanup timed out",
|
||||
).catch(() => {
|
||||
daemonProcess?.kill("SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== Unsupervised restart regression test passed ===')
|
||||
console.log("=== Unsupervised restart regression test passed ===");
|
||||
|
||||
@@ -10,115 +10,115 @@
|
||||
* - All flags are accepted
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { $ } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import assert from "node:assert";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
console.log('=== Delete Command Tests ===\n')
|
||||
console.log("=== Delete Command Tests ===\n");
|
||||
|
||||
const cliRoot = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = join(cliRoot, '..', '..', '..')
|
||||
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-delete-test-home-'))
|
||||
const cliRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(cliRoot, "..", "..", "..");
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-delete-test-home-"));
|
||||
|
||||
async function runCli(args: string[]) {
|
||||
return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow()
|
||||
return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow();
|
||||
}
|
||||
|
||||
async function runDelete(args: string[]) {
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow()
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow();
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
console.log('Test 1: delete --help shows options')
|
||||
const result = await runDelete(['--help'])
|
||||
assert.strictEqual(result.exitCode, 0, 'delete --help should exit 0')
|
||||
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
|
||||
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||
assert(result.stdout.includes('[id]'), 'help should mention optional id argument')
|
||||
console.log('✓ delete --help shows options\n')
|
||||
console.log("Test 1: delete --help shows options");
|
||||
const result = await runDelete(["--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "delete --help should exit 0");
|
||||
assert(result.stdout.includes("--all"), "help should mention --all flag");
|
||||
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("[id]"), "help should mention optional id argument");
|
||||
console.log("✓ delete --help shows options\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 2: delete requires ID, --all, or --cwd')
|
||||
const result = await runDelete([])
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd')
|
||||
const output = result.stdout + result.stderr
|
||||
console.log("Test 2: delete requires ID, --all, or --cwd");
|
||||
const result = await runDelete([]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('missing') ||
|
||||
output.toLowerCase().includes('required') ||
|
||||
output.toLowerCase().includes('argument') ||
|
||||
output.toLowerCase().includes('id')
|
||||
assert(hasError, 'error should mention missing argument')
|
||||
console.log('✓ delete requires ID, --all, or --cwd\n')
|
||||
output.toLowerCase().includes("missing") ||
|
||||
output.toLowerCase().includes("required") ||
|
||||
output.toLowerCase().includes("argument") ||
|
||||
output.toLowerCase().includes("id");
|
||||
assert(hasError, "error should mention missing argument");
|
||||
console.log("✓ delete requires ID, --all, or --cwd\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 3: delete handles daemon not running')
|
||||
const result = await runDelete(['abc123'])
|
||||
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||
const output = result.stdout + result.stderr
|
||||
console.log("Test 3: delete handles daemon not running");
|
||||
const result = await runDelete(["abc123"]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
|
||||
const output = result.stdout + result.stderr;
|
||||
const hasError =
|
||||
output.toLowerCase().includes('daemon') ||
|
||||
output.toLowerCase().includes('connect') ||
|
||||
output.toLowerCase().includes('cannot')
|
||||
assert(hasError, 'error message should mention connection issue')
|
||||
console.log('✓ delete handles daemon not running\n')
|
||||
output.toLowerCase().includes("daemon") ||
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
console.log("✓ delete handles daemon not running\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 4: delete --all flag is accepted')
|
||||
const result = await runDelete(['--all'])
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ delete --all flag is accepted\n')
|
||||
console.log("Test 4: delete --all flag is accepted");
|
||||
const result = await runDelete(["--all"]);
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --all flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ delete --all flag is accepted\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 5: delete --cwd flag is accepted')
|
||||
const result = await runDelete(['--cwd', '/tmp'])
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --cwd flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ delete --cwd flag is accepted\n')
|
||||
console.log("Test 5: delete --cwd flag is accepted");
|
||||
const result = await runDelete(["--cwd", "/tmp"]);
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --cwd flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ delete --cwd flag is accepted\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 6: delete with ID and --host flag is accepted')
|
||||
const result = await runDelete(['abc123', '--host', `localhost:${port}`])
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ delete with ID and --host flag is accepted\n')
|
||||
console.log("Test 6: delete with ID and --host flag is accepted");
|
||||
const result = await runDelete(["abc123", "--host", `localhost:${port}`]);
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept --host flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ delete with ID and --host flag is accepted\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 7: paseo --help shows delete command')
|
||||
const result = await runCli(['--help'])
|
||||
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||
assert(result.stdout.includes('delete'), 'help should mention delete command')
|
||||
console.log('✓ paseo --help shows delete command\n')
|
||||
console.log("Test 7: paseo --help shows delete command");
|
||||
const result = await runCli(["--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
|
||||
assert(result.stdout.includes("delete"), "help should mention delete command");
|
||||
console.log("✓ paseo --help shows delete command\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 8: -q (quiet) flag is accepted with delete')
|
||||
console.log("Test 8: -q (quiet) flag is accepted with delete");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
console.log('✓ -q (quiet) flag is accepted with delete\n')
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("unknown option"), "should accept -q flag");
|
||||
assert(!output.includes("error: option"), "should not have option parsing error");
|
||||
console.log("✓ -q (quiet) flag is accepted with delete\n");
|
||||
}
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('=== All delete tests passed ===')
|
||||
console.log("=== All delete tests passed ===");
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
#!/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 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,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
} from '../src/utils/client.js'
|
||||
} from "../src/utils/client.js";
|
||||
|
||||
console.log('=== CLI IPC Target Helpers ===\n')
|
||||
console.log("=== CLI IPC Target Helpers ===\n");
|
||||
|
||||
{
|
||||
console.log('Test 1: unix hosts resolve to ws+unix URLs')
|
||||
const target = resolveDaemonTarget('unix:///tmp/paseo.sock')
|
||||
console.log("Test 1: unix hosts resolve to ws+unix URLs");
|
||||
const target = resolveDaemonTarget("unix:///tmp/paseo.sock");
|
||||
assert.deepStrictEqual(target, {
|
||||
type: 'ipc',
|
||||
url: 'ws+unix:///tmp/paseo.sock:/ws',
|
||||
socketPath: '/tmp/paseo.sock',
|
||||
})
|
||||
console.log('✓ unix hosts resolve to ws+unix URLs\n')
|
||||
type: "ipc",
|
||||
url: "ws+unix:///tmp/paseo.sock:/ws",
|
||||
socketPath: "/tmp/paseo.sock",
|
||||
});
|
||||
console.log("✓ unix hosts resolve to ws+unix URLs\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 2: pipe hosts preserve the Node socketPath transport form')
|
||||
const target = resolveDaemonTarget('pipe://\\\\.\\pipe\\paseo-managed-test')
|
||||
console.log("Test 2: pipe hosts preserve the Node socketPath transport form");
|
||||
const target = resolveDaemonTarget("pipe://\\\\.\\pipe\\paseo-managed-test");
|
||||
assert.deepStrictEqual(target, {
|
||||
type: 'ipc',
|
||||
url: 'ws://localhost/ws',
|
||||
socketPath: '\\\\.\\pipe\\paseo-managed-test',
|
||||
})
|
||||
console.log('✓ pipe hosts preserve Node socketPath transport form\n')
|
||||
type: "ipc",
|
||||
url: "ws://localhost/ws",
|
||||
socketPath: "\\\\.\\pipe\\paseo-managed-test",
|
||||
});
|
||||
console.log("✓ pipe hosts preserve Node socketPath transport form\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 3: local unix socket paths normalize into IPC daemon targets')
|
||||
assert.strictEqual(normalizeDaemonHost('/tmp/paseo.sock'), 'unix:///tmp/paseo.sock')
|
||||
console.log('✓ local unix socket paths normalize into IPC daemon targets\n')
|
||||
console.log("Test 3: local unix socket paths normalize into IPC daemon targets");
|
||||
assert.strictEqual(normalizeDaemonHost("/tmp/paseo.sock"), "unix:///tmp/paseo.sock");
|
||||
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 4: default host resolution tries local IPC first, then localhost fallback')
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-'))
|
||||
console.log("Test 4: 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 })
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, 'paseo.pid'),
|
||||
JSON.stringify({ pid: process.pid, listen: '/tmp/paseo-from-pid.sock' })
|
||||
)
|
||||
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
|
||||
"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 })
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log('✓ default host resolution tries local IPC first, then localhost fallback\n')
|
||||
console.log("✓ default host resolution tries local IPC first, then localhost fallback\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 5: configured TCP host is preserved before the localhost fallback')
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-tcp-'))
|
||||
console.log("Test 5: 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',
|
||||
PASEO_LISTEN: "127.0.0.1:7777",
|
||||
}),
|
||||
['127.0.0.1:7777', 'localhost:6767']
|
||||
)
|
||||
["127.0.0.1:7777", "localhost:6767"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true })
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log('✓ configured TCP host is preserved before the localhost fallback\n')
|
||||
console.log("✓ configured TCP host is preserved before the localhost fallback\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 6: local IPC still takes priority over configured TCP hosts')
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-order-'))
|
||||
console.log("Test 6: 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 })
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, 'paseo.pid'),
|
||||
JSON.stringify({ pid: process.pid, listen: '/tmp/paseo-priority.sock' })
|
||||
)
|
||||
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',
|
||||
PASEO_LISTEN: "127.0.0.1:7777",
|
||||
}),
|
||||
['unix:///tmp/paseo-priority.sock', '127.0.0.1:7777', 'localhost:6767']
|
||||
)
|
||||
["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"],
|
||||
);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true })
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
console.log('✓ local IPC still takes priority over configured TCP hosts\n')
|
||||
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
|
||||
}
|
||||
|
||||
console.log('=== All CLI IPC target tests passed ===')
|
||||
console.log("=== All CLI IPC target tests passed ===");
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { resolveAgentId } from '../src/utils/client.js'
|
||||
import assert from "node:assert";
|
||||
import { resolveAgentId } from "../src/utils/client.js";
|
||||
|
||||
console.log('=== Agent ID Resolution Tests ===\n')
|
||||
console.log("=== Agent ID Resolution Tests ===\n");
|
||||
|
||||
const agents = [
|
||||
{ id: 'abc1234-full-agent-id', title: 'Build docs' },
|
||||
{ id: 'def5678-other-agent-id', title: 'Fix archive flow' },
|
||||
{ id: '987zyxw-third-agent-id', title: 'Review PR' },
|
||||
]
|
||||
{ id: "abc1234-full-agent-id", title: "Build docs" },
|
||||
{ id: "def5678-other-agent-id", title: "Fix archive flow" },
|
||||
{ id: "987zyxw-third-agent-id", title: "Review PR" },
|
||||
];
|
||||
|
||||
{
|
||||
console.log('Test 1: exact ID match resolves')
|
||||
assert.strictEqual(resolveAgentId('abc1234-full-agent-id', agents), 'abc1234-full-agent-id')
|
||||
console.log('✓ exact ID match resolves\n')
|
||||
console.log("Test 1: exact ID match resolves");
|
||||
assert.strictEqual(resolveAgentId("abc1234-full-agent-id", agents), "abc1234-full-agent-id");
|
||||
console.log("✓ exact ID match resolves\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 2: ID prefix resolves')
|
||||
assert.strictEqual(resolveAgentId('def5678', agents), 'def5678-other-agent-id')
|
||||
console.log('✓ ID prefix resolves\n')
|
||||
console.log("Test 2: ID prefix resolves");
|
||||
assert.strictEqual(resolveAgentId("def5678", agents), "def5678-other-agent-id");
|
||||
console.log("✓ ID prefix resolves\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 3: exact name match resolves case-insensitively')
|
||||
assert.strictEqual(resolveAgentId('fix archive flow', agents), 'def5678-other-agent-id')
|
||||
console.log('✓ exact name match resolves case-insensitively\n')
|
||||
console.log("Test 3: exact name match resolves case-insensitively");
|
||||
assert.strictEqual(resolveAgentId("fix archive flow", agents), "def5678-other-agent-id");
|
||||
console.log("✓ exact name match resolves case-insensitively\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 4: partial name match resolves when unique')
|
||||
assert.strictEqual(resolveAgentId('review', agents), '987zyxw-third-agent-id')
|
||||
console.log('✓ partial name match resolves when unique\n')
|
||||
console.log("Test 4: partial name match resolves when unique");
|
||||
assert.strictEqual(resolveAgentId("review", agents), "987zyxw-third-agent-id");
|
||||
console.log("✓ partial name match resolves when unique\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log('Test 5: missing agent returns null')
|
||||
assert.strictEqual(resolveAgentId('does not exist', agents), null)
|
||||
console.log('✓ missing agent returns null\n')
|
||||
console.log("Test 5: missing agent returns null");
|
||||
assert.strictEqual(resolveAgentId("does not exist", agents), null);
|
||||
console.log("✓ missing agent returns null\n");
|
||||
}
|
||||
|
||||
console.log('=== All agent ID resolution tests passed ===')
|
||||
console.log("=== All agent ID resolution tests passed ===");
|
||||
|
||||
@@ -22,238 +22,250 @@
|
||||
* - Clean up resources after test completes
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let ctx: E2EContext
|
||||
let ctx: E2EContext;
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
console.log('Setting up E2E test context...')
|
||||
console.log('Starting test daemon on random port (this may take a few seconds)...')
|
||||
console.log("Setting up E2E test context...");
|
||||
console.log("Starting test daemon on random port (this may take a few seconds)...");
|
||||
|
||||
try {
|
||||
ctx = await createE2ETestContext({ timeout: 45000 })
|
||||
console.log(`Test daemon started on port ${ctx.port}`)
|
||||
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
|
||||
console.log(`Work directory: ${ctx.workDir}`)
|
||||
ctx = await createE2ETestContext({ timeout: 45000 });
|
||||
console.log(`Test daemon started on port ${ctx.port}`);
|
||||
console.log(`PASEO_HOME: ${ctx.paseoHome}`);
|
||||
console.log(`Work directory: ${ctx.workDir}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start test daemon:', err)
|
||||
throw err
|
||||
console.error("Failed to start test daemon:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
console.log('\nCleaning up...')
|
||||
console.log("\nCleaning up...");
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
console.log('Test daemon stopped and temp directories removed')
|
||||
await ctx.stop();
|
||||
console.log("Test daemon stopped and temp directories removed");
|
||||
}
|
||||
}
|
||||
|
||||
async function test_agent_ls_empty(): Promise<void> {
|
||||
console.log('\n--- Test: agent ls with empty list ---')
|
||||
console.log("\n--- Test: agent ls with empty list ---");
|
||||
|
||||
const result = await ctx.paseo(['ls', '--json'])
|
||||
const result = await ctx.paseo(["ls", "--json"]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent ls should succeed");
|
||||
|
||||
const agents = JSON.parse(result.stdout.trim())
|
||||
assert(Array.isArray(agents), 'Output should be JSON array')
|
||||
assert.strictEqual(agents.length, 0, 'Should have no agents initially')
|
||||
const agents = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(agents), "Output should be JSON array");
|
||||
assert.strictEqual(agents.length, 0, "Should have no agents initially");
|
||||
|
||||
console.log('PASS: agent ls returns empty list')
|
||||
console.log("PASS: agent ls returns empty list");
|
||||
}
|
||||
|
||||
async function test_agent_run_detached(): Promise<string> {
|
||||
console.log('\n--- Test: agent run detached ---')
|
||||
console.log("\n--- Test: agent run detached ---");
|
||||
|
||||
// Use quiet mode to get just the agent ID
|
||||
// CRITICAL: Use haiku model for fast, cheap tests
|
||||
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'-q',
|
||||
'run',
|
||||
'-d',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--model',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'--mode',
|
||||
'bypassPermissions',
|
||||
'--name',
|
||||
'E2E Test Agent',
|
||||
'Say hello world',
|
||||
"-q",
|
||||
"run",
|
||||
"-d",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--model",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"--mode",
|
||||
"bypassPermissions",
|
||||
"--name",
|
||||
"E2E Test Agent",
|
||||
"Say hello world",
|
||||
],
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent run should succeed");
|
||||
|
||||
const agentId = result.stdout.trim()
|
||||
assert(agentId.length > 0, 'Should return agent ID')
|
||||
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
|
||||
const agentId = result.stdout.trim();
|
||||
assert(agentId.length > 0, "Should return agent ID");
|
||||
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`);
|
||||
|
||||
console.log(`PASS: agent created with ID: ${agentId}`)
|
||||
return agentId
|
||||
console.log(`PASS: agent created with ID: ${agentId}`);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
async function test_agent_ls_shows_agent(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: agent ls shows created agent ---')
|
||||
console.log("\n--- Test: agent ls shows created agent ---");
|
||||
|
||||
const result = await ctx.paseo(['ls', '--json'])
|
||||
const result = await ctx.paseo(["ls", "--json"]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent ls should succeed");
|
||||
|
||||
const agents = JSON.parse(result.stdout.trim()) as Array<{ id: string; title?: string; status?: string }>
|
||||
assert(Array.isArray(agents), 'Output should be JSON array')
|
||||
assert(agents.length >= 1, 'Should have at least one agent')
|
||||
const agents = JSON.parse(result.stdout.trim()) as Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
assert(Array.isArray(agents), "Output should be JSON array");
|
||||
assert(agents.length >= 1, "Should have at least one agent");
|
||||
|
||||
// Find our agent by ID prefix
|
||||
const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)))
|
||||
assert(ourAgent, `Our agent ${agentId} should be in the list`)
|
||||
const ourAgent = agents.find(
|
||||
(a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)),
|
||||
);
|
||||
assert(ourAgent, `Our agent ${agentId} should be in the list`);
|
||||
|
||||
console.log('Agent found:', ourAgent)
|
||||
console.log('PASS: agent ls shows created agent')
|
||||
console.log("Agent found:", ourAgent);
|
||||
console.log("PASS: agent ls shows created agent");
|
||||
}
|
||||
|
||||
async function test_agent_wait(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: agent wait ---')
|
||||
console.log("\n--- Test: agent wait ---");
|
||||
|
||||
// Wait for agent to become idle (with generous timeout for haiku model)
|
||||
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
|
||||
const result = await ctx.paseo(["wait", "--timeout", "120s", agentId], { timeout: 130000 });
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent wait should succeed");
|
||||
|
||||
console.log('PASS: agent wait completed successfully')
|
||||
console.log("PASS: agent wait completed successfully");
|
||||
}
|
||||
|
||||
async function test_agent_inspect(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: agent inspect ---')
|
||||
console.log("\n--- Test: agent inspect ---");
|
||||
|
||||
const result = await ctx.paseo(['inspect', agentId])
|
||||
const result = await ctx.paseo(["inspect", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent inspect should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent inspect should succeed");
|
||||
|
||||
// Check that output contains expected fields (table format)
|
||||
const output = result.stdout
|
||||
assert(output.includes('Id'), 'Output should contain Id field')
|
||||
assert(output.includes('Provider'), 'Output should contain Provider field')
|
||||
assert(output.includes('Status'), 'Output should contain Status field')
|
||||
assert(output.includes('claude'), 'Output should mention claude provider')
|
||||
const output = result.stdout;
|
||||
assert(output.includes("Id"), "Output should contain Id field");
|
||||
assert(output.includes("Provider"), "Output should contain Provider field");
|
||||
assert(output.includes("Status"), "Output should contain Status field");
|
||||
assert(output.includes("claude"), "Output should mention claude provider");
|
||||
|
||||
console.log('PASS: agent inspect shows expected fields')
|
||||
console.log("PASS: agent inspect shows expected fields");
|
||||
}
|
||||
|
||||
async function test_agent_logs(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: agent logs ---')
|
||||
console.log("\n--- Test: agent logs ---");
|
||||
|
||||
const result = await ctx.paseo(['logs', '--tail', '20', agentId])
|
||||
const result = await ctx.paseo(["logs", "--tail", "20", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout length:', result.stdout.length)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout length:", result.stdout.length);
|
||||
// Don't print full logs as they can be verbose
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent logs should succeed");
|
||||
// Logs should have some content (agent was asked to say hello)
|
||||
assert(result.stdout.length > 0, 'Logs should have some content')
|
||||
assert(result.stdout.length > 0, "Logs should have some content");
|
||||
|
||||
console.log('PASS: agent logs returns content')
|
||||
console.log("PASS: agent logs returns content");
|
||||
}
|
||||
|
||||
async function test_agent_stop(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: agent stop ---')
|
||||
console.log("\n--- Test: agent stop ---");
|
||||
|
||||
const result = await ctx.paseo(['stop', agentId])
|
||||
const result = await ctx.paseo(["stop", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent stop should succeed");
|
||||
|
||||
const inspectResult = await ctx.paseo(['inspect', agentId])
|
||||
assert.strictEqual(inspectResult.exitCode, 0, 'agent should remain inspectable after stop')
|
||||
assert(!inspectResult.stdout.toLowerCase().includes('running'), 'Agent should not be running after stop')
|
||||
const inspectResult = await ctx.paseo(["inspect", agentId]);
|
||||
assert.strictEqual(inspectResult.exitCode, 0, "agent should remain inspectable after stop");
|
||||
assert(
|
||||
!inspectResult.stdout.toLowerCase().includes("running"),
|
||||
"Agent should not be running after stop",
|
||||
);
|
||||
|
||||
console.log('PASS: agent stop interrupted without deleting the agent')
|
||||
console.log("PASS: agent stop interrupted without deleting the agent");
|
||||
}
|
||||
|
||||
async function test_agent_delete(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: agent delete ---')
|
||||
console.log("\n--- Test: agent delete ---");
|
||||
|
||||
const result = await ctx.paseo(['delete', agentId])
|
||||
const result = await ctx.paseo(["delete", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent delete should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent delete should succeed");
|
||||
|
||||
const inspectResult = await ctx.paseo(['inspect', agentId])
|
||||
assert.notStrictEqual(inspectResult.exitCode, 0, 'deleted agent should not be inspectable')
|
||||
const inspectResult = await ctx.paseo(["inspect", agentId]);
|
||||
assert.notStrictEqual(inspectResult.exitCode, 0, "deleted agent should not be inspectable");
|
||||
|
||||
console.log('PASS: agent delete removed the agent')
|
||||
console.log("PASS: agent delete removed the agent");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== E2E Test: Agent Lifecycle ===\n')
|
||||
console.log('This test creates REAL agents with REAL daemons.')
|
||||
console.log('It may take some time as it waits for agent responses.\n')
|
||||
console.log("=== E2E Test: Agent Lifecycle ===\n");
|
||||
console.log("This test creates REAL agents with REAL daemons.");
|
||||
console.log("It may take some time as it waits for agent responses.\n");
|
||||
|
||||
try {
|
||||
await setup()
|
||||
await setup();
|
||||
|
||||
// Run tests in sequence
|
||||
await test_agent_ls_empty()
|
||||
await test_agent_ls_empty();
|
||||
|
||||
const agentId = await test_agent_run_detached()
|
||||
const agentId = await test_agent_run_detached();
|
||||
|
||||
await test_agent_ls_shows_agent(agentId)
|
||||
await test_agent_ls_shows_agent(agentId);
|
||||
|
||||
await test_agent_wait(agentId)
|
||||
await test_agent_wait(agentId);
|
||||
|
||||
await test_agent_inspect(agentId)
|
||||
await test_agent_inspect(agentId);
|
||||
|
||||
await test_agent_logs(agentId)
|
||||
await test_agent_logs(agentId);
|
||||
|
||||
await test_agent_stop(agentId)
|
||||
await test_agent_delete(agentId)
|
||||
await test_agent_stop(agentId);
|
||||
await test_agent_delete(agentId);
|
||||
|
||||
console.log('\n=== All E2E tests passed! ===')
|
||||
console.log("\n=== All E2E tests passed! ===");
|
||||
} catch (err) {
|
||||
console.error('\n=== E2E test FAILED ===')
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
console.error("\n=== E2E test FAILED ===");
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup()
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
main();
|
||||
|
||||
@@ -21,185 +21,189 @@
|
||||
* - Clean up resources after test completes
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let ctx: E2EContext
|
||||
let ctx: E2EContext;
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
console.log('Setting up E2E test context...')
|
||||
console.log('Starting test daemon on random port (this may take a few seconds)...')
|
||||
console.log("Setting up E2E test context...");
|
||||
console.log("Starting test daemon on random port (this may take a few seconds)...");
|
||||
|
||||
try {
|
||||
ctx = await createE2ETestContext({ timeout: 45000 })
|
||||
console.log(`Test daemon started on port ${ctx.port}`)
|
||||
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
|
||||
console.log(`Work directory: ${ctx.workDir}`)
|
||||
ctx = await createE2ETestContext({ timeout: 45000 });
|
||||
console.log(`Test daemon started on port ${ctx.port}`);
|
||||
console.log(`PASEO_HOME: ${ctx.paseoHome}`);
|
||||
console.log(`Work directory: ${ctx.workDir}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start test daemon:', err)
|
||||
throw err
|
||||
console.error("Failed to start test daemon:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
console.log('\nCleaning up...')
|
||||
console.log("\nCleaning up...");
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
console.log('Test daemon stopped and temp directories removed')
|
||||
await ctx.stop();
|
||||
console.log("Test daemon stopped and temp directories removed");
|
||||
}
|
||||
}
|
||||
|
||||
async function test_create_agent(): Promise<string> {
|
||||
console.log('\n--- Test: Create agent in detached mode ---')
|
||||
console.log("\n--- Test: Create agent in detached mode ---");
|
||||
|
||||
// CRITICAL: Use haiku model for fast, cheap tests
|
||||
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'-q',
|
||||
'run',
|
||||
'-d',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--model',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'--mode',
|
||||
'bypassPermissions',
|
||||
'--name',
|
||||
'Send Test Agent',
|
||||
"-q",
|
||||
"run",
|
||||
"-d",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--model",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"--mode",
|
||||
"bypassPermissions",
|
||||
"--name",
|
||||
"Send Test Agent",
|
||||
'Say "initial task complete"',
|
||||
],
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent run should succeed");
|
||||
|
||||
const agentId = result.stdout.trim()
|
||||
assert(agentId.length > 0, 'Should return agent ID')
|
||||
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
|
||||
const agentId = result.stdout.trim();
|
||||
assert(agentId.length > 0, "Should return agent ID");
|
||||
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`);
|
||||
|
||||
console.log(`PASS: agent created with ID: ${agentId}`)
|
||||
return agentId
|
||||
console.log(`PASS: agent created with ID: ${agentId}`);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
async function test_wait_for_initial_task(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Wait for initial task to complete ---')
|
||||
console.log("\n--- Test: Wait for initial task to complete ---");
|
||||
|
||||
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
|
||||
const result = await ctx.paseo(["wait", "--timeout", "120s", agentId], { timeout: 130000 });
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent wait should succeed");
|
||||
|
||||
console.log('PASS: Initial task completed')
|
||||
console.log("PASS: Initial task completed");
|
||||
}
|
||||
|
||||
async function test_agent_send(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Send follow-up message ---')
|
||||
console.log("\n--- Test: Send follow-up message ---");
|
||||
|
||||
// Send a follow-up message to the agent
|
||||
const result = await ctx.paseo(
|
||||
['send', agentId, 'Now say "follow-up task complete"'],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
const result = await ctx.paseo(["send", agentId, 'Now say "follow-up task complete"'], {
|
||||
timeout: 180000,
|
||||
});
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent send should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent send should succeed");
|
||||
|
||||
// Verify output contains expected status
|
||||
assert(
|
||||
result.stdout.includes('completed') || result.stdout.includes('sent'),
|
||||
'Should indicate message was sent or completed'
|
||||
)
|
||||
result.stdout.includes("completed") || result.stdout.includes("sent"),
|
||||
"Should indicate message was sent or completed",
|
||||
);
|
||||
|
||||
console.log('PASS: Follow-up message sent successfully')
|
||||
console.log("PASS: Follow-up message sent successfully");
|
||||
}
|
||||
|
||||
async function test_verify_logs(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Verify agent processed both messages ---')
|
||||
console.log("\n--- Test: Verify agent processed both messages ---");
|
||||
|
||||
const result = await ctx.paseo(['logs', '--tail', '50', agentId])
|
||||
const result = await ctx.paseo(["logs", "--tail", "50", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout length:', result.stdout.length)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout length:", result.stdout.length);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent logs should succeed");
|
||||
|
||||
// Logs should have content from both tasks
|
||||
assert(result.stdout.length > 0, 'Logs should have content')
|
||||
assert(result.stdout.length > 0, "Logs should have content");
|
||||
|
||||
// The agent was asked to say specific things, check if either appears in logs
|
||||
// This is a loose check since exact log format may vary
|
||||
const logsLower = result.stdout.toLowerCase()
|
||||
const hasInitialTask = logsLower.includes('initial') || logsLower.includes('hello') || logsLower.includes('task')
|
||||
const hasFollowUp = logsLower.includes('follow') || logsLower.includes('complete') || logsLower.includes('task')
|
||||
const logsLower = result.stdout.toLowerCase();
|
||||
const hasInitialTask =
|
||||
logsLower.includes("initial") || logsLower.includes("hello") || logsLower.includes("task");
|
||||
const hasFollowUp =
|
||||
logsLower.includes("follow") || logsLower.includes("complete") || logsLower.includes("task");
|
||||
|
||||
// At minimum, there should be log entries (we can't guarantee exact content)
|
||||
assert(result.stdout.split('\n').length > 3, 'Should have multiple log entries from both tasks')
|
||||
assert(result.stdout.split("\n").length > 3, "Should have multiple log entries from both tasks");
|
||||
|
||||
console.log('PASS: Agent logs show activity from both tasks')
|
||||
console.log("PASS: Agent logs show activity from both tasks");
|
||||
}
|
||||
|
||||
async function test_agent_delete(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Delete agent ---')
|
||||
console.log("\n--- Test: Delete agent ---");
|
||||
|
||||
const result = await ctx.paseo(['delete', agentId])
|
||||
const result = await ctx.paseo(["delete", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent delete should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent delete should succeed");
|
||||
|
||||
console.log('PASS: Agent deleted successfully')
|
||||
console.log("PASS: Agent deleted successfully");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== E2E Test: Agent Send Command ===\n')
|
||||
console.log('This test verifies the `send` command with REAL agents.')
|
||||
console.log('It may take some time as it waits for agent responses.\n')
|
||||
console.log("=== E2E Test: Agent Send Command ===\n");
|
||||
console.log("This test verifies the `send` command with REAL agents.");
|
||||
console.log("It may take some time as it waits for agent responses.\n");
|
||||
|
||||
try {
|
||||
await setup()
|
||||
await setup();
|
||||
|
||||
// Create agent and wait for initial task
|
||||
const agentId = await test_create_agent()
|
||||
await test_wait_for_initial_task(agentId)
|
||||
const agentId = await test_create_agent();
|
||||
await test_wait_for_initial_task(agentId);
|
||||
|
||||
// Send follow-up message (this waits for completion by default)
|
||||
await test_agent_send(agentId)
|
||||
await test_agent_send(agentId);
|
||||
|
||||
// Verify both messages were processed
|
||||
await test_verify_logs(agentId)
|
||||
await test_verify_logs(agentId);
|
||||
|
||||
// Cleanup agent
|
||||
await test_agent_delete(agentId)
|
||||
await test_agent_delete(agentId);
|
||||
|
||||
console.log('\n=== All agent-send E2E tests passed! ===')
|
||||
console.log("\n=== All agent-send E2E tests passed! ===");
|
||||
} catch (err) {
|
||||
console.error('\n=== E2E test FAILED ===')
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
console.error("\n=== E2E test FAILED ===");
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup()
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
main();
|
||||
|
||||
@@ -21,106 +21,111 @@
|
||||
* - Clean up resources after test completes
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let ctx: E2EContext
|
||||
let ctx: E2EContext;
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
console.log('Setting up E2E test context...')
|
||||
console.log('Starting test daemon on random port (this may take a few seconds)...')
|
||||
console.log("Setting up E2E test context...");
|
||||
console.log("Starting test daemon on random port (this may take a few seconds)...");
|
||||
|
||||
try {
|
||||
ctx = await createE2ETestContext({ timeout: 45000 })
|
||||
console.log(`Test daemon started on port ${ctx.port}`)
|
||||
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
|
||||
console.log(`Work directory: ${ctx.workDir}`)
|
||||
ctx = await createE2ETestContext({ timeout: 45000 });
|
||||
console.log(`Test daemon started on port ${ctx.port}`);
|
||||
console.log(`PASEO_HOME: ${ctx.paseoHome}`);
|
||||
console.log(`Work directory: ${ctx.workDir}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start test daemon:', err)
|
||||
throw err
|
||||
console.error("Failed to start test daemon:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
console.log('\nCleaning up...')
|
||||
console.log("\nCleaning up...");
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
console.log('Test daemon stopped and temp directories removed')
|
||||
await ctx.stop();
|
||||
console.log("Test daemon stopped and temp directories removed");
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function test_create_agent_with_permissions(): Promise<string> {
|
||||
console.log('\n--- Test: Create agent in default mode (will request permissions) ---')
|
||||
console.log("\n--- Test: Create agent in default mode (will request permissions) ---");
|
||||
|
||||
// Create agent WITHOUT bypassPermissions - it will need to request permissions
|
||||
// Use a task that is very likely to trigger a tool use (and thus permission request)
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'-q',
|
||||
'agent',
|
||||
'run',
|
||||
'-d',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--model',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'--name',
|
||||
'Permission Test Agent',
|
||||
"-q",
|
||||
"agent",
|
||||
"run",
|
||||
"-d",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--model",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"--name",
|
||||
"Permission Test Agent",
|
||||
// This prompt should trigger file read or bash command, requiring permission
|
||||
'List the files in the current directory using ls',
|
||||
"List the files in the current directory using ls",
|
||||
],
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent run should succeed");
|
||||
|
||||
const agentId = result.stdout.trim()
|
||||
assert(agentId.length > 0, 'Should return agent ID')
|
||||
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
|
||||
const agentId = result.stdout.trim();
|
||||
assert(agentId.length > 0, "Should return agent ID");
|
||||
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`);
|
||||
|
||||
console.log(`PASS: agent created with ID: ${agentId}`)
|
||||
return agentId
|
||||
console.log(`PASS: agent created with ID: ${agentId}`);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
async function test_wait_for_permission_request(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Wait for agent to request permission ---')
|
||||
console.log("\n--- Test: Wait for agent to request permission ---");
|
||||
|
||||
// Poll for permission requests with timeout
|
||||
const maxWait = 60000 // 60 seconds max
|
||||
const pollInterval = 1000 // 1 second
|
||||
const startTime = Date.now()
|
||||
const maxWait = 60000; // 60 seconds max
|
||||
const pollInterval = 1000; // 1 second
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWait) {
|
||||
const result = await ctx.paseo(['permit', 'ls', '--json'])
|
||||
const result = await ctx.paseo(["permit", "ls", "--json"]);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
try {
|
||||
const permissions = JSON.parse(result.stdout.trim())
|
||||
const permissions = JSON.parse(result.stdout.trim());
|
||||
if (Array.isArray(permissions) && permissions.length > 0) {
|
||||
// Check if any permission is for our agent
|
||||
const ourPermission = permissions.find(
|
||||
(p: { agentId?: string }) => p.agentId?.startsWith(agentId.slice(0, 7)) || agentId.startsWith(p.agentId?.slice(0, 7) || '')
|
||||
)
|
||||
(p: { agentId?: string }) =>
|
||||
p.agentId?.startsWith(agentId.slice(0, 7)) ||
|
||||
agentId.startsWith(p.agentId?.slice(0, 7) || ""),
|
||||
);
|
||||
if (ourPermission) {
|
||||
console.log('Permission request detected:', ourPermission)
|
||||
console.log('PASS: Agent requested permission')
|
||||
return
|
||||
console.log("Permission request detected:", ourPermission);
|
||||
console.log("PASS: Agent requested permission");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -128,158 +133,166 @@ async function test_wait_for_permission_request(agentId: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(pollInterval)
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
|
||||
// If we get here, check agent status - it might have already completed
|
||||
const statusResult = await ctx.paseo(['inspect', agentId])
|
||||
console.log('Agent status:', statusResult.stdout)
|
||||
const statusResult = await ctx.paseo(["inspect", agentId]);
|
||||
console.log("Agent status:", statusResult.stdout);
|
||||
|
||||
// It's possible the agent completed without needing permissions (e.g., haiku might not use tools)
|
||||
// In that case, we should skip the permission tests
|
||||
if (statusResult.stdout.includes('idle') || statusResult.stdout.includes('completed')) {
|
||||
console.log('SKIP: Agent completed without requiring permissions (model chose not to use tools)')
|
||||
throw new Error('SKIP_PERMISSION_TEST')
|
||||
if (statusResult.stdout.includes("idle") || statusResult.stdout.includes("completed")) {
|
||||
console.log(
|
||||
"SKIP: Agent completed without requiring permissions (model chose not to use tools)",
|
||||
);
|
||||
throw new Error("SKIP_PERMISSION_TEST");
|
||||
}
|
||||
|
||||
throw new Error('Timeout waiting for agent to request permission')
|
||||
throw new Error("Timeout waiting for agent to request permission");
|
||||
}
|
||||
|
||||
async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> {
|
||||
console.log('\n--- Test: List pending permissions with permit ls ---')
|
||||
console.log("\n--- Test: List pending permissions with permit ls ---");
|
||||
|
||||
const result = await ctx.paseo(['permit', 'ls', '--json'])
|
||||
const result = await ctx.paseo(["permit", "ls", "--json"]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'permit ls should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "permit ls should succeed");
|
||||
|
||||
const permissions = JSON.parse(result.stdout.trim())
|
||||
assert(Array.isArray(permissions), 'Output should be JSON array')
|
||||
assert(permissions.length > 0, 'Should have at least one pending permission')
|
||||
const permissions = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(permissions), "Output should be JSON array");
|
||||
assert(permissions.length > 0, "Should have at least one pending permission");
|
||||
|
||||
const permission = permissions[0]
|
||||
assert(permission.id, 'Permission should have id')
|
||||
assert(permission.agentShortId, 'Permission should have agentShortId')
|
||||
assert(permission.name, 'Permission should have tool name')
|
||||
const permission = permissions[0];
|
||||
assert(permission.id, "Permission should have id");
|
||||
assert(permission.agentShortId, "Permission should have agentShortId");
|
||||
assert(permission.name, "Permission should have tool name");
|
||||
|
||||
console.log(`PASS: Found ${permissions.length} pending permission(s)`)
|
||||
console.log(` Request ID: ${permission.id}`)
|
||||
console.log(` Agent: ${permission.agentShortId}`)
|
||||
console.log(` Tool: ${permission.name}`)
|
||||
console.log(`PASS: Found ${permissions.length} pending permission(s)`);
|
||||
console.log(` Request ID: ${permission.id}`);
|
||||
console.log(` Agent: ${permission.agentShortId}`);
|
||||
console.log(` Tool: ${permission.name}`);
|
||||
|
||||
return {
|
||||
agentShortId: permission.agentShortId,
|
||||
requestId: permission.id,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function test_permit_allow(agentShortId: string, requestId: string): Promise<void> {
|
||||
console.log('\n--- Test: Allow permission with permit allow ---')
|
||||
console.log("\n--- Test: Allow permission with permit allow ---");
|
||||
|
||||
const result = await ctx.paseo(['permit', 'allow', agentShortId, requestId])
|
||||
const result = await ctx.paseo(["permit", "allow", agentShortId, requestId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'permit allow should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "permit allow should succeed");
|
||||
|
||||
// Check that output shows the permission was allowed
|
||||
const output = result.stdout.toLowerCase()
|
||||
assert(output.includes('allowed') || output.includes(requestId.slice(0, 8).toLowerCase()), 'Should confirm permission was allowed')
|
||||
const output = result.stdout.toLowerCase();
|
||||
assert(
|
||||
output.includes("allowed") || output.includes(requestId.slice(0, 8).toLowerCase()),
|
||||
"Should confirm permission was allowed",
|
||||
);
|
||||
|
||||
console.log('PASS: Permission allowed successfully')
|
||||
console.log("PASS: Permission allowed successfully");
|
||||
}
|
||||
|
||||
async function test_agent_continues(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Verify agent continues after permission granted ---')
|
||||
console.log("\n--- Test: Verify agent continues after permission granted ---");
|
||||
|
||||
// Wait for agent to become idle (should continue after permission)
|
||||
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
|
||||
const result = await ctx.paseo(["wait", "--timeout", "120s", agentId], { timeout: 130000 });
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed after permission granted')
|
||||
assert.strictEqual(result.exitCode, 0, "agent wait should succeed after permission granted");
|
||||
|
||||
// Verify agent status
|
||||
const inspectResult = await ctx.paseo(['inspect', agentId])
|
||||
console.log('Final agent status:', inspectResult.stdout)
|
||||
const inspectResult = await ctx.paseo(["inspect", agentId]);
|
||||
console.log("Final agent status:", inspectResult.stdout);
|
||||
|
||||
assert(inspectResult.stdout.includes('idle') || inspectResult.stdout.includes('completed'), 'Agent should be idle after completing')
|
||||
assert(
|
||||
inspectResult.stdout.includes("idle") || inspectResult.stdout.includes("completed"),
|
||||
"Agent should be idle after completing",
|
||||
);
|
||||
|
||||
console.log('PASS: Agent completed after permission was granted')
|
||||
console.log("PASS: Agent completed after permission was granted");
|
||||
}
|
||||
|
||||
async function test_agent_delete(agentId: string): Promise<void> {
|
||||
console.log('\n--- Test: Delete agent ---')
|
||||
console.log("\n--- Test: Delete agent ---");
|
||||
|
||||
const result = await ctx.paseo(['delete', agentId])
|
||||
const result = await ctx.paseo(["delete", agentId]);
|
||||
|
||||
console.log('Exit code:', result.exitCode)
|
||||
console.log('Stdout:', result.stdout)
|
||||
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||
console.log("Exit code:", result.exitCode);
|
||||
console.log("Stdout:", result.stdout);
|
||||
if (result.stderr) console.log("Stderr:", result.stderr);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, 'agent delete should succeed')
|
||||
assert.strictEqual(result.exitCode, 0, "agent delete should succeed");
|
||||
|
||||
console.log('PASS: Agent deleted successfully')
|
||||
console.log("PASS: Agent deleted successfully");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== E2E Test: Permissions Workflow ===\n')
|
||||
console.log('This test verifies the permission request/allow workflow.')
|
||||
console.log('It creates an agent that will request tool permissions.\n')
|
||||
console.log("=== E2E Test: Permissions Workflow ===\n");
|
||||
console.log("This test verifies the permission request/allow workflow.");
|
||||
console.log("It creates an agent that will request tool permissions.\n");
|
||||
|
||||
let agentId: string | undefined
|
||||
let agentId: string | undefined;
|
||||
|
||||
try {
|
||||
await setup()
|
||||
await setup();
|
||||
|
||||
// Create agent that will need permissions
|
||||
agentId = await test_create_agent_with_permissions()
|
||||
agentId = await test_create_agent_with_permissions();
|
||||
|
||||
try {
|
||||
// Wait for permission request
|
||||
await test_wait_for_permission_request(agentId)
|
||||
await test_wait_for_permission_request(agentId);
|
||||
|
||||
// List and verify permissions
|
||||
const { agentShortId, requestId } = await test_permit_ls()
|
||||
const { agentShortId, requestId } = await test_permit_ls();
|
||||
|
||||
// Allow the permission
|
||||
await test_permit_allow(agentShortId, requestId)
|
||||
await test_permit_allow(agentShortId, requestId);
|
||||
|
||||
// Verify agent continues and completes
|
||||
await test_agent_continues(agentId)
|
||||
await test_agent_continues(agentId);
|
||||
|
||||
console.log('\n=== All permissions E2E tests passed! ===')
|
||||
console.log("\n=== All permissions E2E tests passed! ===");
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'SKIP_PERMISSION_TEST') {
|
||||
console.log('\n=== Permissions test skipped (agent completed without tool use) ===')
|
||||
console.log('This can happen when the model chooses not to use tools.')
|
||||
if (err instanceof Error && err.message === "SKIP_PERMISSION_TEST") {
|
||||
console.log("\n=== Permissions test skipped (agent completed without tool use) ===");
|
||||
console.log("This can happen when the model chooses not to use tools.");
|
||||
// Still considered a pass - the CLI works, just the model behavior varies
|
||||
} else {
|
||||
throw err
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('\n=== E2E test FAILED ===')
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
console.error("\n=== E2E test FAILED ===");
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
// Always try to delete the agent if it was created
|
||||
if (agentId) {
|
||||
try {
|
||||
await test_agent_delete(agentId)
|
||||
await test_agent_delete(agentId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
await cleanup()
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
main();
|
||||
|
||||
@@ -1,141 +1,144 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const schema = JSON.stringify({
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ['summary'],
|
||||
required: ["summary"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
});
|
||||
|
||||
const impossibleSchema = JSON.stringify({
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ['summary'],
|
||||
required: ["summary"],
|
||||
allOf: [
|
||||
{ properties: { summary: { type: 'string' } } },
|
||||
{ properties: { summary: { type: 'number' } } },
|
||||
{ properties: { summary: { type: "string" } } },
|
||||
{ properties: { summary: { type: "number" } } },
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
const OPEN_CODE_STRUCTURED_MODEL = 'opencode/gpt-5-nano'
|
||||
const OPEN_CODE_STRUCTURED_MODEL = "opencode/gpt-5-nano";
|
||||
|
||||
let ctx: E2EContext
|
||||
let ctx: E2EContext;
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
ctx = await createE2ETestContext({ timeout: 180000 })
|
||||
ctx = await createE2ETestContext({ timeout: 180000 });
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
await ctx.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderCase(input: {
|
||||
provider: 'claude' | 'codex' | 'opencode'
|
||||
mode: string
|
||||
model: string
|
||||
provider: "claude" | "codex" | "opencode";
|
||||
mode: string;
|
||||
model: string;
|
||||
}): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
"run",
|
||||
"--provider",
|
||||
input.provider,
|
||||
'--mode',
|
||||
"--mode",
|
||||
input.mode,
|
||||
'--model',
|
||||
"--model",
|
||||
input.model,
|
||||
'--output-schema',
|
||||
"--output-schema",
|
||||
schema,
|
||||
`Return valid JSON with a short summary for provider ${input.provider}.`,
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.exitCode,
|
||||
0,
|
||||
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
|
||||
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown };
|
||||
assert.strictEqual(
|
||||
typeof parsed.summary,
|
||||
'string',
|
||||
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
|
||||
)
|
||||
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
|
||||
"string",
|
||||
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`,
|
||||
);
|
||||
assert(parsed.summary && parsed.summary.length > 0, "summary must not be empty");
|
||||
}
|
||||
|
||||
async function test_all_providers_return_structured_output(): Promise<void> {
|
||||
await runProviderCase({
|
||||
provider: 'claude',
|
||||
mode: 'bypassPermissions',
|
||||
model: 'haiku',
|
||||
})
|
||||
provider: "claude",
|
||||
mode: "bypassPermissions",
|
||||
model: "haiku",
|
||||
});
|
||||
await runProviderCase({
|
||||
provider: 'codex',
|
||||
mode: 'full-access',
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
provider: "codex",
|
||||
mode: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
});
|
||||
await runProviderCase({
|
||||
provider: 'opencode',
|
||||
mode: 'default',
|
||||
provider: "opencode",
|
||||
mode: "default",
|
||||
model: OPEN_CODE_STRUCTURED_MODEL,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async function test_schema_validation_is_enforced(): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--mode',
|
||||
'bypassPermissions',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--output-schema',
|
||||
"run",
|
||||
"--provider",
|
||||
"claude",
|
||||
"--mode",
|
||||
"bypassPermissions",
|
||||
"--model",
|
||||
"haiku",
|
||||
"--output-schema",
|
||||
impossibleSchema,
|
||||
'Return exactly {"summary":"ok"} and nothing else.',
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
|
||||
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
assert.notStrictEqual(result.exitCode, 0, "expected impossible schema to fail");
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
assert(
|
||||
output.includes('OUTPUT_SCHEMA_FAILED'),
|
||||
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
output.includes("OUTPUT_SCHEMA_FAILED"),
|
||||
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await setup()
|
||||
await test_all_providers_return_structured_output()
|
||||
await test_schema_validation_is_enforced()
|
||||
await setup();
|
||||
await test_all_providers_return_structured_output();
|
||||
await test_schema_validation_is_enforced();
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup()
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
main();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { $, ProcessPromise } from 'zx'
|
||||
import { join } from 'node:path'
|
||||
import { $, ProcessPromise } from "zx";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CLI_ENTRY = join(import.meta.dirname, '..', '..', 'dist', 'index.js')
|
||||
const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js");
|
||||
|
||||
export function runLocalPaseo(args: string[], env: NodeJS.ProcessEnv = {}): ProcessPromise {
|
||||
$.verbose = false
|
||||
return $({ env: { ...process.env, ...env } })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow()
|
||||
$.verbose = false;
|
||||
return $({ env: { ...process.env, ...env } })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createServer } from 'node:net'
|
||||
import { createServer } from "node:net";
|
||||
|
||||
/**
|
||||
* Reserve and release an ephemeral TCP port, returning the port number.
|
||||
@@ -6,27 +6,27 @@ import { createServer } from 'node:net'
|
||||
*/
|
||||
export async function getAvailablePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => {
|
||||
reject(new Error('Failed to resolve an available TCP port'))
|
||||
})
|
||||
return
|
||||
reject(new Error("Failed to resolve an available TCP port"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { port } = address
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
})
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,139 +12,139 @@
|
||||
* 5. Cleanup: Kill daemon and remove temp dirs after each test
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { getAvailablePort } from './network.ts'
|
||||
import { mkdtemp, rm, mkdir } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { getAvailablePort } from "./network.ts";
|
||||
|
||||
export interface TestDaemonContext {
|
||||
/** Available local port for test daemon (never 6767) */
|
||||
port: number
|
||||
port: number;
|
||||
/** WebSocket URL for connecting to daemon */
|
||||
wsUrl: string
|
||||
wsUrl: string;
|
||||
/** Temp directory for PASEO_HOME */
|
||||
paseoHome: string
|
||||
paseoHome: string;
|
||||
/** Temp directory for agent working directory */
|
||||
workDir: string
|
||||
workDir: string;
|
||||
/** Running daemon process */
|
||||
process: ChildProcess | null
|
||||
process: ChildProcess | null;
|
||||
/** Whether the daemon is ready to accept connections */
|
||||
isReady: boolean
|
||||
isReady: boolean;
|
||||
/** Stop the daemon and clean up resources */
|
||||
stop: () => Promise<void>
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
const TEST_DAEMON_HOST = '127.0.0.1'
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
const TEST_DAEMON_HOST = "127.0.0.1";
|
||||
|
||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024
|
||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
|
||||
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
||||
process.env.PASEO_TEST_OUTPUT_CAPTURE_BYTES ?? `${DEFAULT_OUTPUT_CAPTURE_LIMIT}`,
|
||||
10
|
||||
)
|
||||
10,
|
||||
);
|
||||
|
||||
type OutputCapture = {
|
||||
value: string
|
||||
truncated: boolean
|
||||
}
|
||||
value: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function createOutputCapture(): OutputCapture {
|
||||
return { value: '', truncated: false }
|
||||
return { value: "", truncated: false };
|
||||
}
|
||||
|
||||
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
|
||||
const next = target.value + chunk.toString()
|
||||
const next = target.value + chunk.toString();
|
||||
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
|
||||
target.value = next
|
||||
return
|
||||
target.value = next;
|
||||
return;
|
||||
}
|
||||
target.truncated = true
|
||||
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT)
|
||||
target.truncated = true;
|
||||
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT);
|
||||
}
|
||||
|
||||
function formatOutputCapture(target: OutputCapture): string {
|
||||
if (!target.truncated) {
|
||||
return target.value
|
||||
return target.value;
|
||||
}
|
||||
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`
|
||||
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`;
|
||||
}
|
||||
|
||||
function readNodeErrnoCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return undefined
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof (error as { code?: unknown }).code === 'string'
|
||||
? ((error as { code: string }).code)
|
||||
: undefined
|
||||
return typeof (error as { code?: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = readNodeErrnoCode(error)
|
||||
if (code === 'ESRCH') {
|
||||
return false
|
||||
const code = readNodeErrnoCode(error);
|
||||
if (code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = readNodeErrnoCode(error)
|
||||
if (code === 'ESRCH') {
|
||||
return false
|
||||
const code = readNodeErrnoCode(error);
|
||||
if (code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
const pid = processRef.pid
|
||||
const pid = processRef.pid;
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (processRef.exitCode !== null || processRef.signalCode !== null) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
signalProcessTree(pid, 'SIGTERM')
|
||||
signalProcessTree(pid, "SIGTERM");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
signalProcessTree(pid, 'SIGKILL')
|
||||
finish()
|
||||
}, timeoutMs)
|
||||
signalProcessTree(pid, "SIGKILL");
|
||||
finish();
|
||||
}, timeoutMs);
|
||||
|
||||
processRef.once('exit', () => {
|
||||
finish()
|
||||
})
|
||||
})
|
||||
processRef.once("exit", () => {
|
||||
finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,32 +153,29 @@ async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number)
|
||||
* NEVER uses 6767 (user's running daemon)
|
||||
*/
|
||||
export function getRandomPort(): number {
|
||||
return 20000 + Math.floor(Math.random() * 10000)
|
||||
return 20000 + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create isolated temp directories for testing
|
||||
*/
|
||||
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-e2e-home-'))
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'paseo-e2e-work-'))
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-e2e-home-"));
|
||||
const workDir = await mkdtemp(join(tmpdir(), "paseo-e2e-work-"));
|
||||
|
||||
// Create the agents directory that the daemon expects
|
||||
const agentsDir = join(paseoHome, 'agents')
|
||||
await mkdir(agentsDir, { recursive: true })
|
||||
const agentsDir = join(paseoHome, "agents");
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
|
||||
return { paseoHome, workDir }
|
||||
return { paseoHome, workDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for daemon to be ready by running `paseo agent ls`
|
||||
* This connects via WebSocket and ensures the daemon is responsive
|
||||
*/
|
||||
async function waitForDaemonReady(
|
||||
port: number,
|
||||
timeout = 30000
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
async function waitForDaemonReady(port: number, timeout = 30000): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
@@ -186,28 +183,28 @@ async function waitForDaemonReady(
|
||||
{
|
||||
port,
|
||||
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
||||
paseoHome: '',
|
||||
workDir: '',
|
||||
paseoHome: "",
|
||||
workDir: "",
|
||||
process: null,
|
||||
isReady: false,
|
||||
stop: async () => {}
|
||||
stop: async () => {},
|
||||
},
|
||||
['agent', 'ls']
|
||||
)
|
||||
["agent", "ls"],
|
||||
);
|
||||
|
||||
if (exitCode === 0) {
|
||||
return // Daemon is ready
|
||||
return; // Daemon is ready
|
||||
}
|
||||
} catch {
|
||||
// Connection failed, keep trying
|
||||
}
|
||||
await sleep(100)
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`)
|
||||
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,57 +214,58 @@ function sleep(ms: number): Promise<void> {
|
||||
* with isolated PASEO_HOME and PASEO_LISTEN environment variables.
|
||||
*/
|
||||
export async function startTestDaemon(options?: {
|
||||
port?: number
|
||||
paseoHome?: string
|
||||
workDir?: string
|
||||
timeout?: number
|
||||
port?: number;
|
||||
paseoHome?: string;
|
||||
workDir?: string;
|
||||
timeout?: number;
|
||||
}): Promise<TestDaemonContext> {
|
||||
const port = options?.port ?? await getAvailablePort()
|
||||
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
|
||||
? { paseoHome: options.paseoHome, workDir: options.workDir }
|
||||
: await createTempDirs()
|
||||
const timeout = options?.timeout ?? 30000
|
||||
const port = options?.port ?? (await getAvailablePort());
|
||||
const { paseoHome, workDir } =
|
||||
options?.paseoHome && options?.workDir
|
||||
? { paseoHome: options.paseoHome, workDir: options.workDir }
|
||||
: await createTempDirs();
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
|
||||
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`
|
||||
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
|
||||
|
||||
// Find the CLI entry point - use the source file directly with tsx
|
||||
const cliDir = join(import.meta.dirname, '..', '..')
|
||||
const cliSrcPath = join(cliDir, 'src', 'index.ts')
|
||||
const cliDir = join(import.meta.dirname, "..", "..");
|
||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||
|
||||
// Start daemon process using tsx to run TypeScript directly
|
||||
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
|
||||
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: 'true',
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
})
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
const stdout = createOutputCapture()
|
||||
const stderr = createOutputCapture()
|
||||
const stdout = createOutputCapture();
|
||||
const stderr = createOutputCapture();
|
||||
|
||||
daemonProcess.stdout?.on('data', (data) => {
|
||||
appendOutputCapture(stdout, data)
|
||||
})
|
||||
daemonProcess.stdout?.on("data", (data) => {
|
||||
appendOutputCapture(stdout, data);
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data) => {
|
||||
appendOutputCapture(stderr, data)
|
||||
})
|
||||
daemonProcess.stderr?.on("data", (data) => {
|
||||
appendOutputCapture(stderr, data);
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
if (daemonProcess) {
|
||||
await terminateProcessTree(daemonProcess, 5000)
|
||||
await terminateProcessTree(daemonProcess, 5000);
|
||||
}
|
||||
|
||||
// Clean up temp directories
|
||||
try {
|
||||
if (existsSync(paseoHome)) {
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
@@ -275,27 +273,27 @@ export async function startTestDaemon(options?: {
|
||||
|
||||
try {
|
||||
if (existsSync(workDir)) {
|
||||
await rm(workDir, { recursive: true, force: true })
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle process errors
|
||||
daemonProcess.on('error', (err) => {
|
||||
console.error('Daemon process error:', err)
|
||||
})
|
||||
daemonProcess.on("error", (err) => {
|
||||
console.error("Daemon process error:", err);
|
||||
});
|
||||
|
||||
daemonProcess.on('exit', (code) => {
|
||||
daemonProcess.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`Daemon process exited with code ${code}`)
|
||||
const stderrText = formatOutputCapture(stderr)
|
||||
console.error(`Daemon process exited with code ${code}`);
|
||||
const stderrText = formatOutputCapture(stderr);
|
||||
if (stderrText) {
|
||||
console.error('Daemon stderr:', stderrText)
|
||||
console.error("Daemon stderr:", stderrText);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const ctx: TestDaemonContext = {
|
||||
port,
|
||||
@@ -305,22 +303,22 @@ export async function startTestDaemon(options?: {
|
||||
process: daemonProcess,
|
||||
isReady: false,
|
||||
stop: cleanup,
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for daemon to be ready
|
||||
try {
|
||||
await waitForDaemonReady(port, timeout)
|
||||
ctx.isReady = true
|
||||
await waitForDaemonReady(port, timeout);
|
||||
ctx.isReady = true;
|
||||
} catch (err) {
|
||||
// Daemon failed to start - clean up and rethrow
|
||||
await cleanup()
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
await cleanup();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`
|
||||
)
|
||||
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return ctx
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,18 +331,18 @@ export async function runPaseoCli(
|
||||
ctx: TestDaemonContext,
|
||||
args: string[],
|
||||
options?: {
|
||||
timeout?: number
|
||||
cwd?: string
|
||||
}
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000
|
||||
const cwd = options?.cwd ?? ctx.workDir
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
const cwd = options?.cwd ?? ctx.workDir;
|
||||
|
||||
const cliDir = join(import.meta.dirname, '..', '..')
|
||||
const cliSrcPath = join(cliDir, 'src', 'index.ts')
|
||||
const cliDir = join(import.meta.dirname, "..", "..");
|
||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
|
||||
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
@@ -352,42 +350,42 @@ export async function runPaseoCli(
|
||||
PASEO_HOME: ctx.paseoHome,
|
||||
},
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
})
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
const stdout = createOutputCapture()
|
||||
const stderr = createOutputCapture()
|
||||
const stdout = createOutputCapture();
|
||||
const stderr = createOutputCapture();
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
appendOutputCapture(stdout, data)
|
||||
})
|
||||
proc.stdout?.on("data", (data) => {
|
||||
appendOutputCapture(stdout, data);
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
appendOutputCapture(stderr, data)
|
||||
})
|
||||
proc.stderr?.on("data", (data) => {
|
||||
appendOutputCapture(stderr, data);
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (proc.pid) {
|
||||
signalProcessTree(proc.pid, 'SIGKILL')
|
||||
signalProcessTree(proc.pid, "SIGKILL");
|
||||
}
|
||||
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
|
||||
}, timeout)
|
||||
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(" ")}`));
|
||||
}, timeout);
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timeoutId)
|
||||
proc.on("exit", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout: formatOutputCapture(stdout),
|
||||
stderr: formatOutputCapture(stderr),
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,25 +394,26 @@ export async function runPaseoCli(
|
||||
*
|
||||
* This is the main entry point for E2E tests.
|
||||
*/
|
||||
export async function createE2ETestContext(options?: {
|
||||
timeout?: number
|
||||
}): Promise<
|
||||
export async function createE2ETestContext(options?: { timeout?: number }): Promise<
|
||||
TestDaemonContext & {
|
||||
/** Run a paseo CLI command against this daemon */
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
}
|
||||
> {
|
||||
const ctx = await startTestDaemon({ timeout: options?.timeout })
|
||||
const ctx = await startTestDaemon({ timeout: options?.timeout });
|
||||
|
||||
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) =>
|
||||
runPaseoCli(ctx, args, opts)
|
||||
runPaseoCli(ctx, args, opts);
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
paseo,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,48 +7,48 @@
|
||||
* Each test is a separate .ts file that can also be run independently.
|
||||
*/
|
||||
|
||||
import { $ } from 'zx'
|
||||
import { readdir, writeFile } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { $ } from "zx";
|
||||
import { readdir, writeFile } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const args = process.argv.slice(2)
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const args = process.argv.slice(2);
|
||||
const testEnvDefaults = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
let jsonOutputPath: string | null = null
|
||||
let jsonOutputPath: string | null = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === '--json-output') {
|
||||
const value = args[i + 1]
|
||||
const arg = args[i];
|
||||
if (arg === "--json-output") {
|
||||
const value = args[i + 1];
|
||||
if (!value) {
|
||||
throw new Error('--json-output requires a file path')
|
||||
throw new Error("--json-output requires a file path");
|
||||
}
|
||||
jsonOutputPath = value
|
||||
i++
|
||||
continue
|
||||
jsonOutputPath = value;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$.verbose = false
|
||||
$.verbose = false;
|
||||
|
||||
type Failure = { test: string; error: string }
|
||||
type Failure = { test: string; error: string };
|
||||
|
||||
async function runCommand(label: string, command: string): Promise<void> {
|
||||
console.log(`\n${'─'.repeat(50)}`)
|
||||
console.log(`🔧 ${label}...`)
|
||||
console.log('─'.repeat(50))
|
||||
console.log(`\n${"─".repeat(50)}`);
|
||||
console.log(`🔧 ${label}...`);
|
||||
console.log("─".repeat(50));
|
||||
|
||||
const result = await $`bash -lc ${command}`.nothrow()
|
||||
const result = await $`bash -lc ${command}`.nothrow();
|
||||
if (result.exitCode !== 0) {
|
||||
const error = result.stderr || result.stdout || `Exit code: ${result.exitCode}`
|
||||
console.error(`\n❌ ${label} failed`)
|
||||
console.error(error)
|
||||
throw new Error(error)
|
||||
const error = result.stderr || result.stdout || `Exit code: ${result.exitCode}`;
|
||||
console.error(`\n❌ ${label} failed`);
|
||||
console.error(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,20 +57,20 @@ async function writeJsonSummary({
|
||||
failed,
|
||||
failures,
|
||||
}: {
|
||||
passed: number
|
||||
failed: number
|
||||
failures: Failure[]
|
||||
passed: number;
|
||||
failed: number;
|
||||
failures: Failure[];
|
||||
}) {
|
||||
if (!jsonOutputPath) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
jsonOutputPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
suite: 'cli-local',
|
||||
command: 'npm run test:local --workspace=@getpaseo/cli',
|
||||
suite: "cli-local",
|
||||
command: "npm run test:local --workspace=@getpaseo/cli",
|
||||
counts: {
|
||||
passed,
|
||||
failed,
|
||||
@@ -79,96 +79,95 @@ async function writeJsonSummary({
|
||||
skippedTests: [],
|
||||
failures: failures.map(({ test, error }) => ({
|
||||
test,
|
||||
error: error.split('\n')[0] ?? '',
|
||||
error: error.split("\n")[0] ?? "",
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
)
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🧪 Paseo CLI E2E Test Runner\n')
|
||||
console.log('='.repeat(50))
|
||||
console.log("🧪 Paseo CLI E2E Test Runner\n");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
// Discover all test files
|
||||
const files = await readdir(__dirname)
|
||||
const testFiles = files
|
||||
.filter(f => f.match(/^\d{2}-.*\.test\.ts$/))
|
||||
.sort()
|
||||
const files = await readdir(__dirname);
|
||||
const testFiles = files.filter((f) => f.match(/^\d{2}-.*\.test\.ts$/)).sort();
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.log('❌ No test files found')
|
||||
await writeJsonSummary({ passed: 0, failed: 0, failures: [] })
|
||||
process.exit(1)
|
||||
console.log("❌ No test files found");
|
||||
await writeJsonSummary({ passed: 0, failed: 0, failures: [] });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Found ${testFiles.length} test file(s):\n`)
|
||||
console.log(`Found ${testFiles.length} test file(s):\n`);
|
||||
for (const file of testFiles) {
|
||||
console.log(` - ${file}`)
|
||||
console.log(` - ${file}`);
|
||||
}
|
||||
console.log()
|
||||
console.log();
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
const failures: Failure[] = []
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures: Failure[] = [];
|
||||
|
||||
await runCommand('Building relay', 'npm run build --workspace=@getpaseo/relay')
|
||||
await runCommand('Building server', 'npm run build --workspace=@getpaseo/server')
|
||||
await runCommand('Building CLI', 'npm run build --workspace=@getpaseo/cli')
|
||||
await runCommand("Building relay", "npm run build --workspace=@getpaseo/relay");
|
||||
await runCommand("Building server", "npm run build --workspace=@getpaseo/server");
|
||||
await runCommand("Building CLI", "npm run build --workspace=@getpaseo/cli");
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const testPath = join(__dirname, testFile)
|
||||
const testName = testFile.replace(/\.test\.ts$/, '')
|
||||
const testPath = join(__dirname, testFile);
|
||||
const testName = testFile.replace(/\.test\.ts$/, "");
|
||||
|
||||
console.log(`\n${'─'.repeat(50)}`)
|
||||
console.log(`📋 Running ${testName}...`)
|
||||
console.log('─'.repeat(50))
|
||||
console.log(`\n${"─".repeat(50)}`);
|
||||
console.log(`📋 Running ${testName}...`);
|
||||
console.log("─".repeat(50));
|
||||
|
||||
try {
|
||||
const result = await $`PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnvDefaults.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnvDefaults.PASEO_VOICE_MODE_ENABLED} npx tsx ${testPath}`.nothrow()
|
||||
const result =
|
||||
await $`PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnvDefaults.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnvDefaults.PASEO_VOICE_MODE_ENABLED} npx tsx ${testPath}`.nothrow();
|
||||
if (result.exitCode === 0) {
|
||||
console.log(`\n✅ ${testName} PASSED`)
|
||||
passed++
|
||||
console.log(`\n✅ ${testName} PASSED`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`)
|
||||
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`);
|
||||
if (result.stderr) {
|
||||
console.log('stderr:', result.stderr)
|
||||
console.log("stderr:", result.stderr);
|
||||
}
|
||||
failed++
|
||||
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` })
|
||||
break
|
||||
failed++;
|
||||
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` });
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
console.log(`\n❌ ${testName} FAILED`)
|
||||
console.log('Error:', error)
|
||||
failed++
|
||||
failures.push({ test: testName, error })
|
||||
break
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
console.log(`\n❌ ${testName} FAILED`);
|
||||
console.log("Error:", error);
|
||||
failed++;
|
||||
failures.push({ test: testName, error });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(50))
|
||||
console.log('📊 Test Results')
|
||||
console.log('='.repeat(50))
|
||||
console.log(` ✅ Passed: ${passed}`)
|
||||
console.log(` ❌ Failed: ${failed}`)
|
||||
console.log(` 📝 Total: ${passed + failed}`)
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("📊 Test Results");
|
||||
console.log("=".repeat(50));
|
||||
console.log(` ✅ Passed: ${passed}`);
|
||||
console.log(` ❌ Failed: ${failed}`);
|
||||
console.log(` 📝 Total: ${passed + failed}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ Failed tests:')
|
||||
console.log("\n❌ Failed tests:");
|
||||
for (const { test, error } of failures) {
|
||||
console.log(` - ${test}`)
|
||||
console.log(` - ${test}`);
|
||||
if (error) {
|
||||
console.log(` ${error.split('\n')[0]}`)
|
||||
console.log(` ${error.split("\n")[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log();
|
||||
|
||||
await writeJsonSummary({ passed, failed, failures })
|
||||
await writeJsonSummary({ passed, failed, failures });
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
+65
-67
@@ -9,57 +9,57 @@
|
||||
* 5. Cleanup: Kill daemon and remove temp dirs after each test
|
||||
*/
|
||||
|
||||
import { $, ProcessPromise, sleep } from 'zx'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { $, ProcessPromise, sleep } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
const TEST_ENV_DEFAULTS = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function killPidTree(pid: number, signal: NodeJS.Signals): void {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ESRCH') {
|
||||
return
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'ESRCH') {
|
||||
throw error
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestContext {
|
||||
/** Random port for test daemon (never 6767) */
|
||||
port: number
|
||||
port: number;
|
||||
/** Temp directory for PASEO_HOME */
|
||||
paseoHome: string
|
||||
paseoHome: string;
|
||||
/** Temp directory for agent working directory */
|
||||
workDir: string
|
||||
workDir: string;
|
||||
/** Running daemon process */
|
||||
daemon: ProcessPromise | null
|
||||
daemon: ProcessPromise | null;
|
||||
/** Run a paseo CLI command against the test daemon */
|
||||
paseo: (args: string[]) => ProcessPromise
|
||||
paseo: (args: string[]) => ProcessPromise;
|
||||
/** Clean up all resources */
|
||||
cleanup: () => Promise<void>
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,16 +67,16 @@ export interface TestContext {
|
||||
* NEVER uses 6767 (user's running daemon)
|
||||
*/
|
||||
export function getRandomPort(): number {
|
||||
return 10000 + Math.floor(Math.random() * 50000)
|
||||
return 10000 + Math.floor(Math.random() * 50000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create isolated temp directories for testing
|
||||
*/
|
||||
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'paseo-test-work-'))
|
||||
return { paseoHome, workDir }
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
||||
const workDir = await mkdtemp(join(tmpdir(), "paseo-test-work-"));
|
||||
return { paseoHome, workDir };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,58 +84,56 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
|
||||
* Uses `paseo agent ls` which connects via WebSocket
|
||||
*/
|
||||
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
|
||||
const start = Date.now()
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow()
|
||||
if (result.exitCode === 0) return
|
||||
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
|
||||
if (result.exitCode === 0) return;
|
||||
} catch {
|
||||
// Connection failed, keep trying
|
||||
}
|
||||
await sleep(100)
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`)
|
||||
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an isolated test daemon
|
||||
*/
|
||||
export async function startDaemon(
|
||||
port: number,
|
||||
paseoHome: string
|
||||
): Promise<ProcessPromise> {
|
||||
$.verbose = false
|
||||
const daemon = $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow()
|
||||
return daemon
|
||||
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
|
||||
$.verbose = false;
|
||||
const daemon =
|
||||
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
|
||||
return daemon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full test context with daemon, temp dirs, and helpers
|
||||
*/
|
||||
export async function createTestContext(): Promise<TestContext> {
|
||||
const port = getRandomPort()
|
||||
const { paseoHome, workDir } = await createTempDirs()
|
||||
const port = getRandomPort();
|
||||
const { paseoHome, workDir } = await createTempDirs();
|
||||
|
||||
// Helper to run CLI commands against test daemon
|
||||
const paseo = (args: string[]): ProcessPromise => {
|
||||
$.verbose = false
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow()
|
||||
}
|
||||
$.verbose = false;
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
|
||||
};
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async (): Promise<void> => {
|
||||
if (ctx.daemon) {
|
||||
if (typeof ctx.daemon.pid === 'number') {
|
||||
killPidTree(ctx.daemon.pid, 'SIGTERM')
|
||||
await sleep(250)
|
||||
killPidTree(ctx.daemon.pid, 'SIGKILL')
|
||||
if (typeof ctx.daemon.pid === "number") {
|
||||
killPidTree(ctx.daemon.pid, "SIGTERM");
|
||||
await sleep(250);
|
||||
killPidTree(ctx.daemon.pid, "SIGKILL");
|
||||
} else {
|
||||
ctx.daemon.kill()
|
||||
ctx.daemon.kill();
|
||||
}
|
||||
}
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
await rm(workDir, { recursive: true, force: true })
|
||||
}
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
const ctx: TestContext = {
|
||||
port,
|
||||
@@ -144,9 +142,9 @@ export async function createTestContext(): Promise<TestContext> {
|
||||
daemon: null,
|
||||
paseo,
|
||||
cleanup,
|
||||
}
|
||||
};
|
||||
|
||||
return ctx
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,10 +152,10 @@ export async function createTestContext(): Promise<TestContext> {
|
||||
* Use this for tests that need a running daemon
|
||||
*/
|
||||
export async function createTestContextWithDaemon(): Promise<TestContext> {
|
||||
const ctx = await createTestContext()
|
||||
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome)
|
||||
await waitForDaemon(ctx.port)
|
||||
return ctx
|
||||
const ctx = await createTestContext();
|
||||
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome);
|
||||
await waitForDaemon(ctx.port);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,13 +163,13 @@ export async function createTestContextWithDaemon(): Promise<TestContext> {
|
||||
*/
|
||||
export function registerCleanupHandlers(cleanup: () => Promise<void>): void {
|
||||
const handler = async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
}
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('exit', () => {
|
||||
process.on("exit", () => {
|
||||
// Can't await in exit handler, but at least try to kill daemon
|
||||
})
|
||||
process.on('SIGINT', handler)
|
||||
process.on('SIGTERM', handler)
|
||||
});
|
||||
process.on("SIGINT", handler);
|
||||
process.on("SIGTERM", handler);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { createE2ETestContext } from '/Users/moboudra/.paseo/worktrees/1luy0po7/beefy-parrot/packages/cli/tests/helpers/test-daemon.ts'
|
||||
import { createE2ETestContext } from "/Users/moboudra/.paseo/worktrees/1luy0po7/beefy-parrot/packages/cli/tests/helpers/test-daemon.ts";
|
||||
|
||||
async function main() {
|
||||
const ctx = await createE2ETestContext({ timeout: 180000 })
|
||||
const ctx = await createE2ETestContext({ timeout: 180000 });
|
||||
try {
|
||||
const run = await ctx.paseo([
|
||||
'run',
|
||||
'--provider','opencode',
|
||||
'--mode','default',
|
||||
'--model','opencode/kimi-k2.5-free',
|
||||
'Say hello in one short sentence.'
|
||||
], { timeout: 180000 })
|
||||
console.log('EXIT', run.exitCode)
|
||||
console.log('STDOUT\n' + run.stdout)
|
||||
console.log('STDERR\n' + run.stderr)
|
||||
const run = await ctx.paseo(
|
||||
[
|
||||
"run",
|
||||
"--provider",
|
||||
"opencode",
|
||||
"--mode",
|
||||
"default",
|
||||
"--model",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"Say hello in one short sentence.",
|
||||
],
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
console.log("EXIT", run.exitCode);
|
||||
console.log("STDOUT\n" + run.stdout);
|
||||
console.log("STDERR\n" + run.stderr);
|
||||
} finally {
|
||||
await ctx.stop()
|
||||
await ctx.stop();
|
||||
}
|
||||
}
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
import { createE2ETestContext } from '../helpers/test-daemon.ts'
|
||||
import { createE2ETestContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
const schema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: { summary: { type: 'string' } },
|
||||
required: ['summary'],
|
||||
type: "object",
|
||||
properties: { summary: { type: "string" } },
|
||||
required: ["summary"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const ctx = await createE2ETestContext({ timeout: 180000 })
|
||||
const ctx = await createE2ETestContext({ timeout: 180000 });
|
||||
try {
|
||||
const run = await ctx.paseo([
|
||||
'run',
|
||||
'--provider','opencode',
|
||||
'--mode','default',
|
||||
'--model','opencode/kimi-k2.5-free',
|
||||
'--output-schema', schema,
|
||||
'Return valid JSON with a short summary for provider opencode.'
|
||||
], { timeout: 180000 })
|
||||
console.log('RUN EXIT', run.exitCode)
|
||||
console.log('RUN STDOUT\n' + run.stdout)
|
||||
console.log('RUN STDERR\n' + run.stderr)
|
||||
const run = await ctx.paseo(
|
||||
[
|
||||
"run",
|
||||
"--provider",
|
||||
"opencode",
|
||||
"--mode",
|
||||
"default",
|
||||
"--model",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"--output-schema",
|
||||
schema,
|
||||
"Return valid JSON with a short summary for provider opencode.",
|
||||
],
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
console.log("RUN EXIT", run.exitCode);
|
||||
console.log("RUN STDOUT\n" + run.stdout);
|
||||
console.log("RUN STDERR\n" + run.stderr);
|
||||
|
||||
const ls = await ctx.paseo(['ls','--json'])
|
||||
console.log('LS\n' + ls.stdout)
|
||||
const agents = JSON.parse(ls.stdout)
|
||||
const agentId = agents[0]?.id
|
||||
const ls = await ctx.paseo(["ls", "--json"]);
|
||||
console.log("LS\n" + ls.stdout);
|
||||
const agents = JSON.parse(ls.stdout);
|
||||
const agentId = agents[0]?.id;
|
||||
if (agentId) {
|
||||
const inspect = await ctx.paseo(['inspect', agentId])
|
||||
console.log('INSPECT\n' + inspect.stdout)
|
||||
const logs = await ctx.paseo(['logs','--tail','100', agentId], { timeout: 30000 })
|
||||
console.log('LOGS\n' + logs.stdout)
|
||||
const inspect = await ctx.paseo(["inspect", agentId]);
|
||||
console.log("INSPECT\n" + inspect.stdout);
|
||||
const logs = await ctx.paseo(["logs", "--tail", "100", agentId], { timeout: 30000 });
|
||||
console.log("LOGS\n" + logs.stdout);
|
||||
}
|
||||
} finally {
|
||||
await ctx.stop()
|
||||
await ctx.stop();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user