Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user