Update files
This commit is contained in:
@@ -5,6 +5,9 @@ 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'
|
||||
@@ -123,6 +126,33 @@ export function createCli(): Command {
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runWaitCommand))
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(daemonStartCommand())
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show local daemon status (alias for "paseo daemon status")')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.action(withOutput(runDaemonStatusCommand))
|
||||
|
||||
program
|
||||
.command('restart')
|
||||
.description('Restart local daemon (alias for "paseo daemon restart")')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.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(
|
||||
'--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())
|
||||
|
||||
|
||||
@@ -14,23 +14,35 @@ export function createDaemonCommand(): Command {
|
||||
|
||||
daemon
|
||||
.command('status')
|
||||
.description('Show daemon status')
|
||||
.description('Show local daemon status')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.action(withOutput(runStatusCommand))
|
||||
|
||||
daemon
|
||||
.command('stop')
|
||||
.description('Stop the daemon')
|
||||
.description('Stop the local daemon')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.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))
|
||||
|
||||
daemon
|
||||
.command('restart')
|
||||
.description('Restart the daemon')
|
||||
.description('Restart the local daemon')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.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(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
)
|
||||
.action(withOutput(runRestartCommand))
|
||||
|
||||
return daemon
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
|
||||
|
||||
export interface DaemonStartOptions {
|
||||
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
|
||||
sockPath?: string
|
||||
}
|
||||
|
||||
export interface LocalDaemonState {
|
||||
home: string
|
||||
listen: string
|
||||
logPath: string
|
||||
pidPath: string
|
||||
pidInfo: LocalDaemonPidInfo | null
|
||||
running: boolean
|
||||
stalePidFile: boolean
|
||||
}
|
||||
|
||||
export interface DetachedStartResult {
|
||||
pid: number | null
|
||||
logPath: string
|
||||
}
|
||||
|
||||
export interface StopLocalDaemonOptions {
|
||||
home?: string
|
||||
timeoutMs?: number
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface StopLocalDaemonResult {
|
||||
action: 'stopped' | 'not_running'
|
||||
home: string
|
||||
pid: number | null
|
||||
forced: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
type ProcessExitDetails = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
error?: Error
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
export const DEFAULT_STOP_TIMEOUT_MS = 15_000
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false })
|
||||
|
||||
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
||||
exitedEarly: true,
|
||||
...details,
|
||||
})
|
||||
|
||||
function envWithHome(home?: string): NodeJS.ProcessEnv {
|
||||
if (!home) {
|
||||
return process.env
|
||||
}
|
||||
|
||||
return { ...process.env, PASEO_HOME: home }
|
||||
}
|
||||
|
||||
function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
||||
const args: string[] = []
|
||||
if (options.relay === false) {
|
||||
args.push('--no-relay')
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
args.push('--no-mcp')
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function resolveDaemonRunnerEntry(): string {
|
||||
const serverExportPath = require.resolve('@getpaseo/server')
|
||||
let currentDir = path.dirname(serverExportPath)
|
||||
|
||||
while (true) {
|
||||
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')
|
||||
if (existsSync(distRunner)) {
|
||||
return distRunner
|
||||
}
|
||||
return path.join(currentDir, 'scripts', 'daemon-runner.ts')
|
||||
}
|
||||
} catch {
|
||||
// Continue searching up if package.json exists but is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir)
|
||||
if (parentDir === currentDir) {
|
||||
break
|
||||
}
|
||||
currentDir = parentDir
|
||||
}
|
||||
|
||||
throw new Error('Unable to resolve @getpaseo/server package root for daemon runner')
|
||||
}
|
||||
|
||||
function pidFilePath(paseoHome: string): string {
|
||||
return path.join(paseoHome, DAEMON_PID_FILENAME)
|
||||
}
|
||||
|
||||
function 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
|
||||
}
|
||||
|
||||
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,
|
||||
sockPath: typeof parsed.sockPath === 'string' ? parsed.sockPath : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, lines = 30): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
return content.split('\n').filter(Boolean).slice(-lines).join('\n')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (err) {
|
||||
const code = typeof err === 'object' && err && 'code' in err ? (err as { code?: string }).code : undefined
|
||||
if (code === 'EPERM') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (err) {
|
||||
const code = typeof err === 'object' && err && 'code' in err ? (err as { code?: string }).code : undefined
|
||||
if (code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return true
|
||||
}
|
||||
await sleep(PID_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
return !isProcessRunning(pid)
|
||||
}
|
||||
|
||||
export function resolveLocalPaseoHome(home?: string): string {
|
||||
return resolvePaseoHome(envWithHome(home))
|
||||
}
|
||||
|
||||
export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
const normalized = listen.trim()
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/') || normalized.startsWith('unix://')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return `127.0.0.1:${normalized}`
|
||||
}
|
||||
|
||||
if (normalized.includes(':')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...envWithHome(options.home),
|
||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||
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?.sockPath ?? config.listen
|
||||
|
||||
return {
|
||||
home,
|
||||
listen,
|
||||
logPath,
|
||||
pidPath,
|
||||
pidInfo,
|
||||
running,
|
||||
stalePidFile: Boolean(pidInfo) && !running,
|
||||
}
|
||||
}
|
||||
|
||||
export function tailDaemonLog(home?: string, lines = 30): string | null {
|
||||
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME)
|
||||
return tailFile(logPath, lines)
|
||||
}
|
||||
|
||||
export async function startLocalDaemonDetached(
|
||||
options: DaemonStartOptions
|
||||
): Promise<DetachedStartResult> {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error('Cannot use --listen and --port together')
|
||||
}
|
||||
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home
|
||||
}
|
||||
if (options.listen) {
|
||||
childEnv.PASEO_LISTEN = options.listen
|
||||
} else if (options.port) {
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||
}
|
||||
if (options.allowedHosts) {
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
|
||||
}
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv)
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry()
|
||||
const logFd = openSync(logPath, 'a')
|
||||
|
||||
try {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
}
|
||||
)
|
||||
|
||||
child.unref()
|
||||
|
||||
const startup = await new Promise<DetachedStartupResult>((resolve) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (value: DetachedStartupResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS)
|
||||
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish(startupExited({ code: null, signal: null, error }))
|
||||
})
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
finish(startupExited({ code, signal }))
|
||||
})
|
||||
})
|
||||
|
||||
if (startup.exitedEarly) {
|
||||
const reason = startup.error
|
||||
? startup.error.message
|
||||
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
|
||||
const recentLogs = tailFile(logPath)
|
||||
throw new Error(
|
||||
[
|
||||
`Daemon failed to start in background (${reason}).`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid ?? null,
|
||||
logPath,
|
||||
}
|
||||
} finally {
|
||||
closeSync(logFd)
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopLocalDaemon(
|
||||
options: StopLocalDaemonOptions = {}
|
||||
): Promise<StopLocalDaemonResult> {
|
||||
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})`
|
||||
: ''
|
||||
return {
|
||||
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 signaled = signalProcess(pid, 'SIGTERM')
|
||||
if (!signaled) {
|
||||
return {
|
||||
action: 'not_running',
|
||||
home: state.home,
|
||||
pid,
|
||||
forced: false,
|
||||
message: 'Daemon process was already stopped',
|
||||
}
|
||||
}
|
||||
|
||||
let forced = false
|
||||
let stopped = await waitForExit(pid, timeoutMs)
|
||||
|
||||
if (!stopped && options.force) {
|
||||
forced = true
|
||||
const killSent = signalProcess(pid, 'SIGKILL')
|
||||
if (killSent) {
|
||||
stopped = await waitForExit(pid, KILL_TIMEOUT_MS)
|
||||
} else {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!stopped) {
|
||||
throw new Error(
|
||||
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`
|
||||
)
|
||||
}
|
||||
|
||||
rmSync(state.pidPath, { force: true })
|
||||
|
||||
return {
|
||||
action: 'stopped',
|
||||
home: state.home,
|
||||
pid,
|
||||
forced,
|
||||
message: forced ? 'Daemon was force-stopped' : 'Daemon stopped gracefully',
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,122 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import {
|
||||
startLocalDaemonDetached,
|
||||
stopLocalDaemon,
|
||||
DEFAULT_STOP_TIMEOUT_MS,
|
||||
type DaemonStartOptions,
|
||||
} from './local-daemon.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
|
||||
/** Result of restart command */
|
||||
interface RestartResult {
|
||||
action: 'restarted' | 'not_running'
|
||||
host: string
|
||||
action: 'restarted'
|
||||
home: string
|
||||
pid: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Schema for restart result */
|
||||
const restartResultSchema: OutputSchema<RestartResult> = {
|
||||
idField: 'action',
|
||||
columns: [
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'action',
|
||||
color: (value) => (value === 'restarted' ? 'green' : 'red'),
|
||||
color: () => 'green',
|
||||
},
|
||||
{ header: 'HOST', field: 'host' },
|
||||
{ header: 'HOME', field: 'home' },
|
||||
{ header: 'PID', field: 'pid' },
|
||||
{ header: 'MESSAGE', field: 'message' },
|
||||
],
|
||||
}
|
||||
|
||||
export type RestartCommandResult = SingleResult<RestartResult>
|
||||
|
||||
export async function runRestartCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
): Promise<RestartCommandResult> {
|
||||
const connectOptions = { host: options.host as string | undefined }
|
||||
const host = getDaemonHost(connectOptions)
|
||||
function parseTimeoutMs(raw: unknown): number {
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS
|
||||
}
|
||||
|
||||
let client
|
||||
try {
|
||||
client = await connectToDaemon(connectOptions)
|
||||
} catch {
|
||||
// Daemon not running - cannot restart
|
||||
const seconds = Number(raw)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
message: `Daemon is not running (tried to connect to ${host})`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
code: 'INVALID_TIMEOUT',
|
||||
message: `Invalid timeout value: ${raw}`,
|
||||
details: 'Timeout must be a positive number of seconds',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
// Request server restart
|
||||
await client.restartServer('cli_restart')
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
|
||||
await client.close()
|
||||
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,
|
||||
}
|
||||
|
||||
if (startOptions.listen && startOptions.port) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_OPTIONS',
|
||||
message: 'Cannot use --listen and --port together',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return startOptions
|
||||
}
|
||||
|
||||
export async function runRestartCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
): Promise<RestartCommandResult> {
|
||||
const timeoutMs = parseTimeoutMs(options.timeout)
|
||||
const force = options.force === true
|
||||
const startOptions = toStartOptions(options)
|
||||
|
||||
try {
|
||||
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')
|
||||
if (!force && isTimeout) {
|
||||
stopResult = await stopLocalDaemon({
|
||||
home: startOptions.home,
|
||||
timeoutMs,
|
||||
force: true,
|
||||
})
|
||||
} else {
|
||||
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}`
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
action: 'restarted',
|
||||
host,
|
||||
message: 'Daemon restart requested',
|
||||
home: stopResult.home,
|
||||
pid: startup.pid === null ? '-' : String(startup.pid),
|
||||
message: `Local daemon restarted (${before} -> ${after})`,
|
||||
},
|
||||
schema: restartResultSchema,
|
||||
}
|
||||
} 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')) {
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
action: 'restarted',
|
||||
host,
|
||||
message: 'Daemon is restarting',
|
||||
},
|
||||
schema: restartResultSchema,
|
||||
}
|
||||
}
|
||||
|
||||
const error: CommandError = {
|
||||
code: 'RESTART_FAILED',
|
||||
message: `Failed to restart daemon: ${message}`,
|
||||
message: `Failed to restart local daemon: ${message}`,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { closeSync, openSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
createPaseoDaemon,
|
||||
loadConfig,
|
||||
@@ -11,36 +8,16 @@ import {
|
||||
loadPersistedConfig,
|
||||
} from '@getpaseo/server'
|
||||
import type { CliConfigOverrides } from '@getpaseo/server'
|
||||
import {
|
||||
startLocalDaemonDetached,
|
||||
type DaemonStartOptions as StartOptions,
|
||||
} from './local-daemon.js'
|
||||
|
||||
interface StartOptions {
|
||||
port?: string
|
||||
listen?: string
|
||||
home?: string
|
||||
foreground?: boolean
|
||||
relay?: boolean
|
||||
mcp?: boolean
|
||||
allowedHosts?: string
|
||||
}
|
||||
|
||||
interface DetachedStartupReady {
|
||||
exitedEarly: false
|
||||
}
|
||||
|
||||
interface DetachedStartupExited {
|
||||
exitedEarly: true
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
error?: Error
|
||||
}
|
||||
|
||||
type DetachedStartupResult = DetachedStartupReady | DetachedStartupExited
|
||||
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200
|
||||
const DAEMON_LOG_FILENAME = 'daemon.log'
|
||||
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
|
||||
|
||||
export function startCommand(): Command {
|
||||
return new Command('start')
|
||||
.description('Start the Paseo daemon')
|
||||
.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)')
|
||||
@@ -49,148 +26,14 @@ export function startCommand(): Command {
|
||||
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
|
||||
.option(
|
||||
'--allowed-hosts <hosts>',
|
||||
'Comma-separated list of allowed Host header values (Vite-style; e.g., "localhost,.example.com" or "true")'
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||
)
|
||||
.action(async (options: StartOptions) => {
|
||||
await runStart(options)
|
||||
})
|
||||
}
|
||||
|
||||
function buildForegroundArgs(options: StartOptions): string[] {
|
||||
const args = ['daemon', 'start', '--foreground']
|
||||
|
||||
if (options.listen) {
|
||||
args.push('--listen', options.listen)
|
||||
} else if (options.port) {
|
||||
args.push('--port', options.port)
|
||||
}
|
||||
|
||||
if (options.home) {
|
||||
args.push('--home', options.home)
|
||||
}
|
||||
|
||||
if (options.relay === false) {
|
||||
args.push('--no-relay')
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
args.push('--no-mcp')
|
||||
}
|
||||
|
||||
if (options.allowedHosts) {
|
||||
args.push('--allowed-hosts', options.allowedHosts)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, lines = 30): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
return content.split('\n').filter(Boolean).slice(-lines).join('\n')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function runDetachedStart(options: StartOptions): Promise<void> {
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home
|
||||
}
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv)
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
||||
|
||||
const cliEntry = process.argv[1]
|
||||
if (!cliEntry) {
|
||||
throw new Error('Unable to determine CLI entrypoint for detached daemon start')
|
||||
}
|
||||
|
||||
const logFd = openSync(logPath, 'a')
|
||||
|
||||
try {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, cliEntry, ...buildForegroundArgs(options)],
|
||||
{
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
}
|
||||
)
|
||||
|
||||
child.unref()
|
||||
|
||||
const startup = await new Promise<DetachedStartupResult>((resolve) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (value: DetachedStartupResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS)
|
||||
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish({ exitedEarly: true, code: null, signal: null, error })
|
||||
})
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
finish({ exitedEarly: true, code, signal })
|
||||
})
|
||||
})
|
||||
|
||||
if (startup.exitedEarly) {
|
||||
const reason = startup.error
|
||||
? startup.error.message
|
||||
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
|
||||
const recentLogs = tailFile(logPath)
|
||||
throw new Error(
|
||||
[
|
||||
`Daemon failed to start in background (${reason}).`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
)
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Daemon starting in background (PID ${child.pid ?? 'unknown'}).`))
|
||||
console.log(chalk.dim(`Logs: ${logPath}`))
|
||||
} finally {
|
||||
closeSync(logFd)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStart(options: StartOptions): Promise<void> {
|
||||
if (options.listen && options.port) {
|
||||
console.error(chalk.red('Cannot use --listen and --port together'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!options.foreground) {
|
||||
try {
|
||||
await runDetachedStart(options)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(message))
|
||||
process.exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set environment variables based on CLI options
|
||||
if (options.home) {
|
||||
process.env.PASEO_HOME = options.home
|
||||
}
|
||||
|
||||
let paseoHome: string
|
||||
let logger: ReturnType<typeof createRootLogger>
|
||||
let config: ReturnType<typeof loadConfig>
|
||||
function toCliOverrides(options: StartOptions): CliConfigOverrides {
|
||||
const cliOverrides: CliConfigOverrides = {}
|
||||
|
||||
if (options.listen) {
|
||||
@@ -215,11 +58,41 @@ async function runStart(options: StartOptions): Promise<void> {
|
||||
cliOverrides.mcpEnabled = false
|
||||
}
|
||||
|
||||
return cliOverrides
|
||||
}
|
||||
|
||||
export async function runStart(options: StartOptions): Promise<void> {
|
||||
if (options.listen && options.port) {
|
||||
console.error(chalk.red('Cannot use --listen and --port together'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!options.foreground) {
|
||||
try {
|
||||
const startup = await startLocalDaemonDetached(options)
|
||||
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? 'unknown'}).`))
|
||||
console.log(chalk.dim(`Logs: ${startup.logPath}`))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(message))
|
||||
process.exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (options.home) {
|
||||
process.env.PASEO_HOME = options.home
|
||||
}
|
||||
|
||||
let paseoHome: string
|
||||
let logger: ReturnType<typeof createRootLogger>
|
||||
let config: ReturnType<typeof loadConfig>
|
||||
|
||||
try {
|
||||
paseoHome = resolvePaseoHome()
|
||||
const persistedConfig = loadPersistedConfig(paseoHome)
|
||||
logger = createRootLogger(persistedConfig)
|
||||
config = loadConfig(paseoHome, { cli: cliOverrides })
|
||||
config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(message))
|
||||
@@ -235,7 +108,6 @@ async function runStart(options: StartOptions): Promise<void> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
let shuttingDown = false
|
||||
const handleShutdown = async (signal: string) => {
|
||||
if (shuttingDown) {
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import type { Command } from 'commander'
|
||||
import { resolvePaseoHome } from '@getpaseo/server'
|
||||
import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import { tryConnectToDaemon } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
|
||||
|
||||
/** Status data for the daemon */
|
||||
interface DaemonStatus {
|
||||
status: 'running' | 'stopped'
|
||||
host: string
|
||||
status: 'running' | 'stopped' | 'unresponsive'
|
||||
home: string
|
||||
runningAgents: number
|
||||
idleAgents: number
|
||||
listen: string
|
||||
pid: number | null
|
||||
startedAt: string | null
|
||||
owner: string | null
|
||||
logPath: string
|
||||
runningAgents: number | null
|
||||
idleAgents: number | null
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** Key-value row for table display */
|
||||
interface StatusRow {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Schema for key-value display with custom serialization for JSON/YAML */
|
||||
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
return {
|
||||
idField: 'key',
|
||||
@@ -28,28 +30,60 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
header: 'VALUE',
|
||||
field: 'value',
|
||||
color: (_, item) => {
|
||||
if (item.key === 'Status') {
|
||||
return item.value === 'running' ? 'green' : 'red'
|
||||
if (item.key !== 'Status') {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
if (item.value === 'running') {
|
||||
return 'green'
|
||||
}
|
||||
if (item.value === 'unresponsive') {
|
||||
return 'yellow'
|
||||
}
|
||||
return 'red'
|
||||
},
|
||||
},
|
||||
],
|
||||
// For JSON/YAML, return the structured status object (not key-value rows)
|
||||
// The serializer receives each item, but we want the whole object
|
||||
// So we return null for individual items and handle it at the result level
|
||||
serialize: (_item) => status,
|
||||
serialize: () => status,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert status to key-value rows for table display */
|
||||
function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
return [
|
||||
const rows: StatusRow[] = [
|
||||
{ key: 'Status', value: status.status },
|
||||
{ key: 'Host', value: status.host },
|
||||
{ key: 'Home', value: status.home },
|
||||
{ key: 'Agents', value: `${status.runningAgents} running, ${status.idleAgents} idle` },
|
||||
{ key: 'Listen', value: status.listen },
|
||||
{ key: 'PID', value: status.pid === null ? '-' : String(status.pid) },
|
||||
{ key: 'Started', value: status.startedAt ?? '-' },
|
||||
{ key: 'Owner', value: status.owner ?? '-' },
|
||||
{ key: 'Logs', value: status.logPath },
|
||||
]
|
||||
|
||||
if (status.runningAgents !== null && status.idleAgents !== null) {
|
||||
rows.push({
|
||||
key: 'Agents',
|
||||
value: `${status.runningAgents} running, ${status.idleAgents} idle`,
|
||||
})
|
||||
} else {
|
||||
rows.push({
|
||||
key: 'Agents',
|
||||
value: 'Unavailable (daemon API not reachable)',
|
||||
})
|
||||
}
|
||||
|
||||
if (status.note) {
|
||||
rows.push({ key: 'Note', value: status.note })
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
|
||||
if (uid === undefined && !hostname) {
|
||||
return null
|
||||
}
|
||||
const uidPart = uid === undefined ? '?' : String(uid)
|
||||
const hostPart = hostname ?? 'unknown-host'
|
||||
return `${uidPart}@${hostPart}`
|
||||
}
|
||||
|
||||
export type StatusResult = ListResult<StatusRow>
|
||||
@@ -58,49 +92,59 @@ export async function runStatusCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
): Promise<StatusResult> {
|
||||
const connectOptions = { host: options.host as string | undefined }
|
||||
const host = getDaemonHost(connectOptions)
|
||||
const client = await tryConnectToDaemon(connectOptions)
|
||||
const home = typeof options.home === 'string' ? options.home : undefined
|
||||
const state = resolveLocalDaemonState({ home })
|
||||
|
||||
if (!client) {
|
||||
const error: CommandError = {
|
||||
code: 'DAEMON_NOT_RUNNING',
|
||||
message: `Daemon is not running (tried to connect to ${host})`,
|
||||
details: 'Start the daemon with: paseo daemon start',
|
||||
}
|
||||
throw error
|
||||
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
|
||||
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}`
|
||||
}
|
||||
|
||||
try {
|
||||
const agents = await client.fetchAgents()
|
||||
const runningAgents = agents.filter((a) => a.status === 'running')
|
||||
const idleAgents = agents.filter((a) => a.status === 'idle')
|
||||
|
||||
// Get paseo home for display
|
||||
const paseoHome = resolvePaseoHome()
|
||||
|
||||
const status: DaemonStatus = {
|
||||
status: 'running',
|
||||
host,
|
||||
home: paseoHome,
|
||||
runningAgents: runningAgents.length,
|
||||
idleAgents: idleAgents.length,
|
||||
if (state.running) {
|
||||
const host = resolveTcpHostFromListen(state.listen)
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 })
|
||||
if (client) {
|
||||
try {
|
||||
const agents = await client.fetchAgents()
|
||||
runningAgents = agents.filter(a => a.status === 'running').length
|
||||
idleAgents = agents.filter(a => a.status === 'idle').length
|
||||
} catch {
|
||||
status = 'unresponsive'
|
||||
note = `Daemon PID is running but API requests to ${host} failed`
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
}
|
||||
} else {
|
||||
status = 'unresponsive'
|
||||
note = `Daemon PID is running but websocket at ${host} is not reachable`
|
||||
}
|
||||
} else {
|
||||
note = 'Daemon is configured for unix socket listen; API probe skipped'
|
||||
}
|
||||
}
|
||||
|
||||
await client.close()
|
||||
const daemonStatus: DaemonStatus = {
|
||||
status,
|
||||
home: state.home,
|
||||
listen: state.listen,
|
||||
pid: state.pidInfo?.pid ?? null,
|
||||
startedAt: state.pidInfo?.startedAt ?? null,
|
||||
owner,
|
||||
logPath: state.logPath,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
note,
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
data: toStatusRows(status),
|
||||
schema: createStatusSchema(status),
|
||||
}
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'STATUS_FAILED',
|
||||
message: `Failed to get status: ${message}`,
|
||||
}
|
||||
throw error
|
||||
return {
|
||||
type: 'list',
|
||||
data: toStatusRows(daemonStatus),
|
||||
schema: createStatusSchema(daemonStatus),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { Command } from 'commander'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from './local-daemon.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
|
||||
/** Result of stop command */
|
||||
interface StopResult {
|
||||
action: 'stopped' | 'not_running'
|
||||
host: string
|
||||
home: string
|
||||
pid: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Schema for stop result */
|
||||
const stopResultSchema: OutputSchema<StopResult> = {
|
||||
idField: 'action',
|
||||
columns: [
|
||||
@@ -18,75 +17,57 @@ const stopResultSchema: OutputSchema<StopResult> = {
|
||||
field: 'action',
|
||||
color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
|
||||
},
|
||||
{ header: 'HOST', field: 'host' },
|
||||
{ header: 'HOME', field: 'home' },
|
||||
{ header: 'PID', field: 'pid' },
|
||||
{ header: 'MESSAGE', field: 'message' },
|
||||
],
|
||||
}
|
||||
|
||||
export type StopCommandResult = SingleResult<StopResult>
|
||||
|
||||
function parseTimeoutMs(raw: unknown): number {
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS
|
||||
}
|
||||
|
||||
const seconds = Number(raw)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_TIMEOUT',
|
||||
message: `Invalid timeout value: ${raw}`,
|
||||
details: 'Timeout must be a positive number of seconds',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
|
||||
export async function runStopCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command
|
||||
): Promise<StopCommandResult> {
|
||||
const connectOptions = { host: options.host as string | undefined }
|
||||
const host = getDaemonHost(connectOptions)
|
||||
const home = typeof options.home === 'string' ? options.home : undefined
|
||||
const force = options.force === true
|
||||
const timeoutMs = parseTimeoutMs(options.timeout)
|
||||
|
||||
let client
|
||||
try {
|
||||
client = await connectToDaemon(connectOptions)
|
||||
} catch {
|
||||
// Daemon not running - this is a valid outcome
|
||||
const result = await stopLocalDaemon({ home, force, timeoutMs })
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
action: 'not_running',
|
||||
host,
|
||||
message: 'Daemon was not running',
|
||||
},
|
||||
schema: stopResultSchema,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 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))
|
||||
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
action: 'stopped',
|
||||
host,
|
||||
message: 'Daemon stop requested - shutting down gracefully',
|
||||
action: result.action,
|
||||
home: result.home,
|
||||
pid: result.pid === null ? '-' : String(result.pid),
|
||||
message: result.message,
|
||||
},
|
||||
schema: stopResultSchema,
|
||||
}
|
||||
} 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')) {
|
||||
return {
|
||||
type: 'single',
|
||||
data: {
|
||||
action: 'stopped',
|
||||
host,
|
||||
message: 'Daemon is stopping',
|
||||
},
|
||||
schema: stopResultSchema,
|
||||
}
|
||||
}
|
||||
|
||||
const error: CommandError = {
|
||||
code: 'STOP_FAILED',
|
||||
message: `Failed to stop daemon: ${message}`,
|
||||
message: `Failed to stop local daemon: ${message}`,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user