test(cli): add zx TDD infrastructure and phase 1 tests
- Install zx as devDependency for E2E testing - Create tests/ directory with setup utilities - Add setup.ts with test context helpers: - Random port generation (never 6767) - Temp directory creation for PASEO_HOME and workDir - Daemon startup/waitForDaemon helper (WebSocket only) - Cleanup function - paseo() helper to run CLI commands against test daemon - Add Phase 1 foundation tests (01-foundation.test.mts): - Test paseo --version outputs version - Test paseo --help shows commands - Add test runner (run-all.mts) that discovers and runs all tests - Add test:e2e script to package.json
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Command } from 'commander'
|
||||
import { createDaemonCommand } from './commands/daemon/index.js'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
|
||||
@@ -10,7 +11,7 @@ export function createCli(): Command {
|
||||
.description('Paseo CLI - control your AI coding agents from the command line')
|
||||
.version(VERSION, '-v, --version', 'output the version number')
|
||||
|
||||
// Placeholder subcommands for Phase 1
|
||||
// Placeholder subcommands
|
||||
program
|
||||
.command('agent')
|
||||
.description('Manage agents')
|
||||
@@ -18,12 +19,8 @@ export function createCli(): Command {
|
||||
console.log('agent command (not yet implemented)')
|
||||
})
|
||||
|
||||
program
|
||||
.command('daemon')
|
||||
.description('Manage the Paseo daemon')
|
||||
.action(() => {
|
||||
console.log('daemon command (not yet implemented)')
|
||||
})
|
||||
// Real daemon command
|
||||
program.addCommand(createDaemonCommand())
|
||||
|
||||
program
|
||||
.command('permit')
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Command } from 'commander'
|
||||
import { startCommand } from './start.js'
|
||||
import { statusCommand } from './status.js'
|
||||
import { stopCommand } from './stop.js'
|
||||
import { restartCommand } from './restart.js'
|
||||
|
||||
export function createDaemonCommand(): Command {
|
||||
const daemon = new Command('daemon')
|
||||
.description('Manage the Paseo daemon')
|
||||
|
||||
daemon.addCommand(startCommand())
|
||||
daemon.addCommand(statusCommand())
|
||||
daemon.addCommand(stopCommand())
|
||||
daemon.addCommand(restartCommand())
|
||||
|
||||
return daemon
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
|
||||
export function restartCommand(): Command {
|
||||
return new Command('restart')
|
||||
.description('Restart the daemon')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(async (options: { host?: string }) => {
|
||||
await runRestart(options)
|
||||
})
|
||||
}
|
||||
|
||||
async function runRestart(options: { host?: string }): Promise<void> {
|
||||
const host = getDaemonHost(options)
|
||||
|
||||
let client
|
||||
try {
|
||||
client = await connectToDaemon(options)
|
||||
} catch {
|
||||
console.log(chalk.yellow('Daemon is not running'))
|
||||
console.log(chalk.dim(`Tried to connect to ${host}`))
|
||||
console.log()
|
||||
console.log(chalk.dim('Start the daemon with:'))
|
||||
console.log(chalk.dim(' paseo daemon start'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(chalk.dim('Restarting daemon...'))
|
||||
|
||||
// Request server restart
|
||||
await client.restartServer('cli_restart')
|
||||
|
||||
console.log(chalk.green('Daemon restart requested'))
|
||||
|
||||
await client.close()
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// If connection was closed, the daemon is restarting
|
||||
if (message.includes('closed') || message.includes('disconnected')) {
|
||||
console.log(chalk.green('Daemon is restarting'))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error(chalk.red(`Failed to restart daemon: ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import {
|
||||
createPaseoDaemon,
|
||||
loadConfig,
|
||||
resolvePaseoHome,
|
||||
createRootLogger,
|
||||
loadPersistedConfig,
|
||||
} from '@paseo/server'
|
||||
|
||||
interface StartOptions {
|
||||
port?: string
|
||||
home?: string
|
||||
foreground?: boolean
|
||||
noRelay?: boolean
|
||||
}
|
||||
|
||||
export function startCommand(): Command {
|
||||
return new Command('start')
|
||||
.description('Start the Paseo daemon')
|
||||
.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')
|
||||
.action(async (options: StartOptions) => {
|
||||
await runStart(options)
|
||||
})
|
||||
}
|
||||
|
||||
async function runStart(options: StartOptions): Promise<void> {
|
||||
// Set environment variables based on CLI options
|
||||
if (options.home) {
|
||||
process.env.PASEO_HOME = options.home
|
||||
}
|
||||
if (options.port) {
|
||||
process.env.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||
}
|
||||
|
||||
const paseoHome = resolvePaseoHome()
|
||||
const persistedConfig = loadPersistedConfig(paseoHome)
|
||||
const logger = createRootLogger(persistedConfig)
|
||||
const config = loadConfig(paseoHome)
|
||||
|
||||
// Apply CLI overrides
|
||||
if (options.noRelay) {
|
||||
config.relayEnabled = false
|
||||
}
|
||||
|
||||
// For now, only foreground mode is supported
|
||||
// TODO: Implement daemonization in a future phase
|
||||
if (!options.foreground) {
|
||||
console.log(chalk.yellow('Note: Background daemon mode not yet implemented. Running in foreground.'))
|
||||
}
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger)
|
||||
|
||||
// Handle graceful shutdown
|
||||
let shuttingDown = false
|
||||
const handleShutdown = async (signal: string) => {
|
||||
if (shuttingDown) {
|
||||
logger.info('Forcing exit...')
|
||||
process.exit(1)
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
|
||||
|
||||
const forceExit = setTimeout(() => {
|
||||
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
|
||||
process.exit(1)
|
||||
}, 10000)
|
||||
|
||||
try {
|
||||
await daemon.stop()
|
||||
clearTimeout(forceExit)
|
||||
logger.info('Server closed')
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
clearTimeout(forceExit)
|
||||
logger.error({ err }, 'Shutdown failed')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => handleShutdown('SIGINT'))
|
||||
|
||||
try {
|
||||
await daemon.start()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(`Failed to start daemon: ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { resolvePaseoHome } from '@paseo/server'
|
||||
import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
|
||||
export function statusCommand(): Command {
|
||||
return new Command('status')
|
||||
.description('Show daemon status')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(async (options: { host?: string }) => {
|
||||
await runStatus(options)
|
||||
})
|
||||
}
|
||||
|
||||
async function runStatus(options: { host?: string }): Promise<void> {
|
||||
const host = getDaemonHost(options)
|
||||
const client = await tryConnectToDaemon(options)
|
||||
|
||||
if (!client) {
|
||||
console.log(chalk.red('Status:'), 'not running')
|
||||
console.log(chalk.dim(`Tried to connect to ${host}`))
|
||||
console.log()
|
||||
console.log(chalk.dim('Start the daemon with:'))
|
||||
console.log(chalk.dim(' paseo daemon start'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
// Request session state to get agent information
|
||||
client.requestSessionState()
|
||||
|
||||
// Wait a moment for the session state to be populated
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const agents = client.listAgents()
|
||||
const runningAgents = agents.filter(a => a.status === 'running')
|
||||
const idleAgents = agents.filter(a => a.status === 'idle')
|
||||
|
||||
// Get paseo home for display
|
||||
const paseoHome = resolvePaseoHome()
|
||||
|
||||
console.log(chalk.green('Status:'), 'running')
|
||||
console.log(chalk.dim('Host:'), host)
|
||||
console.log(chalk.dim('Home:'), paseoHome)
|
||||
console.log(chalk.dim('Agents:'), `${runningAgents.length} running, ${idleAgents.length} idle`)
|
||||
|
||||
await client.close()
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(`Failed to get status: ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
|
||||
export function stopCommand(): Command {
|
||||
return new Command('stop')
|
||||
.description('Stop the daemon')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(async (options: { host?: string }) => {
|
||||
await runStop(options)
|
||||
})
|
||||
}
|
||||
|
||||
async function runStop(options: { host?: string }): Promise<void> {
|
||||
const host = getDaemonHost(options)
|
||||
|
||||
let client
|
||||
try {
|
||||
client = await connectToDaemon(options)
|
||||
} catch {
|
||||
console.log(chalk.yellow('Daemon is not running'))
|
||||
console.log(chalk.dim(`Tried to connect to ${host}`))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(chalk.dim('Stopping daemon...'))
|
||||
|
||||
// Request server restart with "shutdown" reason
|
||||
// This signals the daemon to shut down gracefully
|
||||
await client.restartServer('cli_shutdown')
|
||||
|
||||
// Give the daemon a moment to acknowledge
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
console.log(chalk.green('Daemon stop requested'))
|
||||
console.log(chalk.dim('The daemon will shut down gracefully'))
|
||||
|
||||
await client.close()
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// If connection was closed, the daemon is stopping
|
||||
if (message.includes('closed') || message.includes('disconnected')) {
|
||||
console.log(chalk.green('Daemon is stopping'))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error(chalk.red(`Failed to stop daemon: ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DaemonClientV2 } from '@paseo/server'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = 'localhost:6767'
|
||||
const DEFAULT_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* Get the daemon host from environment or options
|
||||
*/
|
||||
export function getDaemonHost(options?: ConnectOptions): string {
|
||||
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
function createNodeWebSocketFactory() {
|
||||
return (url: string, options?: { headers?: Record<string, string> }) => {
|
||||
return new WebSocket(url, { headers: options?.headers }) as unknown as {
|
||||
readyState: number
|
||||
send: (data: string) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => void
|
||||
off: (event: string, listener: (...args: unknown[]) => void) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and connect a daemon client
|
||||
* Returns the connected client or throws if connection fails
|
||||
*/
|
||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> {
|
||||
const host = getDaemonHost(options)
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||
const url = `ws://${host}/ws`
|
||||
|
||||
const client = new DaemonClientV2({
|
||||
url,
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
|
||||
// Connect with timeout
|
||||
const connectPromise = client.connect()
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Connection timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.race([connectPromise, timeoutPromise])
|
||||
return client
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to connect to the daemon, returns null if connection fails
|
||||
*/
|
||||
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> {
|
||||
try {
|
||||
return await connectToDaemon(options)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user