mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(daemon): keep lifecycle operations on the selected instance (#4575)
* fix(daemon): keep lifecycle operations on the selected instance Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop. * test(daemon): verify bounded desktop readiness and cancellable artifacts * test(cli): verify reload target hints and durable offline pairing * test(daemon): align lifecycle fixtures with ownership and targeting * fix(daemon): preserve reachability when status details time out Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome. Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows. * test(daemon): observe restart through the worker status RPC The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
This commit is contained in:
@@ -201,12 +201,35 @@ jobs:
|
||||
|
||||
- name: Install virtual display
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Verify Windows daemon process control
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home"
|
||||
|
||||
- name: Run desktop lifecycle E2E (Linux)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle
|
||||
|
||||
- name: Upload desktop lifecycle screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() && runner.os == 'Linux' }}
|
||||
with:
|
||||
name: desktop-lifecycle
|
||||
path: ${{ runner.temp }}/desktop-lifecycle
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Run desktop lifecycle E2E (Windows)
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Run desktop renderer E2E
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
|
||||
|
||||
@@ -145,7 +145,7 @@ traffic. Workspace assignments stay on the workspace directory sequence.
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
@@ -429,12 +429,16 @@ $PASEO_HOME/
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
├── paseo.pid # Supervisor identity and published bound endpoint
|
||||
└── daemon.log # Daemon trace logs (rotated)
|
||||
```
|
||||
|
||||
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
|
||||
|
||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
|
||||
2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
+5
-4
@@ -444,16 +444,17 @@ so the port may already be bound. `portScript` takes precedence when both values
|
||||
|
||||
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
|
||||
|
||||
Enable it for a running daemon with:
|
||||
Enable it in persistent configuration:
|
||||
|
||||
```bash
|
||||
paseo daemon start --web-ui
|
||||
paseo daemon config set features.webUi.enabled true
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||
PASEO_WEB_UI_ENABLED=true paseo daemon run
|
||||
```
|
||||
|
||||
Or persist it in `config.json`:
|
||||
@@ -564,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a
|
||||
```
|
||||
|
||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
||||
`--host` overrides the environment variable.
|
||||
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ the web UI. The served app receives a same-origin connection hint and connects
|
||||
back to that daemon. Static UI files load without daemon auth; API and
|
||||
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
||||
|
||||
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
openDaemonOverview,
|
||||
restartDaemonInSettings,
|
||||
expectDaemonRestartComplete,
|
||||
readWorkerPid,
|
||||
} from "../support/helpers/daemon-lifecycle";
|
||||
|
||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
try {
|
||||
const supervisor = daemon.getPid();
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
expect(daemon.getPid()).toBe(supervisor);
|
||||
await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true });
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,8 @@ export interface WaitForServerOptions {
|
||||
type ServerProbe = (host: string, port: number) => Promise<void>;
|
||||
|
||||
const RESERVED_LOCAL_PORTS = new Set([
|
||||
6767, // Developer daemon.
|
||||
6767, // Installed daemon.
|
||||
6768, // Developer daemon.
|
||||
61680, // OpenCode's default local server.
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { openSettings } from "./app";
|
||||
import { openHostSection, openSettingsHost, seedSavedSettingsHosts } from "./settings";
|
||||
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
|
||||
export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon) {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: daemon.serverId, label: "Lifecycle host", endpoint: `127.0.0.1:${daemon.port}` },
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, daemon.serverId);
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
}
|
||||
|
||||
export async function restartDaemonInSettings(page: Page) {
|
||||
const button = page.getByRole("button", { name: "Restart", exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await button.click();
|
||||
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectDaemonRestartComplete(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: daemon.port,
|
||||
clientIdPrefix: "lifecycle-observer",
|
||||
});
|
||||
try {
|
||||
return (await client.getDaemonStatus()).pid;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export async function startIsolatedHostDaemon(
|
||||
): Promise<IsolatedHostDaemon> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -59,23 +59,37 @@ export async function startPackagedWebDaemon(input: {
|
||||
const serverId = `relay-deployment-${Date.now().toString(36)}`;
|
||||
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CI: "true",
|
||||
NODE_ENV: "development",
|
||||
PASEO_NODE_ENV: "development",
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
|
||||
PASEO_RELAY_USE_TLS: "false",
|
||||
PASEO_RELAY_PUBLIC_USE_TLS: "false",
|
||||
};
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
paseo,
|
||||
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
|
||||
{ env },
|
||||
await writeFile(
|
||||
path.join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
daemon: {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
relay: {
|
||||
enabled: true,
|
||||
endpoint: input.relayEndpoint,
|
||||
publicEndpoint: input.relayEndpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
webUi: { enabled: true },
|
||||
dictation: { enabled: false },
|
||||
voiceMode: { enabled: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await execFileAsync(paseo, ["daemon", "start", "--home", home], { env });
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
await waitForWebUi(origin, home);
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
shouldUseDesktopDaemon,
|
||||
confirmAndStopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
@@ -330,6 +334,17 @@ export function LocalDaemonSection() {
|
||||
const daemonStatus = data?.status ?? null;
|
||||
const daemonLogs = data?.logs ?? null;
|
||||
const daemonVersion = daemonStatus?.version ?? null;
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const handleStop = useCallback(() => {
|
||||
setIsStopping(true);
|
||||
void confirmAndStopDesktopDaemon()
|
||||
.then((status) => {
|
||||
if (status) setStatus(status);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => Alert.alert(t("desktop.daemon.lifecycle.stopFailed"), String(error)))
|
||||
.finally(() => setIsStopping(false));
|
||||
}, [setStatus, t]);
|
||||
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
const daemonStatusStateText =
|
||||
@@ -445,6 +460,26 @@ export function LocalDaemonSection() {
|
||||
isLoadingCliStatus={isLoadingCliStatus}
|
||||
/>
|
||||
|
||||
{daemonStatus?.pid ? (
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{daemonStatus.ownedByDesktop
|
||||
? t("desktop.daemon.lifecycle.owned")
|
||||
: t("desktop.daemon.lifecycle.attached")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonStatus.home}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={handleStop} disabled={isStopping}>
|
||||
{isStopping
|
||||
? t("desktop.daemon.lifecycle.stopping")
|
||||
: t("desktop.daemon.lifecycle.stop")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>{t("desktop.daemon.versionMismatch")}</Text>
|
||||
|
||||
@@ -11,10 +11,16 @@ const runningManagedStatus: DesktopDaemonStatus = {
|
||||
home: "/tmp/paseo",
|
||||
version: "1.0.0",
|
||||
desktopManaged: true,
|
||||
ownedByDesktop: true,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
error: null,
|
||||
};
|
||||
|
||||
const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" };
|
||||
const stoppedStatus: DesktopDaemonStatus = {
|
||||
...runningManagedStatus,
|
||||
status: "stopped",
|
||||
ownedByDesktop: false,
|
||||
};
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
confirm?: () => Promise<boolean>;
|
||||
@@ -128,9 +134,9 @@ describe("executeDaemonManagementToggle", () => {
|
||||
expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus });
|
||||
});
|
||||
|
||||
it("skips stop when daemon is running but not desktop-managed", async () => {
|
||||
it("skips stop for an attached daemon even with a legacy desktop-managed flag", async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false };
|
||||
const manuallyManagedStatus = { ...runningManagedStatus, ownedByDesktop: false };
|
||||
|
||||
const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export type DaemonManagementToggleResult =
|
||||
|
||||
export async function executeDaemonManagementToggle(
|
||||
currentlyManaging: boolean,
|
||||
daemonStatus: Pick<DesktopDaemonStatus, "status" | "desktopManaged"> | null,
|
||||
daemonStatus: Pick<DesktopDaemonStatus, "status" | "ownedByDesktop"> | null,
|
||||
deps: DaemonManagementToggleDeps,
|
||||
): Promise<DaemonManagementToggleResult> {
|
||||
if (!currentlyManaging) {
|
||||
@@ -32,7 +32,7 @@ export async function executeDaemonManagementToggle(
|
||||
// state reflects what was actually applied if the stop fails.
|
||||
await deps.persistSettings({ manageBuiltInDaemon: false });
|
||||
|
||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||
if (daemonStatus?.ownedByDesktop && ["running", "starting"].includes(daemonStatus.status)) {
|
||||
const newStatus = await deps.stopDaemon();
|
||||
return { kind: "disabled", newStatus };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
@@ -21,6 +23,8 @@ export interface DesktopDaemonStatus {
|
||||
home: string;
|
||||
version: string | null;
|
||||
desktopManaged: boolean;
|
||||
ownedByDesktop: boolean;
|
||||
startedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -128,6 +132,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
||||
home: toStringOrNull(raw.home) ?? "",
|
||||
version: toStringOrNull(raw.version),
|
||||
desktopManaged: raw.desktopManaged === true,
|
||||
ownedByDesktop: raw.ownedByDesktop === true,
|
||||
startedAt: typeof raw.startedAt === "string" ? raw.startedAt : null,
|
||||
error: toStringOrNull(raw.error),
|
||||
};
|
||||
}
|
||||
@@ -190,6 +196,33 @@ export async function stopDesktopDaemon(
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason }));
|
||||
}
|
||||
|
||||
export async function confirmAndStopDesktopDaemon(): Promise<DesktopDaemonStatus | null> {
|
||||
const captured = await getDesktopDaemonStatus();
|
||||
if (!captured.pid || !captured.startedAt) return captured;
|
||||
const ownership = captured.ownedByDesktop
|
||||
? i18n.t("desktop.daemon.lifecycle.ownedMessage")
|
||||
: i18n.t("desktop.daemon.lifecycle.attachedMessage");
|
||||
const confirmed = await confirmDialog({
|
||||
title: i18n.t("desktop.daemon.lifecycle.stopTitle"),
|
||||
message: i18n.t("desktop.daemon.lifecycle.stopMessage", {
|
||||
ownership,
|
||||
home: captured.home,
|
||||
pid: captured.pid,
|
||||
}),
|
||||
confirmLabel: i18n.t("desktop.daemon.lifecycle.stop"),
|
||||
cancelLabel: i18n.t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return null;
|
||||
return parseDesktopDaemonStatus(
|
||||
await invokeDesktopCommand("stop_desktop_daemon", {
|
||||
reason: "manual_ipc",
|
||||
pid: captured.pid,
|
||||
startedAt: captured.startedAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon"));
|
||||
}
|
||||
|
||||
@@ -58,8 +58,16 @@ export function useBuiltInDaemonManagement(
|
||||
confirm: () =>
|
||||
confirmDialog({
|
||||
title: t("desktop.daemon.management.pauseTitle"),
|
||||
message: t("desktop.daemon.management.pauseMessage"),
|
||||
confirmLabel: t("desktop.daemon.management.pauseAndStop"),
|
||||
message: t(
|
||||
daemonStatus?.ownedByDesktop
|
||||
? "desktop.daemon.management.pauseMessage"
|
||||
: "desktop.daemon.lifecycle.pauseAttached",
|
||||
),
|
||||
confirmLabel: t(
|
||||
daemonStatus?.ownedByDesktop
|
||||
? "desktop.daemon.management.pauseAndStop"
|
||||
: "desktop.daemon.lifecycle.pause",
|
||||
),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,8 @@ function makeSources(): DesktopDiagnosticSources {
|
||||
home: "/paseo/home",
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
ownedByDesktop: true,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
error: null,
|
||||
}),
|
||||
getDaemonLogs: async () => ({
|
||||
|
||||
@@ -1274,6 +1274,7 @@ export const ar: TranslationResources = {
|
||||
detail: "إيقاف البرنامج الخفي المحلي.",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "Daemon",
|
||||
status: {
|
||||
title: "حالة",
|
||||
|
||||
@@ -1284,6 +1284,23 @@ export const en = {
|
||||
},
|
||||
daemon: {
|
||||
title: "Daemon",
|
||||
lifecycle: {
|
||||
owned: "Launched by this Desktop session",
|
||||
attached: "Attached to an existing daemon",
|
||||
ownedMessage: "This daemon was launched by this Desktop session.",
|
||||
attachedMessage: "This daemon was not launched by this Desktop session.",
|
||||
stopTitle: "Stop local daemon?",
|
||||
stopMessage:
|
||||
"{{ownership}}\nHome: {{home}}\nSupervisor PID: {{pid}}\nRunning agent work will be interrupted.",
|
||||
stop: "Stop daemon",
|
||||
stopping: "Stopping…",
|
||||
stopFailed: "Unable to stop daemon",
|
||||
pauseAttached: "Pause automatic daemon management? The attached daemon will keep running.",
|
||||
pause: "Pause management",
|
||||
workerUpdated: "Worker updated to {{version}}",
|
||||
supervisorRefresh:
|
||||
"The running supervisor retains its original code. Its launcher must stop and start it to refresh the supervisor.",
|
||||
},
|
||||
status: {
|
||||
title: "Status",
|
||||
builtInOnly: "Only the built-in desktop daemon is shown here",
|
||||
|
||||
@@ -1308,6 +1308,7 @@ export const es: TranslationResources = {
|
||||
detail: "Deteniendo el demonio local.",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "Daemon",
|
||||
status: {
|
||||
title: "Estado",
|
||||
|
||||
@@ -1308,6 +1308,7 @@ export const fr: TranslationResources = {
|
||||
detail: "Arrêt du démon local.",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "Daemon",
|
||||
status: {
|
||||
title: "Statut",
|
||||
|
||||
@@ -1286,6 +1286,7 @@ export const ja: TranslationResources = {
|
||||
detail: "ローカルデーモンを停止中。",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "デーモン",
|
||||
status: {
|
||||
title: "ステータス",
|
||||
|
||||
@@ -1281,6 +1281,7 @@ export const ko: TranslationResources = {
|
||||
detail: "로컬 데몬을 중지하는 중입니다.",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "데몬",
|
||||
status: {
|
||||
title: "상태",
|
||||
|
||||
@@ -1299,6 +1299,7 @@ export const ptBR: TranslationResources = {
|
||||
detail: "Parando o daemon local.",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "Daemon",
|
||||
status: {
|
||||
title: "Status",
|
||||
|
||||
@@ -1291,6 +1291,7 @@ export const ru: TranslationResources = {
|
||||
detail: "Остановка локального демона.",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "Демон",
|
||||
status: {
|
||||
title: "Статус",
|
||||
|
||||
@@ -1263,6 +1263,7 @@ export const zhCN: TranslationResources = {
|
||||
detail: "正在停止本地 daemon。",
|
||||
},
|
||||
daemon: {
|
||||
lifecycle: en.desktop.daemon.lifecycle,
|
||||
title: "Daemon",
|
||||
status: {
|
||||
title: "状态",
|
||||
|
||||
@@ -36,6 +36,8 @@ function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemon
|
||||
home: "/home",
|
||||
version: "0.0.0",
|
||||
desktopManaged: true,
|
||||
ownedByDesktop: true,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||
|
||||
test("settings restart completes when a replacement worker is observed without a sampled disconnect", async () => {
|
||||
let pid = 10;
|
||||
await restartDaemonFromSettings("daemon", "settings", {
|
||||
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
|
||||
restartServer: async () => {
|
||||
pid = 11;
|
||||
},
|
||||
});
|
||||
expect(pid).toBe(11);
|
||||
});
|
||||
|
||||
test("a status permission failure prevents the restart request", async () => {
|
||||
let restarted = false;
|
||||
await expect(
|
||||
restartDaemonFromSettings("daemon", "settings", {
|
||||
getStatus: async () => {
|
||||
throw new Error("Permission denied");
|
||||
},
|
||||
restartServer: async () => {
|
||||
restarted = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Permission denied");
|
||||
expect(restarted).toBe(false);
|
||||
});
|
||||
|
||||
test("a different responder fails confirmation immediately", async () => {
|
||||
let serverId = "daemon";
|
||||
await expect(
|
||||
restartDaemonFromSettings("daemon", "settings", {
|
||||
getStatus: async () => ({ pid: 10, version: "1.0.0", serverId }),
|
||||
restartServer: async () => {
|
||||
serverId = "other";
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("identity changed");
|
||||
});
|
||||
|
||||
test("a lost restart acknowledgment can still confirm the replacement", async () => {
|
||||
let pid = 10;
|
||||
await restartDaemonFromSettings("daemon", "settings", {
|
||||
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
|
||||
restartServer: async () => {
|
||||
pid = 11;
|
||||
throw Object.assign(new Error("Connection lost"), { code: "DAEMON_CONNECTION_LOST" });
|
||||
},
|
||||
});
|
||||
expect(pid).toBe(11);
|
||||
});
|
||||
|
||||
test("installation and worker version confirmation are separate outcomes", async () => {
|
||||
let pid = 10;
|
||||
await expect(
|
||||
updateDaemonFromSettings("daemon", {
|
||||
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
|
||||
updateDaemon: async () => {
|
||||
pid = 11;
|
||||
return { success: true, error: null, newVersion: "2.0.0" };
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Package installed; replacement worker version was not confirmed");
|
||||
});
|
||||
|
||||
test("an installed version is confirmed only in its replacement worker", async () => {
|
||||
let pid = 10,
|
||||
version = "1.0.0";
|
||||
await expect(
|
||||
updateDaemonFromSettings("daemon", {
|
||||
getStatus: async () => ({ pid, version, serverId: "daemon" }),
|
||||
updateDaemon: async () => {
|
||||
pid = 11;
|
||||
version = "2.0.0";
|
||||
return { success: true, error: null, newVersion: version };
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ workerVersion: "2.0.0" });
|
||||
});
|
||||
|
||||
test("RPC errors mentioning transport are not retried", async () => {
|
||||
let requested = false;
|
||||
await expect(
|
||||
restartDaemonFromSettings("daemon", "settings", {
|
||||
getStatus: async () => {
|
||||
if (requested)
|
||||
throw Object.assign(new Error("Connection policy denied by plugin"), {
|
||||
code: "permission_denied",
|
||||
});
|
||||
return { pid: 10, version: "1.0.0", serverId: "daemon" };
|
||||
},
|
||||
restartServer: async () => {
|
||||
requested = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Connection policy denied by plugin");
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
interface WorkerStatus {
|
||||
pid: number;
|
||||
serverId: string;
|
||||
version: string | null;
|
||||
}
|
||||
interface StatusReader {
|
||||
getStatus: () => Promise<WorkerStatus>;
|
||||
}
|
||||
|
||||
export interface SettingsDaemonRestartDeps extends StatusReader {
|
||||
restartServer: (reason: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Acknowledgment starts the wait; only a different ready worker completes it. */
|
||||
export async function restartDaemonFromSettings(
|
||||
hostServerId: string,
|
||||
reason: string,
|
||||
deps: SettingsDaemonRestartDeps,
|
||||
): Promise<void> {
|
||||
const previous = await readSelectedWorker(hostServerId, deps);
|
||||
let acknowledged = false;
|
||||
try {
|
||||
await deps.restartServer(reason);
|
||||
acknowledged = true;
|
||||
} catch (error) {
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
}
|
||||
try {
|
||||
await observeReplacement(previous, deps);
|
||||
} catch (error) {
|
||||
throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDaemonFromSettings(
|
||||
hostServerId: string,
|
||||
deps: StatusReader & {
|
||||
updateDaemon: () => Promise<{
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
newVersion: string | null;
|
||||
}>;
|
||||
},
|
||||
): Promise<{ workerVersion: string }> {
|
||||
const previous = await readSelectedWorker(hostServerId, deps);
|
||||
const installed = await deps.updateDaemon();
|
||||
if (!installed.success) throw new Error(installed.error ?? "Package installation failed");
|
||||
try {
|
||||
const worker = await observeReplacement(previous, deps);
|
||||
if (!worker.version || !installed.newVersion || worker.version !== installed.newVersion) {
|
||||
throw new Error(
|
||||
`Expected installed version ${installed.newVersion ?? "unknown"}; observed worker ${worker.version}.`,
|
||||
);
|
||||
}
|
||||
return { workerVersion: worker.version };
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Package installed; replacement worker version was not confirmed. ${String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readSelectedWorker(serverId: string, deps: StatusReader): Promise<WorkerStatus> {
|
||||
const worker = await deps.getStatus();
|
||||
if (worker.serverId !== serverId) throw new Error("Daemon identity changed");
|
||||
return worker;
|
||||
}
|
||||
|
||||
async function observeReplacement(
|
||||
previous: WorkerStatus,
|
||||
deps: StatusReader,
|
||||
): Promise<WorkerStatus> {
|
||||
const deadline = Date.now() + 600_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const current = await readSelectedWorker(previous.serverId, deps);
|
||||
if (current.pid !== previous.pid) return current;
|
||||
} catch (error) {
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error("Replacement worker could not be confirmed. Check daemon status and logs.");
|
||||
}
|
||||
|
||||
function isReconnectFailure(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"].includes(String(error.code)),
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { restartDaemonFromSettings, type SettingsDaemonRestartDeps } from "./daemon-restart";
|
||||
|
||||
const runningDesktopDaemonStatus: DesktopDaemonStatus = {
|
||||
serverId: "local-desktop",
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: 123,
|
||||
home: "/tmp/paseo",
|
||||
version: "1.0.0",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const desktopSettings = {
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
},
|
||||
};
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
isElectron?: boolean;
|
||||
desktopDaemonStatus?: DesktopDaemonStatus;
|
||||
desktopSettings?: typeof desktopSettings;
|
||||
desktopSettingsError?: Error;
|
||||
restartDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||
restartServer?: (reason: string) => Promise<void>;
|
||||
}) {
|
||||
const calls: string[] = [];
|
||||
const deps: SettingsDaemonRestartDeps = {
|
||||
getIsElectron: () => overrides?.isElectron ?? true,
|
||||
getDesktopDaemonStatus: async () => {
|
||||
calls.push("desktop-status");
|
||||
return overrides?.desktopDaemonStatus ?? runningDesktopDaemonStatus;
|
||||
},
|
||||
getDesktopSettings: async () => {
|
||||
calls.push("desktop-settings");
|
||||
if (overrides?.desktopSettingsError) {
|
||||
throw overrides.desktopSettingsError;
|
||||
}
|
||||
return overrides?.desktopSettings ?? desktopSettings;
|
||||
},
|
||||
restartDesktopDaemon:
|
||||
overrides?.restartDesktopDaemon ??
|
||||
(async () => {
|
||||
calls.push("desktop-restart");
|
||||
return runningDesktopDaemonStatus;
|
||||
}),
|
||||
restartServer:
|
||||
overrides?.restartServer ??
|
||||
(async (reason) => {
|
||||
calls.push(`rpc-restart:${reason}`);
|
||||
}),
|
||||
};
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
describe("restartDaemonFromSettings", () => {
|
||||
it("restarts the local desktop-managed daemon through the desktop bridge", async () => {
|
||||
const { calls, deps } = makeDeps();
|
||||
|
||||
await restartDaemonFromSettings(" local-desktop ", "settings_daemon_restart_local", deps);
|
||||
|
||||
expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]);
|
||||
});
|
||||
|
||||
it("restarts remote hosts over the daemon RPC without reading desktop settings", async () => {
|
||||
const { calls, deps } = makeDeps({
|
||||
desktopSettingsError: new Error("Unreadable desktop settings."),
|
||||
});
|
||||
|
||||
await restartDaemonFromSettings("remote-host", "settings_daemon_restart_remote", deps);
|
||||
|
||||
expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_remote"]);
|
||||
});
|
||||
|
||||
it("keeps manually managed local daemons on the RPC path without reading desktop settings", async () => {
|
||||
const { calls, deps } = makeDeps({
|
||||
desktopDaemonStatus: { ...runningDesktopDaemonStatus, desktopManaged: false },
|
||||
desktopSettingsError: new Error("Unreadable desktop settings."),
|
||||
});
|
||||
|
||||
await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps);
|
||||
|
||||
expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_local"]);
|
||||
});
|
||||
|
||||
it("keeps the RPC path when built-in daemon management is disabled", async () => {
|
||||
const { calls, deps } = makeDeps({
|
||||
desktopSettings: {
|
||||
...desktopSettings,
|
||||
daemon: { ...desktopSettings.daemon, manageBuiltInDaemon: false },
|
||||
},
|
||||
});
|
||||
|
||||
await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps);
|
||||
|
||||
expect(calls).toEqual([
|
||||
"desktop-status",
|
||||
"desktop-settings",
|
||||
"rpc-restart:settings_daemon_restart_local",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the RPC path outside Electron without reading desktop daemon status or settings", async () => {
|
||||
const { calls, deps } = makeDeps({
|
||||
isElectron: false,
|
||||
});
|
||||
|
||||
await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps);
|
||||
|
||||
expect(calls).toEqual(["rpc-restart:settings_daemon_restart_local"]);
|
||||
});
|
||||
|
||||
it("surfaces desktop restart failures without falling back to worker recycle", async () => {
|
||||
const { calls, deps } = makeDeps({
|
||||
restartDesktopDaemon: async () => {
|
||||
calls.push("desktop-restart");
|
||||
throw new Error("Desktop restart failed.");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps),
|
||||
).rejects.toThrow("Desktop restart failed.");
|
||||
|
||||
expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
interface DesktopDaemonRestartStatus {
|
||||
desktopManaged: boolean;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface DesktopDaemonRestartSettings {
|
||||
daemon: {
|
||||
manageBuiltInDaemon: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsDaemonRestartDeps {
|
||||
getIsElectron: () => boolean;
|
||||
getDesktopDaemonStatus: () => Promise<DesktopDaemonRestartStatus>;
|
||||
getDesktopSettings: () => Promise<DesktopDaemonRestartSettings>;
|
||||
restartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||
restartServer: (reason: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
async function isLocalDesktopManagedDaemon(
|
||||
hostServerId: string,
|
||||
deps: SettingsDaemonRestartDeps,
|
||||
): Promise<boolean> {
|
||||
if (!deps.getIsElectron()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desktopDaemonStatus = await deps.getDesktopDaemonStatus();
|
||||
if (!desktopDaemonStatus.desktopManaged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedHostServerId = hostServerId.trim();
|
||||
const normalizedDesktopServerId = desktopDaemonStatus.serverId.trim();
|
||||
|
||||
if (normalizedHostServerId.length === 0 || normalizedHostServerId !== normalizedDesktopServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desktopSettings = await deps.getDesktopSettings();
|
||||
return desktopSettings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
export async function restartDaemonFromSettings(
|
||||
hostServerId: string,
|
||||
reason: string,
|
||||
deps: SettingsDaemonRestartDeps,
|
||||
): Promise<void> {
|
||||
if (await isLocalDesktopManagedDaemon(hostServerId, deps)) {
|
||||
await deps.restartDesktopDaemon();
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.restartServer(reason);
|
||||
}
|
||||
@@ -33,16 +33,10 @@ import {
|
||||
ProfileDraft,
|
||||
TerminalProfileEditModal,
|
||||
} from "@/screens/settings/terminal-profile-edit-modal";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import {
|
||||
getDesktopDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { loadDesktopSettings, useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
@@ -72,8 +66,16 @@ import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect";
|
||||
import { restartDaemonFromSettings } from "./daemon-restart";
|
||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||
|
||||
const ThemedRestart = withUnistyles(RotateCw);
|
||||
const ThemedUpdate = withUnistyles(ArrowUpToLine);
|
||||
const lifecycleIconProps = (theme: Theme) => ({
|
||||
size: theme.iconSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const restartIcon = <ThemedRestart uniProps={lifecycleIconProps} />;
|
||||
const updateIcon = <ThemedUpdate uniProps={lifecycleIconProps} />;
|
||||
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedArrowDown = withUnistyles(ArrowDown);
|
||||
@@ -553,14 +555,8 @@ function ConnectionRow({
|
||||
);
|
||||
}
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const daemonClient = useHostRuntimeClient(host.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const runtime = getHostRuntimeStore();
|
||||
@@ -578,68 +574,20 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
const waitForCondition = useCallback(
|
||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isMountedRef.current) return false;
|
||||
if (predicate()) return true;
|
||||
await delay(intervalMs);
|
||||
}
|
||||
return predicate();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (restartRequest: Promise<void>) => {
|
||||
const disconnectTimeoutMs = 30000;
|
||||
const reconnectTimeoutMs = 30000;
|
||||
const requestFailureDisconnectGraceMs = 2000;
|
||||
const disconnectedPromise = isHostConnected()
|
||||
? waitForCondition(() => !isHostConnected(), disconnectTimeoutMs)
|
||||
: Promise.resolve(true);
|
||||
const restartResult = await restartRequest.then(
|
||||
() => ({ status: "accepted" as const }),
|
||||
async (error) => ({
|
||||
status: "rejected" as const,
|
||||
error,
|
||||
disconnectedAfterFailure: await waitForCondition(
|
||||
() => !isHostConnected(),
|
||||
requestFailureDisconnectGraceMs,
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (restartResult.status === "rejected" && !restartResult.disconnectedAfterFailure) {
|
||||
console.error(`[HostPage] Failed to restart daemon ${host.label}`, restartResult.error);
|
||||
setIsRestarting(false);
|
||||
async (request: Promise<void>) => {
|
||||
try {
|
||||
await request;
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.requestFailedMessage"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnected =
|
||||
restartResult.status === "rejected"
|
||||
? restartResult.disconnectedAfterFailure
|
||||
: await disconnectedPromise;
|
||||
const reconnected =
|
||||
disconnected && (await waitForCondition(() => isHostConnected(), reconnectTimeoutMs));
|
||||
if (isMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.unableToReconnectTitle"),
|
||||
t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsRestarting(false);
|
||||
}
|
||||
},
|
||||
[host.label, isHostConnected, t, waitForCondition],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
@@ -672,11 +620,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
host.serverId,
|
||||
`settings_daemon_restart_${host.serverId}`,
|
||||
{
|
||||
getIsElectron,
|
||||
getDesktopDaemonStatus,
|
||||
getDesktopSettings: loadDesktopSettings,
|
||||
restartDesktopDaemon,
|
||||
restartServer: (reason) => daemonClient.restartServer(reason),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
void waitForDaemonRestart(restartRequest);
|
||||
@@ -691,11 +640,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
|
||||
|
||||
const restartIcon = useMemo(
|
||||
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-restart-card">
|
||||
<View style={settingsStyles.row}>
|
||||
@@ -722,12 +666,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
|
||||
type DaemonUpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "complete"; workerVersion: string }
|
||||
| { status: "updating"; phase: string }
|
||||
| { status: "failed"; title: string; message: string };
|
||||
|
||||
function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const daemonClient = useHostRuntimeClient(host.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const runtime = getHostRuntimeStore();
|
||||
@@ -759,55 +703,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
const hasReconnectedAfter = useCallback(
|
||||
(startMarker: DaemonConnectionMarker | null) =>
|
||||
hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker),
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
const waitForCondition = useCallback(
|
||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isMountedRef.current) return false;
|
||||
if (predicate()) return true;
|
||||
await delay(intervalMs);
|
||||
}
|
||||
return predicate();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (startMarker: DaemonConnectionMarker | null) => {
|
||||
const disconnectTimeoutMs = 15000;
|
||||
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
|
||||
if (!hasReconnectedAfter(startMarker) && isHostConnected()) {
|
||||
await waitForCondition(
|
||||
() => !isHostConnected() || hasReconnectedAfter(startMarker),
|
||||
disconnectTimeoutMs,
|
||||
);
|
||||
}
|
||||
const reconnected =
|
||||
hasReconnectedAfter(startMarker) ||
|
||||
(await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs));
|
||||
if (isMountedRef.current) {
|
||||
if (!reconnected) {
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.unableToReconnectTitle"),
|
||||
message: t("settings.host.daemon.update.unableToReconnectMessage", {
|
||||
name: host.label,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUpdateState({ status: "idle" });
|
||||
}
|
||||
},
|
||||
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
setUpdateState({
|
||||
@@ -835,13 +730,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed || !isMountedRef.current) return;
|
||||
const startSnapshot = runtime.getSnapshot(host.serverId);
|
||||
const startMarker = startSnapshot
|
||||
? {
|
||||
clientGeneration: startSnapshot.clientGeneration,
|
||||
lastOnlineAt: startSnapshot.lastOnlineAt,
|
||||
}
|
||||
: null;
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseStarting"),
|
||||
@@ -875,24 +763,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
});
|
||||
unsubscribeRef.current = unsubscribe;
|
||||
|
||||
void daemonClient
|
||||
.updateDaemon(requestId)
|
||||
.then((response) => {
|
||||
void updateDaemonFromSettings(host.serverId, {
|
||||
updateDaemon: () => daemonClient.updateDaemon(requestId),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
})
|
||||
.then(({ workerVersion }) => {
|
||||
unsubscribeRef.current = null;
|
||||
unsubscribe();
|
||||
if (!response.success) {
|
||||
if (!isMountedRef.current) return undefined;
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: t("settings.host.daemon.update.requestFailedMessage", {
|
||||
error: response.error ?? "Unknown error",
|
||||
}),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
// Update succeeded — wait for daemon to restart and reconnect
|
||||
void waitForDaemonRestart(startMarker);
|
||||
if (isMountedRef.current) setUpdateState({ status: "complete", workerVersion });
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -919,15 +801,10 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
message: t("settings.host.daemon.update.dialogFailedMessage"),
|
||||
});
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
|
||||
|
||||
const updateIcon = useMemo(
|
||||
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, t]);
|
||||
|
||||
const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged);
|
||||
if (!shouldShowUpdate) {
|
||||
if (!shouldShowUpdate && updateState.status !== "complete") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -956,6 +833,15 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
{updateState.status === "complete" ? (
|
||||
<InlineAlert
|
||||
variant="success"
|
||||
title={t("desktop.daemon.lifecycle.workerUpdated", {
|
||||
version: updateState.workerVersion,
|
||||
})}
|
||||
description={t("desktop.daemon.lifecycle.supervisorRefresh")}
|
||||
/>
|
||||
) : null}
|
||||
{updateState.status === "failed" ? (
|
||||
<View style={styles.updateFailure}>
|
||||
<InlineAlert
|
||||
@@ -1253,13 +1139,14 @@ function RemoveHostSection({
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const daemonStatus = daemonStatusData?.status ?? null;
|
||||
const stopsOwnedDaemon = isLocalDaemon && daemonStatus?.ownedByDesktop === true;
|
||||
const removeHostHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: isLocalDaemon
|
||||
title: stopsOwnedDaemon
|
||||
? t("settings.host.daemon.remove.localConfirmTitle")
|
||||
: t("settings.host.daemon.remove.title"),
|
||||
}),
|
||||
[isLocalDaemon, t],
|
||||
[stopsOwnedDaemon, t],
|
||||
);
|
||||
|
||||
const destructiveTextStyle = useMemo(
|
||||
@@ -1292,7 +1179,7 @@ function RemoveHostSection({
|
||||
try {
|
||||
await updateSettings({ daemon: { manageBuiltInDaemon: false } });
|
||||
didDisableDaemonManagement = true;
|
||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||
if (daemonStatus?.status === "running" && daemonStatus.ownedByDesktop) {
|
||||
setStatus(await stopDesktopDaemon("host_remove"));
|
||||
didStopDaemon = true;
|
||||
}
|
||||
@@ -1360,7 +1247,7 @@ function RemoveHostSection({
|
||||
: t("settings.host.daemon.remove.title")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isLocalDaemon
|
||||
{stopsOwnedDaemon
|
||||
? t("settings.host.daemon.remove.localHint")
|
||||
: t("settings.host.daemon.remove.hint")}
|
||||
</Text>
|
||||
@@ -1386,7 +1273,7 @@ function RemoveHostSection({
|
||||
testID="remove-host-confirm-modal"
|
||||
>
|
||||
<Text style={styles.confirmText}>
|
||||
{isLocalDaemon
|
||||
{stopsOwnedDaemon
|
||||
? t("settings.host.daemon.remove.localConfirmMessage")
|
||||
: t("settings.host.daemon.remove.confirmMessage", { name: host.label })}
|
||||
</Text>
|
||||
|
||||
+23
-55
@@ -1,3 +1,4 @@
|
||||
import { pairCommand } from "./commands/daemon/pair.js";
|
||||
import { Command, Option } from "commander";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
@@ -15,9 +16,9 @@ import { createHeartbeatCommand } from "./commands/heartbeat/index.js";
|
||||
import { createHubCommand } from "./commands/hub/index.js";
|
||||
import { createHooksCommand } from "./commands/hooks.js";
|
||||
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
|
||||
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
|
||||
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
|
||||
import { runDaemonReloadCommand } from "./commands/daemon/reload.js";
|
||||
import { daemonStatusCommand } from "./commands/daemon/status.js";
|
||||
import { daemonRestartCommand } from "./commands/daemon/restart.js";
|
||||
import { daemonReloadCommand } from "./commands/daemon/reload.js";
|
||||
import { addLsOptions, runLsCommand } from "./commands/agent/ls.js";
|
||||
import { addRunOptions, runRunCommand } from "./commands/agent/run.js";
|
||||
import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js";
|
||||
@@ -35,19 +36,12 @@ import { onboardCommand } from "./commands/onboard.js";
|
||||
import {
|
||||
addDaemonHostOption,
|
||||
addJsonAndDaemonHostOptions,
|
||||
addJsonOption,
|
||||
withGlobalOptions,
|
||||
} from "./utils/command-options.js";
|
||||
import { resolveCliVersion } from "./version.js";
|
||||
|
||||
const VERSION = resolveCliVersion();
|
||||
|
||||
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
|
||||
if (typeof hostnames === "string") return hostnames;
|
||||
if (typeof allowedHosts === "string") return allowedHosts;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createCli(): Command {
|
||||
const program = new Command();
|
||||
|
||||
@@ -125,51 +119,10 @@ export function createCli(): Command {
|
||||
program.addCommand(daemonStartCommand());
|
||||
program.addCommand(createHooksCommand());
|
||||
|
||||
addJsonOption(
|
||||
program
|
||||
.command("status")
|
||||
.description('Show local daemon status (alias for "paseo daemon status")'),
|
||||
)
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.action(withOutput(runDaemonStatusCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'),
|
||||
).action(withOutput(runDaemonReloadCommand));
|
||||
|
||||
addJsonOption(
|
||||
program
|
||||
.command("restart")
|
||||
.description('Restart local daemon (alias for "paseo daemon restart")'),
|
||||
)
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
|
||||
.option("--force", "Send SIGKILL if graceful stop times out")
|
||||
.option(
|
||||
"--listen <listen>",
|
||||
"Listen target for restarted daemon (host:port, port, or unix socket)",
|
||||
)
|
||||
.option("--port <port>", "Port for restarted daemon listen target")
|
||||
.option("--relay", "Enable relay on restarted daemon")
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(
|
||||
withOutput((...args) => {
|
||||
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
|
||||
return runDaemonRestartCommand(
|
||||
{
|
||||
...options,
|
||||
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
|
||||
},
|
||||
command,
|
||||
);
|
||||
}),
|
||||
);
|
||||
program.addCommand(daemonStatusCommand());
|
||||
program.addCommand(daemonRestartCommand());
|
||||
program.addCommand(daemonReloadCommand());
|
||||
program.addCommand(pairCommand());
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
program.addCommand(createAgentCommand());
|
||||
@@ -207,5 +160,20 @@ export function createCli(): Command {
|
||||
// Added in v0.2.0; remove after 2027-01-17.
|
||||
program.addCommand(createWorktreeCommand(), { hidden: true });
|
||||
|
||||
const enforceSelectorDuplicates = (command: Command) => {
|
||||
for (const option of command.options) {
|
||||
if (option.long !== "--home" && option.long !== "--host") continue;
|
||||
option.argParser((value: string, previous: string | undefined) => {
|
||||
if (previous !== undefined && previous !== value)
|
||||
throw {
|
||||
code: "TARGET_AMBIGUOUS",
|
||||
message: `Conflicting duplicate ${option.long} selectors.`,
|
||||
};
|
||||
return value;
|
||||
});
|
||||
}
|
||||
command.commands.forEach(enforceSelectorDuplicates);
|
||||
};
|
||||
enforceSelectorDuplicates(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -44,8 +43,6 @@ export async function runArchiveCommand(
|
||||
options: AgentArchiveOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentArchiveCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
@@ -56,18 +53,7 @@ export async function runArchiveCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
|
||||
@@ -5,17 +5,17 @@ export function addAttachOptions(cmd: Command): Command {
|
||||
.description("Attach to a running agent's output stream")
|
||||
.argument("<id>", "Agent ID (or prefix)");
|
||||
}
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
} from "../../utils/timeline.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface AgentAttachOptions {
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -105,23 +105,13 @@ export async function runAttachCommand(
|
||||
options: AgentAttachOptions,
|
||||
_command: Command,
|
||||
): Promise<void> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!id) {
|
||||
console.error("Error: Agent ID required");
|
||||
console.error("Usage: paseo attach <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
|
||||
console.error("Start the daemon with: paseo daemon start");
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
import { runDeleteCommand } from "./delete.js";
|
||||
|
||||
const agent = {
|
||||
@@ -27,7 +29,7 @@ vi.mock("../../utils/client.js", () => ({
|
||||
|
||||
describe("runDeleteCommand", () => {
|
||||
it("force-deletes a running agent when graceful cancellation is refused", async () => {
|
||||
const result = await runDeleteCommand(agent.id, {}, {} as never);
|
||||
const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never);
|
||||
|
||||
expect(cancelAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(deleteAgent).toHaveBeenCalledWith(agent.id);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
@@ -39,8 +38,6 @@ export async function runDeleteCommand(
|
||||
options: AgentDeleteOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentDeleteResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!id && !options.all && !options.cwd) {
|
||||
const error: CommandError = {
|
||||
code: "MISSING_ARGUMENT",
|
||||
@@ -50,18 +47,7 @@ export async function runDeleteCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
@@ -97,6 +83,7 @@ export async function runDeleteCommand(
|
||||
await client.deleteAgent(agent.id);
|
||||
return { ok: true as const, id: agent.id };
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false as const, id: agent.id, message };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
@@ -26,17 +25,7 @@ export async function runDetachCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentDetachResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("resolveImportCwd", () => {
|
||||
const result = await runImportCommand(
|
||||
"pi-session-1",
|
||||
{
|
||||
daemonTarget: { kind: "endpoint", host: "example.test:12345" },
|
||||
provider: "pi",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
|
||||
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||
@@ -94,28 +94,11 @@ export function resolveImportCwd(explicitCwd: string | undefined, defaultCwd: st
|
||||
return cwd;
|
||||
}
|
||||
|
||||
async function connectToDaemonOrThrow(
|
||||
hostOption: string | undefined,
|
||||
host: string,
|
||||
): Promise<Awaited<ReturnType<typeof connectToDaemon>>> {
|
||||
try {
|
||||
return await connectToDaemon({ host: hostOption });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runImportCommand(
|
||||
sessionIdArg: string,
|
||||
options: AgentImportOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentImportCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const sessionId = sessionIdArg.trim();
|
||||
if (!sessionId) {
|
||||
throw {
|
||||
@@ -129,7 +112,7 @@ export async function runImportCommand(
|
||||
const cwd = resolveImportCwd(options.cwd, process.cwd());
|
||||
|
||||
const labels = parseImportLabels(options.label);
|
||||
const client = await connectToDaemonOrThrow(options.host, host);
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const agent = await client.importAgent({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
export function addInspectOptions(cmd: Command): Command {
|
||||
@@ -217,8 +217,6 @@ export async function runInspectCommand(
|
||||
options: AgentInspectOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentInspectResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
@@ -229,18 +227,7 @@ export async function runInspectCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
@@ -91,23 +91,13 @@ export async function runLogsCommand(
|
||||
options: AgentLogsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLogsResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!id) {
|
||||
console.error("Error: Agent ID required");
|
||||
console.error("Usage: paseo agent logs <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
|
||||
console.error("Start the daemon with: paseo daemon start");
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
@@ -157,6 +147,7 @@ export async function runLogsCommand(
|
||||
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
|
||||
console.log(transcript);
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: Failed to get logs: ${message}`);
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
@@ -104,21 +104,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
|
||||
export type AgentLsResult = ListResult<AgentListItem>;
|
||||
|
||||
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
const isSsh = host.trim().startsWith("ssh://");
|
||||
return {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot reach the daemon at ${host}: ${reason}`,
|
||||
details: isSsh
|
||||
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
|
||||
: [
|
||||
"Start a local daemon with: paseo daemon start",
|
||||
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentLsOptions extends CommandOptions {
|
||||
/** -a: Include archived agents */
|
||||
all?: boolean;
|
||||
@@ -185,14 +170,7 @@ export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLsResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
throw daemonConnectionFailure(host, err);
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function runModeCommand(
|
||||
_command: Command,
|
||||
): Promise<AgentModeResult> {
|
||||
const normalizedMode = mode?.trim();
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const host = getDaemonHost({ target: options.daemonTarget });
|
||||
|
||||
// Validate arguments
|
||||
if (!options.list && !normalizedMode) {
|
||||
@@ -64,7 +64,7 @@ export async function runModeCommand(
|
||||
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
client = await connectToDaemon({ target: options.daemonTarget });
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { openDesktopWithAgent } from "../open.js";
|
||||
import type {
|
||||
CommandError,
|
||||
@@ -36,12 +36,7 @@ async function resolveServerId(options: CommandOptions): Promise<string> {
|
||||
return explicitServerId;
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (error) {
|
||||
throw buildDaemonConnectionCommandError({ host: options.host, error });
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
try {
|
||||
const serverId = client.getLastServerInfoMessage()?.serverId.trim();
|
||||
if (!serverId) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
|
||||
import { connectToDaemon, resolveAgentId } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -40,8 +39,6 @@ export async function runReloadCommand(
|
||||
options: AgentReloadOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentReloadCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
code: "MISSING_AGENT_ID",
|
||||
@@ -51,18 +48,7 @@ export async function runReloadCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type AgentRunOptions,
|
||||
} from "./run";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
describe("managed agent caller context", () => {
|
||||
it("propagates a trimmed PASEO_AGENT_ID", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
|
||||
@@ -65,8 +67,13 @@ describe("runRunCommand option validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function expectInvalidOptions(options: AgentRunOptions, messageMatch: RegExp) {
|
||||
await expect(runRunCommand("do something", options, {} as never)).rejects.toMatchObject({
|
||||
async function expectInvalidOptions(
|
||||
options: Omit<AgentRunOptions, "daemonTarget">,
|
||||
messageMatch: RegExp,
|
||||
) {
|
||||
await expect(
|
||||
runRunCommand("do something", { ...options, daemonTarget }, {} as never),
|
||||
).rejects.toMatchObject({
|
||||
code: "INVALID_OPTIONS",
|
||||
message: expect.stringMatching(messageMatch),
|
||||
});
|
||||
@@ -84,7 +91,11 @@ describe("runRunCommand option validation", () => {
|
||||
// must clear validation. It still fails later (provider resolution), which
|
||||
// is enough to prove the new guard did not reject it.
|
||||
await expect(
|
||||
runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never),
|
||||
runRunCommand(
|
||||
"do something",
|
||||
{ newWorkspace: "worktree", provider: undefined, daemonTarget },
|
||||
{} as never,
|
||||
),
|
||||
).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -140,6 +140,7 @@ function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined {
|
||||
function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) {
|
||||
const newWorkspace = resolveNewWorkspaceKind(options) ?? "local";
|
||||
return buildWorkspaceSource({
|
||||
daemonTarget: options.daemonTarget,
|
||||
isolation: newWorkspace,
|
||||
path: cwd,
|
||||
mode: options.worktreeMode,
|
||||
@@ -181,6 +182,7 @@ function loadOutputSchema(value: string): Record<string, unknown> {
|
||||
try {
|
||||
source = readFileSync(resolve(trimmed), "utf8");
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "INVALID_OUTPUT_SCHEMA",
|
||||
@@ -195,6 +197,7 @@ function loadOutputSchema(value: string): Record<string, unknown> {
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "INVALID_OUTPUT_SCHEMA",
|
||||
@@ -406,6 +409,7 @@ function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
||||
}
|
||||
return ms;
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "INVALID_TIMEOUT",
|
||||
@@ -432,6 +436,7 @@ function loadRunImages(
|
||||
mimeType,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err });
|
||||
}
|
||||
@@ -482,22 +487,6 @@ function parseKeyValueFlags(
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function connectToDaemonOrThrow(
|
||||
hostOption: string | undefined,
|
||||
host: string,
|
||||
): Promise<ConnectedDaemonClient> {
|
||||
try {
|
||||
return await connectToDaemon({ host: hostOption });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace is the explicit home of a run: it owns the directory the agent
|
||||
// runs in. The CLI resolves one before creating any agent, so no run leans on
|
||||
// createAgent's legacy cwd->workspace fallback.
|
||||
@@ -587,7 +576,6 @@ export async function runRunCommand(
|
||||
options: AgentRunOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentRunResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
|
||||
|
||||
validateRunOptions(prompt, options, outputSchema);
|
||||
@@ -596,7 +584,7 @@ export async function runRunCommand(
|
||||
const resolvedProviderModel = resolveProviderAndModel(options);
|
||||
const resolvedTitle = options.title ?? options.name;
|
||||
|
||||
const client = await connectToDaemonOrThrow(options.host, host);
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
// Resolve working directory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Command } from "commander";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -78,6 +78,7 @@ async function readImageFiles(
|
||||
|
||||
return { data: buffer.toString("base64"), mimeType };
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "IMAGE_READ_ERROR",
|
||||
@@ -128,6 +129,7 @@ async function resolvePromptInput(options: {
|
||||
try {
|
||||
return await readFile(resolve(promptFilePath), "utf8");
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "PROMPT_FILE_READ_ERROR",
|
||||
@@ -166,8 +168,6 @@ export async function runSendCommand(
|
||||
options: AgentSendOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentSendResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
@@ -184,18 +184,7 @@ export async function runSendCommand(
|
||||
promptFile: options.promptFile,
|
||||
});
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
// Read image files if provided
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -42,8 +41,6 @@ export async function runStopCommand(
|
||||
options: AgentStopOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentStopResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments - need either an id, --all, or --cwd
|
||||
if (!id && !options.all && !options.cwd) {
|
||||
const error: CommandError = {
|
||||
@@ -54,18 +51,7 @@ export async function runStopCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -203,8 +203,6 @@ export async function runUpdateCommand(
|
||||
options: AgentUpdateOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentUpdateCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
const error: CommandError = {
|
||||
@@ -217,18 +215,7 @@ export async function runUpdateCommand(
|
||||
|
||||
const changes = parseAgentChanges(options);
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -72,6 +72,7 @@ function parseWaitTimeout(timeout: string | undefined): {
|
||||
timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "INVALID_TIMEOUT",
|
||||
@@ -142,8 +143,6 @@ export async function runWaitCommand(
|
||||
options: AgentWaitOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<AgentWaitResult>> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||
throw {
|
||||
code: "MISSING_AGENT_ID",
|
||||
@@ -154,18 +153,7 @@ export async function runWaitCommand(
|
||||
|
||||
const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout);
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js";
|
||||
import { connectToDaemon } from "../utils/client.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../output/index.js";
|
||||
import type { CommandOptions } from "../output/with-output.js";
|
||||
|
||||
@@ -45,12 +44,7 @@ export async function runCloneCommand(
|
||||
throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names");
|
||||
}
|
||||
|
||||
let client: DaemonClient;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
throw buildDaemonConnectionCommandError({ host: options.host, error: err });
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { addLocalDaemonOptions } from "../../utils/command-options.js";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
readDaemonInstance,
|
||||
readPersistedConfig,
|
||||
getPersistedConfigValue,
|
||||
editPersistedConfig,
|
||||
} from "@getpaseo/server";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { withOutput, type CommandOptions } from "../../output/index.js";
|
||||
|
||||
function redact(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
/password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(key)
|
||||
? "[redacted]"
|
||||
: redact(item),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function result(data: Record<string, unknown>) {
|
||||
return {
|
||||
type: "single" as const,
|
||||
data,
|
||||
schema: {
|
||||
idField: () => "configuration",
|
||||
columns: [],
|
||||
renderHuman: () => JSON.stringify(data, null, 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function homeOf(options: CommandOptions) {
|
||||
if (options.daemonTarget.kind !== "instance")
|
||||
throw new Error("Configuration edits require --home");
|
||||
return options.daemonTarget.home;
|
||||
}
|
||||
|
||||
async function applySaved(home: string, options: CommandOptions) {
|
||||
const nextCommand = `paseo daemon start --home ${JSON.stringify(home)}`;
|
||||
const instance = await readDaemonInstance(home);
|
||||
if (!instance?.listen)
|
||||
return result({
|
||||
action: "saved",
|
||||
applied: false,
|
||||
message: "Saved; not applied to a running daemon",
|
||||
nextCommand,
|
||||
});
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ target: options.daemonTarget, timeout: 1_500 });
|
||||
} catch {
|
||||
return result({
|
||||
action: "saved",
|
||||
applied: false,
|
||||
message: "Saved; not applied to a running daemon",
|
||||
nextCommand: `paseo daemon reload --home ${JSON.stringify(home)}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const reload = await client.reloadDaemonConfig();
|
||||
return result({ action: "saved", ...reload });
|
||||
} catch (error) {
|
||||
throw { code: "CONFIG_SAVED_RELOAD_FAILED", message: `Saved; reload failed: ${String(error)}` };
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function daemonConfigCommand(): Command {
|
||||
const config = new Command("config").description(
|
||||
"Read or edit persistent local configuration (local operation)",
|
||||
);
|
||||
addLocalDaemonOptions(config.command("get [path]")).action(
|
||||
withOutput(async (field: string | undefined, options: CommandOptions, _command: Command) => {
|
||||
const persisted = readPersistedConfig(homeOf(options));
|
||||
const value = field ? getPersistedConfigValue(persisted, field) : persisted;
|
||||
return result({
|
||||
source: "configured",
|
||||
path: field ?? null,
|
||||
set: value !== undefined,
|
||||
value:
|
||||
field && /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(field)
|
||||
? "[redacted]"
|
||||
: redact(value),
|
||||
});
|
||||
}),
|
||||
);
|
||||
addLocalDaemonOptions(config.command("set <path> <value>"))
|
||||
.option("--string", "Interpret value literally as a string")
|
||||
.action(
|
||||
withOutput(async (field: string, raw: string, options: CommandOptions, _command: Command) => {
|
||||
let value: unknown = raw;
|
||||
if (!options.string) {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
/* Unquoted input is a string. */
|
||||
}
|
||||
}
|
||||
editPersistedConfig(homeOf(options), field, { value });
|
||||
return applySaved(homeOf(options), options);
|
||||
}),
|
||||
);
|
||||
addLocalDaemonOptions(config.command("unset <path>")).action(
|
||||
withOutput(async (field: string, options: CommandOptions, _command: Command) => {
|
||||
editPersistedConfig(homeOf(options), field, { unset: true });
|
||||
return applySaved(homeOf(options), options);
|
||||
}),
|
||||
);
|
||||
return config;
|
||||
}
|
||||
@@ -1,81 +1,32 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { startCommand } from "./start.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
import { runStopCommand } from "./stop.js";
|
||||
import { runRestartCommand } from "./restart.js";
|
||||
import { Command } from "commander";
|
||||
import { startCommand, daemonRunCommand } from "./start.js";
|
||||
import { daemonStatusCommand } from "./status.js";
|
||||
import { daemonStopCommand } from "./stop.js";
|
||||
import { daemonRestartCommand } from "./restart.js";
|
||||
import { runSetPasswordCommand } from "./set-password.js";
|
||||
import { pairCommand } from "./pair.js";
|
||||
import { runDaemonReloadCommand } from "./reload.js";
|
||||
import { daemonReloadCommand } from "./reload.js";
|
||||
import { daemonConfigCommand } from "./config.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
|
||||
|
||||
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
|
||||
if (typeof hostnames === "string") return hostnames;
|
||||
if (typeof allowedHosts === "string") return allowedHosts;
|
||||
return undefined;
|
||||
}
|
||||
import { addJsonOption, addLocalDaemonOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createDaemonCommand(): Command {
|
||||
const daemon = new Command("daemon").description("Manage the Paseo daemon");
|
||||
|
||||
daemon.addCommand(startCommand());
|
||||
daemon.addCommand(pairCommand());
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
daemon.command("reload").description("Reload config.json without restarting the daemon"),
|
||||
).action(withOutput(runDaemonReloadCommand));
|
||||
|
||||
addJsonOption(daemon.command("status").description("Show local daemon status"))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.action(withOutput(runStatusCommand));
|
||||
|
||||
addJsonOption(daemon.command("stop").description("Stop the local daemon"))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
|
||||
.option("--force", "Send SIGKILL if graceful stop times out")
|
||||
.option("--kill-timeout <seconds>", "Wait after SIGKILL before failing (default: 3)")
|
||||
.action(withOutput(runStopCommand));
|
||||
|
||||
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
|
||||
.option("--force", "Send SIGKILL if graceful stop times out")
|
||||
.option(
|
||||
"--listen <listen>",
|
||||
"Listen target for restarted daemon (host:port, port, or unix socket)",
|
||||
)
|
||||
.option("--port <port>", "Port for restarted daemon listen target")
|
||||
.option("--relay", "Enable relay on restarted daemon")
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||
.option("--web-ui", "Enable the bundled daemon web UI on restarted daemon")
|
||||
.option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon")
|
||||
.option(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(
|
||||
withOutput((...args) => {
|
||||
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
|
||||
return runRestartCommand(
|
||||
{
|
||||
...options,
|
||||
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
|
||||
},
|
||||
command,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
for (const command of [
|
||||
startCommand(),
|
||||
daemonRunCommand(),
|
||||
daemonStatusCommand(),
|
||||
daemonStopCommand(),
|
||||
daemonRestartCommand(),
|
||||
daemonReloadCommand(),
|
||||
pairCommand(),
|
||||
daemonConfigCommand(),
|
||||
])
|
||||
daemon.addCommand(command);
|
||||
addJsonOption(
|
||||
daemon
|
||||
.command("set-password")
|
||||
.description("Prompt for and save a hashed daemon password to config.json"),
|
||||
)
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.action(withOutput(runSetPasswordCommand));
|
||||
|
||||
addLocalDaemonOptions(
|
||||
daemon.command("set-password").description("Save a hashed daemon password (local operation)"),
|
||||
),
|
||||
).action(withOutput(runSetPasswordCommand));
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server";
|
||||
import { expect, test } from "vitest";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
const repo = fileURLToPath(new URL("../../../../..", import.meta.url));
|
||||
const cli = path.join(repo, "packages/cli/dist/index.js");
|
||||
|
||||
async function port() {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const value = (server.address() as net.AddressInfo).port;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (value === 6767 || value === 6768) throw new Error("Unsafe test port");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo lifecycle "));
|
||||
const env = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
};
|
||||
const homes = [path.join(root, "a"), path.join(root, "b")];
|
||||
const owned = new Map<string, { pid: number; startedAt: string }>();
|
||||
async function run(args: string[], overrides: NodeJS.ProcessEnv = {}) {
|
||||
const child = spawn(process.execPath, [cli, ...args], {
|
||||
cwd: root,
|
||||
env: { ...env, ...overrides },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "",
|
||||
stderr = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data;
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data;
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`));
|
||||
}, 45_000);
|
||||
child.once("error", reject);
|
||||
child.once("close", (exitCode) => {
|
||||
clearTimeout(timer);
|
||||
resolve(exitCode);
|
||||
});
|
||||
});
|
||||
for (const home of homes) {
|
||||
try {
|
||||
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
||||
owned.set(home, lock);
|
||||
} catch {
|
||||
/* No acquired launch. */
|
||||
}
|
||||
}
|
||||
return { code, stdout, stderr, json: () => JSON.parse(stdout) };
|
||||
}
|
||||
async function ok(args: string[], overrides?: NodeJS.ProcessEnv) {
|
||||
const result = await run(["--json", ...args], overrides);
|
||||
expect(result.code, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
return result.json();
|
||||
}
|
||||
async function liveStatus(home: string, overrides?: NodeJS.ProcessEnv) {
|
||||
let status;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
status = await ok(["status", "--home", home], overrides);
|
||||
return status.connectedDaemon;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("reachable");
|
||||
return status;
|
||||
}
|
||||
async function configure(home: string, listen: string) {
|
||||
for (const [field, value] of [
|
||||
["daemon.listen", listen],
|
||||
["daemon.relay.enabled", "false"],
|
||||
["features.webUi.enabled", "false"],
|
||||
["features.dictation.enabled", "false"],
|
||||
["features.voiceMode.enabled", "false"],
|
||||
]) {
|
||||
await ok(["daemon", "config", "set", field!, value!, "--home", home]);
|
||||
}
|
||||
}
|
||||
async function close() {
|
||||
for (const [home, captured] of owned) {
|
||||
try {
|
||||
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
||||
if (lock.pid === captured.pid && lock.startedAt === captured.startedAt) {
|
||||
await run(["daemon", "stop", "--home", home, "--force", "--timeout", "2"]);
|
||||
}
|
||||
} catch {
|
||||
/* Already exited. */
|
||||
}
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
return { root, homes, env, run, ok, liveStatus, configure, close };
|
||||
}
|
||||
|
||||
test("managed two-home restart retains its supervisor and never routes ordinary commands or stop to the other home", async () => {
|
||||
const f = await fixture();
|
||||
const [a, b] = f.homes as [string, string];
|
||||
const portA = await port(),
|
||||
portB = await port(),
|
||||
movedPort = await port();
|
||||
try {
|
||||
await f.configure(a, `127.0.0.1:${portA}`);
|
||||
await f.configure(b, `127.0.0.1:${portB}`);
|
||||
const launchA = await f.ok(["start", "--home", a, "--timeout", "30"]);
|
||||
const poisoned = {
|
||||
PASEO_HOME: a,
|
||||
PASEO_HOST: `127.0.0.1:${portA}`,
|
||||
PASEO_LISTEN: `127.0.0.1:${portA}`,
|
||||
PORT: String(portA),
|
||||
PASEO_WEB_UI_ENABLED: "true",
|
||||
PASEO_RELAY_ENABLED: "true",
|
||||
};
|
||||
const launchB = await f.ok(["daemon", "start", "--home", b, "--timeout", "30"], poisoned);
|
||||
expect(launchA.listen).toBe(`127.0.0.1:${portA}`);
|
||||
expect(launchB.listen).toBe(`127.0.0.1:${portB}`);
|
||||
const beforeA = await f.liveStatus(a);
|
||||
const beforeB = await f.ok(["--home", b, "daemon", "status"], poisoned);
|
||||
if (process.platform !== "win32") {
|
||||
for (const home of [a, b, path.join(f.root, ".paseo")])
|
||||
expect((await stat(home)).mode & 0o777).toBe(0o700);
|
||||
}
|
||||
|
||||
const repoB = path.join(f.root, "project-b");
|
||||
await mkdir(repoB);
|
||||
await f.ok(["project", "create", repoB, "--home", b], poisoned);
|
||||
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
|
||||
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
|
||||
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
|
||||
expect(stream.stderr).toContain("Agent not found: missing-agent");
|
||||
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
|
||||
expect(restart.supervisorPid).toBe(launchB.pid);
|
||||
expect(restart.workerPid).not.toBe(beforeB.workerPid);
|
||||
const saved = await f.ok(
|
||||
["daemon", "config", "set", "daemon.listen", `127.0.0.1:${movedPort}`, "--home", b],
|
||||
poisoned,
|
||||
);
|
||||
expect(saved.restartRequiredPaths).toContain("daemon.listen");
|
||||
await f.ok(["daemon", "restart", "--home", b, "--timeout", "30"], poisoned);
|
||||
const moved = await f.liveStatus(b);
|
||||
expect(moved.listen).toBe(`127.0.0.1:${movedPort}`);
|
||||
expect(moved.pid).toBe(launchB.pid);
|
||||
await f.ok(["daemon", "stop", "--home", b], poisoned);
|
||||
const stoppedQuery = await f.run(["--json", "project", "ls", "--home", b], poisoned);
|
||||
expect(stoppedQuery.code).toBe(1);
|
||||
expect(stoppedQuery.stderr).toContain("DAEMON_NOT_RUNNING");
|
||||
const afterA = await f.liveStatus(a);
|
||||
expect(afterA.workerPid).toBe(beforeA.workerPid);
|
||||
expect(afterA.pid).toBe(beforeA.pid);
|
||||
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("removed flags and ambiguous targets fail before side effects; observation does not create a home", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.ok(["status", "--home", home]);
|
||||
await f.ok(["daemon", "config", "get", "--home", home]);
|
||||
expect(existsSync(home)).toBe(false);
|
||||
for (const args of [
|
||||
["start", "--port", "12345"],
|
||||
["start", "--foreground"],
|
||||
["daemon", "restart", "--no-relay"],
|
||||
]) {
|
||||
const result = await f.run(["--json", ...args, "--home", home]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain("REMOVED_LAUNCH_OPTION");
|
||||
expect(existsSync(home)).toBe(false);
|
||||
}
|
||||
const ambiguous = await f.run(["--home", home, "project", "ls", "--host", "unused:12345"]);
|
||||
expect(ambiguous.stderr).toContain("Choose either --home or --host");
|
||||
expect(existsSync(home)).toBe(false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
|
||||
const f = await fixture();
|
||||
const [a, b] = f.homes as [string, string];
|
||||
const portA = await port(),
|
||||
portB = await port();
|
||||
try {
|
||||
await f.configure(a, `127.0.0.1:${portA}`);
|
||||
await f.configure(b, `127.0.0.1:${portA}`);
|
||||
await f.ok(["start", "--home", a, "--timeout", "30"]);
|
||||
const before = await f.liveStatus(a);
|
||||
const failed = await f.run(["--json", "start", "--home", b, "--timeout", "30"]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain("DAEMON_START_FAILED");
|
||||
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portB}`, "--home", b]);
|
||||
await f.ok(["start", "--home", b, "--timeout", "30"]);
|
||||
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portA}`, "--home", b]);
|
||||
const restart = await f.run(["--json", "restart", "--home", b, "--timeout", "30"]);
|
||||
expect(restart.code).toBe(1);
|
||||
expect(restart.stderr).toContain("RESTART_NOT_CONFIRMED");
|
||||
await f.ok(["daemon", "stop", "--home", b]);
|
||||
expect((await f.liveStatus(a)).workerPid).toBe(before.workerPid);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("a slow worker outlives the caller's readiness deadline and remains stoppable", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
const preload = path.join(f.root, "slow-worker.mjs");
|
||||
await writeFile(
|
||||
preload,
|
||||
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 5000));',
|
||||
);
|
||||
const launch = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
|
||||
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
|
||||
});
|
||||
expect(launch.code).toBe(1);
|
||||
expect(launch.stderr).toContain("DAEMON_NOT_READY");
|
||||
expect(launch.stderr).toContain("remains running");
|
||||
expect(launch.stderr).toContain("Logs:");
|
||||
expect(launch.stderr).toContain("Status:");
|
||||
expect(launch.stderr).toContain("Stop:");
|
||||
const pending = await f.ok(["status", "--home", home]);
|
||||
expect(pending.localDaemon).toBe("not_ready");
|
||||
expect(pending.listen).toBeNull();
|
||||
process.kill(pending.pid, 0);
|
||||
const already = await f.ok(["start", "--home", home]);
|
||||
expect(already.action).toBe("already_running");
|
||||
expect(already.pid).toBe(pending.pid);
|
||||
await f.ok(["project", "ls", "--home", home]);
|
||||
expect((await f.ok(["status", "--home", home])).pid).toBe(pending.pid);
|
||||
await f.ok(["daemon", "stop", "--home", home]);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("worker restart preserves an already-running legacy supervisor's launch flags", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
const filePort = await port(),
|
||||
launchPort = await port();
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${filePort}`);
|
||||
const launch = await startDaemonInstance({
|
||||
home,
|
||||
command: process.execPath,
|
||||
args: [
|
||||
path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js"),
|
||||
"--no-relay",
|
||||
"--no-web-ui",
|
||||
],
|
||||
// The legacy CLI translated --port into PASEO_LISTEN before spawning.
|
||||
env: { ...f.env, PASEO_LISTEN: `127.0.0.1:${launchPort}` },
|
||||
mode: "deployment",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const before = await f.liveStatus(home);
|
||||
expect(before.listen).toBe(`127.0.0.1:${launchPort}`);
|
||||
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"], {
|
||||
PASEO_LISTEN: `127.0.0.1:${filePort}`,
|
||||
});
|
||||
expect(restarted.supervisorPid).toBe(launch.instance.pid);
|
||||
expect(restarted.workerPid).not.toBe(before.workerPid);
|
||||
expect((await f.ok(["status", "--home", home])).listen).toBe(before.listen);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"POSIX stop ignores a wrong endpoint even with copied identities; supported live identity overrides still restart",
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
const [a, b] = f.homes as [string, string];
|
||||
try {
|
||||
await f.configure(a, `127.0.0.1:${await port()}`);
|
||||
await f.configure(b, `127.0.0.1:${await port()}`);
|
||||
await writeFile(path.join(b, "server-id"), "saved-b");
|
||||
await f.ok(["start", "--home", a, "--timeout", "30"]);
|
||||
await f.ok(["start", "--home", b, "--timeout", "30"], { PASEO_SERVER_ID: "live-b" });
|
||||
const beforeA = await f.liveStatus(a);
|
||||
expect((await f.liveStatus(b)).serverId).toBe("live-b");
|
||||
await f.ok(["restart", "--home", b, "--timeout", "30"]);
|
||||
const bLock = JSON.parse(await readFile(path.join(b, "paseo.pid"), "utf8"));
|
||||
await writeFile(
|
||||
path.join(b, "paseo.pid"),
|
||||
JSON.stringify({ ...bLock, listen: beforeA.listen }),
|
||||
);
|
||||
await writeFile(path.join(b, "server-id"), beforeA.serverId);
|
||||
await f.ok(["daemon", "stop", "--home", b]);
|
||||
expect((await f.liveStatus(a)).workerPid).toBe(beforeA.workerPid);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
|
||||
"explicit cancellation before readiness cleans up only this launch: %j",
|
||||
async (...launchArgs) => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
const preload = path.join(f.root, "slow-worker.mjs");
|
||||
await writeFile(
|
||||
preload,
|
||||
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
|
||||
);
|
||||
child = spawn(process.execPath, [cli, ...launchArgs, "--home", home], {
|
||||
cwd: repo,
|
||||
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
|
||||
stdio: "ignore",
|
||||
});
|
||||
const exited = new Promise((resolve) => child!.once("exit", resolve));
|
||||
await expect
|
||||
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
|
||||
.toBe(true);
|
||||
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
||||
await f.ok(["status", "--home", home]);
|
||||
child.kill("SIGINT");
|
||||
await exited;
|
||||
expect(() => process.kill(lock.pid, 0)).toThrow();
|
||||
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
|
||||
} finally {
|
||||
child?.kill("SIGTERM");
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.runIf(process.platform === "win32")(
|
||||
"Windows unbound stop requires explicit force",
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
const preload = path.join(f.root, "slow-worker.mjs");
|
||||
await writeFile(
|
||||
preload,
|
||||
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
|
||||
);
|
||||
const pending = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
|
||||
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
|
||||
});
|
||||
expect(pending.stderr).toContain("DAEMON_NOT_READY");
|
||||
const refused = await f.run(["--json", "daemon", "stop", "--home", home]);
|
||||
expect(refused.code).toBe(1);
|
||||
expect(refused.stderr).toContain("STOP_NO_GRACEFUL_CHANNEL");
|
||||
const stopped = await f.ok(["daemon", "stop", "--home", home, "--force"]);
|
||||
expect(stopped.forced).toBe(true);
|
||||
expect(stopped.usedLifecycleRpc).toBe(false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("empty explicit selectors never select the ambient daemon or create local state", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
await f.ok(["start", "--home", home]);
|
||||
const before = await f.liveStatus(home);
|
||||
for (const args of [
|
||||
["daemon", "stop", "--home", ""],
|
||||
["daemon", "stop", "--host", ""],
|
||||
["start", "--home", ""],
|
||||
["daemon", "config", "set", "daemon.relay.enabled", "true", "--home", ""],
|
||||
]) {
|
||||
const refused = await f.run(["--json", ...args], { PASEO_HOME: home });
|
||||
expect(refused.code).toBe(1);
|
||||
expect(refused.stderr).toContain("TARGET_INVALID");
|
||||
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
|
||||
expect(existsSync(path.join(f.root, "paseo.pid"))).toBe(false);
|
||||
expect(existsSync(path.join(f.root, "config.json"))).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("IPv6 publication supports home-selected query and worker restart", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
const endpoint = `[::1]:${await port()}`;
|
||||
try {
|
||||
await f.configure(home, endpoint);
|
||||
const launch = await f.ok(["start", "--home", home, "--timeout", "30"]);
|
||||
expect(launch.listen).toBe(endpoint);
|
||||
expect(await f.ok(["project", "ls", "--home", home])).toEqual([]);
|
||||
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]);
|
||||
expect(restarted.supervisorPid).toBe(launch.pid);
|
||||
expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("raw log following subscribes to a stored agent that exists only in B", async () => {
|
||||
const f = await fixture();
|
||||
const [a, b] = f.homes as [string, string];
|
||||
let follower: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await f.configure(a, `127.0.0.1:${await port()}`);
|
||||
await f.configure(b, `127.0.0.1:${await port()}`);
|
||||
const agentId = "4de8b157-7614-4c38-b189-e50e57fb9498";
|
||||
const now = new Date().toISOString();
|
||||
await mkdir(path.join(b, "agents"));
|
||||
await writeFile(
|
||||
path.join(b, "agents", `${agentId}.json`),
|
||||
JSON.stringify({
|
||||
id: agentId,
|
||||
provider: "codex",
|
||||
cwd: f.root,
|
||||
title: "B-only stored agent",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastStatus: "closed",
|
||||
persistence: null,
|
||||
}),
|
||||
);
|
||||
const launchA = await f.ok(["start", "--home", a]);
|
||||
await f.ok(["start", "--home", b]);
|
||||
const absent = await f.run(["logs", agentId, "--home", a]);
|
||||
expect(absent.stderr).toContain("Agent not found");
|
||||
follower = spawn(
|
||||
process.execPath,
|
||||
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
|
||||
{
|
||||
cwd: f.root,
|
||||
env: { ...f.env, PASEO_HOME: a, PASEO_HOST: launchA.listen },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let output = "",
|
||||
errors = "";
|
||||
follower.stdout!.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
follower.stderr!.on("data", (data) => {
|
||||
errors += data;
|
||||
});
|
||||
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
|
||||
expect(errors).not.toContain("Agent not found");
|
||||
} finally {
|
||||
if (follower && follower.exitCode === null) {
|
||||
const exited = new Promise((resolve) => follower!.once("exit", resolve));
|
||||
follower.kill("SIGINT");
|
||||
await exited;
|
||||
}
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a connection pinned to an earlier supervisor refuses its replacement", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
await f.ok(["start", "--home", home]);
|
||||
const captured = (await readDaemonInstance(home))!;
|
||||
await f.ok(["daemon", "stop", "--home", home]);
|
||||
await f.ok(["start", "--home", home]);
|
||||
const before = await f.liveStatus(home);
|
||||
await expect(
|
||||
connectToDaemon({ target: { kind: "instance", home }, instance: captured, timeout: 1000 }),
|
||||
).rejects.toMatchObject({ code: "DAEMON_REPLACED" });
|
||||
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("foreground deployment retains environment until its owner ends the launch; endpoint stop reports acknowledgment", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
const fileEndpoint = `127.0.0.1:${await port()}`,
|
||||
deploymentEndpoint = `127.0.0.1:${await port()}`,
|
||||
changedEndpoint = `127.0.0.1:${await port()}`;
|
||||
let deployment: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await f.configure(home, fileEndpoint);
|
||||
deployment = spawn(process.execPath, [cli, "daemon", "run", "--home", home], {
|
||||
cwd: f.root,
|
||||
env: { ...f.env, PASEO_LISTEN: deploymentEndpoint, PASEO_RELAY_ENABLED: "false" },
|
||||
stdio: "ignore",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await readDaemonInstance(home))?.listen, { timeout: 30_000 })
|
||||
.toBe(deploymentEndpoint);
|
||||
const before = await f.liveStatus(home);
|
||||
expect(before.configuredListen).toBe(fileEndpoint);
|
||||
expect(await f.ok(["daemon", "run", "--home", home])).toMatchObject({
|
||||
action: "already_running",
|
||||
pid: before.pid,
|
||||
});
|
||||
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
|
||||
const changed = await f.ok([
|
||||
"daemon",
|
||||
"config",
|
||||
"set",
|
||||
"daemon.listen",
|
||||
changedEndpoint,
|
||||
"--home",
|
||||
home,
|
||||
]);
|
||||
expect(changed.overrideControlledPaths).toContain("daemon.listen");
|
||||
const restarted = await f.ok(["restart", "--home", home], { PASEO_LISTEN: fileEndpoint });
|
||||
expect(restarted.supervisorPid).toBe(before.pid);
|
||||
expect((await f.ok(["status", "--home", home])).listen).toBe(deploymentEndpoint);
|
||||
const exited = new Promise((resolve) => deployment!.once("exit", resolve));
|
||||
const remote = await f.ok(["daemon", "stop", "--host", deploymentEndpoint]);
|
||||
expect(remote.action).toBe("shutdown_requested");
|
||||
await exited;
|
||||
const managed = await f.ok(["start", "--home", home]);
|
||||
expect(managed.listen).toBe(changedEndpoint);
|
||||
expect(managed.pid).not.toBe(before.pid);
|
||||
} finally {
|
||||
if (deployment?.exitCode === null) deployment.kill("SIGTERM");
|
||||
await f.close();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("malformed configuration cannot turn a readiness timeout into launch cancellation", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
let starter: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
const configPath = path.join(home, "config.json");
|
||||
const saved = await readFile(configPath, "utf8");
|
||||
const preload = path.join(f.root, "slow-worker.mjs");
|
||||
await writeFile(
|
||||
preload,
|
||||
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
|
||||
);
|
||||
starter = spawn(process.execPath, [cli, "--json", "start", "--home", home, "--timeout", "2"], {
|
||||
cwd: f.root,
|
||||
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let errors = "";
|
||||
starter.stderr!.on("data", (data) => {
|
||||
errors += data;
|
||||
});
|
||||
const exited = new Promise((resolve) => starter!.once("exit", resolve));
|
||||
await expect
|
||||
.poll(async () => (await readDaemonInstance(home))?.pid, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
const captured = (await readDaemonInstance(home))!;
|
||||
await f.ok(["status", "--home", home]);
|
||||
await writeFile(configPath, "{");
|
||||
await exited;
|
||||
expect(errors).toContain("DAEMON_NOT_READY");
|
||||
expect(errors).toContain(path.join(home, "daemon.log"));
|
||||
process.kill(captured.pid, 0);
|
||||
await writeFile(configPath, saved);
|
||||
await f.ok(["status", "--home", home]);
|
||||
} finally {
|
||||
if (starter?.exitCode === null) starter.kill("SIGTERM");
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
await f.ok(["daemon", "config", "set", "daemon.mcp.enabled", "false", "--home", home]);
|
||||
for (const flags of [[], ["--no-mcp"]]) {
|
||||
const onboard = await f.run(["onboard", "--home", home, "--voice", "disable", ...flags]);
|
||||
expect(onboard.code, onboard.stderr).toBe(0);
|
||||
expect(
|
||||
(await f.ok(["daemon", "config", "get", "daemon.mcp.enabled", "--home", home])).value,
|
||||
).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -1,214 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type DaemonLaunchRuntime,
|
||||
type DetachedDaemonProcess,
|
||||
resolveLocalDaemonState,
|
||||
startLocalDaemonDetached,
|
||||
startLocalDaemonForeground,
|
||||
} from "./local-daemon.js";
|
||||
|
||||
type RecordedDaemonLaunch =
|
||||
| {
|
||||
mode: "detached";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
|
||||
}
|
||||
| {
|
||||
mode: "foreground";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
|
||||
};
|
||||
|
||||
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
|
||||
pid = 4242;
|
||||
wasUnreferenced = false;
|
||||
|
||||
unref(): void {
|
||||
this.wasUnreferenced = true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonRuntime implements DaemonLaunchRuntime {
|
||||
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
|
||||
readonly daemonProcess = new FakeDaemonProcess();
|
||||
foregroundStatus = 0;
|
||||
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
|
||||
|
||||
resolveRunnerEntry(): string {
|
||||
return this.runnerEntry;
|
||||
}
|
||||
|
||||
resolveHome(env: NodeJS.ProcessEnv): string {
|
||||
return env.PASEO_HOME ?? "/tmp/paseo";
|
||||
}
|
||||
|
||||
spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
|
||||
): DetachedDaemonProcess {
|
||||
this.recordedLaunches.push({ mode: "detached", command, args, options });
|
||||
return this.daemonProcess;
|
||||
}
|
||||
|
||||
spawnForeground(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
|
||||
) {
|
||||
this.recordedLaunches.push({ mode: "foreground", command, args, options });
|
||||
return { status: this.foregroundStatus, error: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function createPaseoHome(config: unknown): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-"));
|
||||
tempRoots.push(root);
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
function expectSupervisorLaunch(argv: string[]): void {
|
||||
const joined = argv.join(" ");
|
||||
expect(joined).toContain("supervisor-entrypoint");
|
||||
expect(joined).not.toContain("src/server/index.ts");
|
||||
expect(joined).not.toContain("dist/server/server/index.js");
|
||||
expect(joined).not.toContain("src/server/daemon-worker.ts");
|
||||
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
|
||||
}
|
||||
|
||||
describe("local daemon launch supervision", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-relay");
|
||||
});
|
||||
|
||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const resultPromise = startLocalDaemonDetached(
|
||||
{ home: "/tmp/paseo-test", mcp: false },
|
||||
runtime,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("detached");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-mcp");
|
||||
});
|
||||
|
||||
test("relay TLS flag is passed to the supervised daemon", async () => {
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const status = startLocalDaemonForeground(
|
||||
{
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.args).toContain("--relay-use-tls");
|
||||
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
});
|
||||
|
||||
test("web UI flag is passed to the supervised daemon", async () => {
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const status = startLocalDaemonForeground(
|
||||
{
|
||||
home: "/tmp/paseo-test",
|
||||
webUi: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.args).toContain("--web-ui");
|
||||
expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true");
|
||||
});
|
||||
|
||||
test("no-web UI flag is passed to the supervised daemon", async () => {
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const status = startLocalDaemonForeground(
|
||||
{
|
||||
home: "/tmp/paseo-test",
|
||||
webUi: false,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.args).toContain("--no-web-ui");
|
||||
expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false");
|
||||
});
|
||||
|
||||
test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
relay: {
|
||||
endpoint: "10.0.0.5:51185",
|
||||
publicEndpoint: "paseo.example.com",
|
||||
useTls: false,
|
||||
publicUseTls: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const state = resolveLocalDaemonState({ home });
|
||||
|
||||
expect(state.relayEndpoint).toBe("paseo.example.com");
|
||||
expect(state.relayUseTls).toBe(false);
|
||||
expect(state.relayPublicUseTls).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,184 +1,9 @@
|
||||
import { spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { Command, Option } from "commander";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||
import treeKill from "tree-kill";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
export interface DaemonStartOptions {
|
||||
port?: string;
|
||||
listen?: string;
|
||||
home?: string;
|
||||
foreground?: boolean;
|
||||
relay?: boolean;
|
||||
relayUseTls?: boolean;
|
||||
mcp?: boolean;
|
||||
injectMcp?: boolean;
|
||||
webUi?: boolean;
|
||||
hostnames?: string;
|
||||
}
|
||||
|
||||
export interface LocalDaemonPidInfo {
|
||||
pid: number;
|
||||
startedAt?: string;
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
desktopManaged?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalDaemonState {
|
||||
home: string;
|
||||
listen: string;
|
||||
relayEnabled: boolean;
|
||||
relayEndpoint: string;
|
||||
relayUseTls: boolean;
|
||||
relayPublicUseTls: boolean;
|
||||
logPath: string;
|
||||
pidPath: string;
|
||||
pidInfo: LocalDaemonPidInfo | null;
|
||||
running: boolean;
|
||||
stalePidFile: boolean;
|
||||
}
|
||||
|
||||
export interface DetachedStartResult {
|
||||
pid: number | null;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface StopLocalDaemonOptions {
|
||||
home?: string;
|
||||
timeoutMs?: number;
|
||||
killTimeoutMs?: number;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface StopLocalDaemonResult {
|
||||
action: "stopped" | "not_running";
|
||||
home: string;
|
||||
pid: number | null;
|
||||
forced: boolean;
|
||||
usedLifecycleRpc: boolean;
|
||||
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ProcessExitDetails {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
||||
|
||||
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
|
||||
|
||||
export interface ForegroundDaemonProcessResult {
|
||||
status: number | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface DaemonLaunchRuntime {
|
||||
resolveRunnerEntry(): string;
|
||||
resolveHome(env: NodeJS.ProcessEnv): string;
|
||||
spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof spawnProcess>[2],
|
||||
): DetachedDaemonProcess;
|
||||
spawnForeground(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof spawnSync>[2],
|
||||
): ForegroundDaemonProcessResult;
|
||||
}
|
||||
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const PID_POLL_INTERVAL_MS = 100;
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
|
||||
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
|
||||
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||
|
||||
import { startDaemonInstance, resolvePaseoHome } from "@getpaseo/server";
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
|
||||
resolveRunnerEntry: resolveDaemonRunnerEntry,
|
||||
resolveHome: resolvePaseoHome,
|
||||
spawnDetached: spawnProcess,
|
||||
spawnForeground: spawnSync,
|
||||
};
|
||||
|
||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
||||
|
||||
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
||||
exitedEarly: true,
|
||||
...details,
|
||||
});
|
||||
|
||||
function envWithHome(home?: string): NodeJS.ProcessEnv {
|
||||
if (!home) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
return { ...process.env, PASEO_HOME: home };
|
||||
}
|
||||
|
||||
function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.relay === true) {
|
||||
args.push("--relay");
|
||||
}
|
||||
if (options.relay === false) {
|
||||
args.push("--no-relay");
|
||||
}
|
||||
if (options.relayUseTls === true) {
|
||||
args.push("--relay-use-tls");
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
args.push("--no-mcp");
|
||||
}
|
||||
if (options.injectMcp === false) {
|
||||
args.push("--no-inject-mcp");
|
||||
}
|
||||
if (options.webUi === true) {
|
||||
args.push("--web-ui");
|
||||
}
|
||||
if (options.webUi === false) {
|
||||
args.push("--no-web-ui");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home;
|
||||
}
|
||||
if (options.listen) {
|
||||
childEnv.PASEO_LISTEN = options.listen;
|
||||
} else if (options.port) {
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
|
||||
}
|
||||
if (options.hostnames) {
|
||||
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
||||
}
|
||||
if (options.relayUseTls === true) {
|
||||
childEnv.PASEO_RELAY_USE_TLS = "true";
|
||||
}
|
||||
if (options.webUi === true) {
|
||||
childEnv.PASEO_WEB_UI_ENABLED = "true";
|
||||
}
|
||||
if (options.webUi === false) {
|
||||
childEnv.PASEO_WEB_UI_ENABLED = "false";
|
||||
}
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
function resolveServerRunnerFromDir(currentDir: string): string | null {
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
@@ -215,550 +40,72 @@ function resolveDaemonRunnerEntry(): string {
|
||||
throw new Error("Unable to resolve @getpaseo/server package root for daemon runner");
|
||||
}
|
||||
|
||||
function pidFilePath(paseoHome: string): string {
|
||||
return path.join(paseoHome, DAEMON_PID_FILENAME);
|
||||
}
|
||||
|
||||
function resolveListenField(listen: unknown, sockPath: unknown): string | undefined {
|
||||
if (typeof listen === "string") return listen;
|
||||
if (typeof sockPath === "string") return sockPath;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveStopMessage(
|
||||
forced: boolean,
|
||||
lifecycleRequested: boolean,
|
||||
fallbackMessage: string | null | undefined,
|
||||
): string {
|
||||
if (forced) return "Daemon owner process was force-stopped";
|
||||
if (lifecycleRequested) return "Daemon stopped gracefully";
|
||||
return fallbackMessage ?? "Daemon stopped via owner PID signal";
|
||||
}
|
||||
|
||||
function resolveStopReason(
|
||||
forced: boolean,
|
||||
lifecycleRequested: boolean,
|
||||
): StopLocalDaemonResult["reason"] {
|
||||
if (forced) return "owner_pid_sigkill";
|
||||
if (lifecycleRequested) return "lifecycle_shutdown_rpc";
|
||||
return "owner_pid_signal";
|
||||
}
|
||||
|
||||
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
export async function launchLocalDaemon(options: {
|
||||
home: string;
|
||||
timeoutMs?: number;
|
||||
foreground?: boolean;
|
||||
}) {
|
||||
const abort = new AbortController();
|
||||
const cancel = () => abort.abort();
|
||||
process.once("SIGINT", cancel);
|
||||
process.once("SIGTERM", cancel);
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
|
||||
const pidValue = parsed.pid;
|
||||
if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pid: pidValue,
|
||||
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
|
||||
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
||||
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
||||
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, lines = 30): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
const entry = resolveDaemonRunnerEntry();
|
||||
return await startDaemonInstance({
|
||||
home: resolvePaseoHome({ PASEO_HOME: options.home }),
|
||||
command: process.execPath,
|
||||
args: [...(entry.endsWith(".ts") ? ["--import", "tsx"] : []), entry],
|
||||
env: process.env,
|
||||
mode: options.foreground ? "deployment" : "managed",
|
||||
foreground: options.foreground,
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: abort.signal,
|
||||
onReady: options.foreground
|
||||
? (instance) =>
|
||||
process.stdout.write(`Listening on ${instance.listen} (PID ${instance.pid})\n`)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function readNodeErrnoCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typeof error.code === "string" ? error.code : undefined;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return signalProcess(pid, signal);
|
||||
} catch (err) {
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise<boolean> {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
treeKill(pid, signal, (err) => {
|
||||
if (!err) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = readNodeErrnoCode(err);
|
||||
if (code === "ESRCH") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (code === "EPERM") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function signalProcessTreeOrOwnerSafely(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await signalProcessTreeSafely(pid, signal);
|
||||
} catch {
|
||||
return signalProcessSafely(pid, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
async function poll(): Promise<boolean> {
|
||||
if (!isProcessRunning(pid)) return true;
|
||||
if (Date.now() >= deadline) return !isProcessRunning(pid);
|
||||
await sleep(PID_POLL_INTERVAL_MS);
|
||||
return poll();
|
||||
}
|
||||
return poll();
|
||||
}
|
||||
|
||||
async function waitForDaemonUnreachable(
|
||||
state: LocalDaemonState,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
if (!host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const reachableHost = host;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
async function poll(): Promise<boolean> {
|
||||
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
|
||||
if (!client) {
|
||||
return true;
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
if (Date.now() >= deadline) {
|
||||
const finalClient = await tryConnectToDaemon({
|
||||
host: reachableHost,
|
||||
timeout: PID_POLL_INTERVAL_MS,
|
||||
});
|
||||
if (!finalClient) {
|
||||
return true;
|
||||
}
|
||||
await finalClient.close().catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
await sleep(PID_POLL_INTERVAL_MS);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
function removeStalePidFile(state: LocalDaemonState): void {
|
||||
if (!state.stalePidFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(state.pidPath);
|
||||
} catch {
|
||||
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function createNotRunningStopResult(
|
||||
state: LocalDaemonState,
|
||||
pid: number | null,
|
||||
message: string,
|
||||
): StopLocalDaemonResult {
|
||||
return {
|
||||
action: "not_running",
|
||||
home: state.home,
|
||||
pid,
|
||||
forced: false,
|
||||
usedLifecycleRpc: false,
|
||||
reason: "not_running",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function createStopTimeoutError(
|
||||
state: LocalDaemonState,
|
||||
pid: number | null,
|
||||
timeoutMs: number,
|
||||
): Error {
|
||||
if (!state.running) {
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
return new Error(
|
||||
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
|
||||
timeoutMs / 1000,
|
||||
)}s`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
async function signalDaemonOwnerForStop(
|
||||
state: LocalDaemonState,
|
||||
pid: number | null,
|
||||
): Promise<StopLocalDaemonResult | null> {
|
||||
if (pid === null) {
|
||||
return createNotRunningStopResult(state, null, "Daemon is not running");
|
||||
}
|
||||
|
||||
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
|
||||
if (signaled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
|
||||
}
|
||||
|
||||
async function waitForStopAfterRequest(args: {
|
||||
state: LocalDaemonState;
|
||||
pid: number | null;
|
||||
timeoutMs: number;
|
||||
killTimeoutMs: number;
|
||||
force?: boolean;
|
||||
}): Promise<{ stopped: boolean; forced: boolean }> {
|
||||
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
|
||||
let stopped =
|
||||
state.running && pid !== null
|
||||
? await waitForPidExit(pid, timeoutMs)
|
||||
: await waitForDaemonUnreachable(state, timeoutMs);
|
||||
|
||||
if (!stopped && force && state.running && pid !== null) {
|
||||
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
|
||||
stopped = await waitForPidExit(pid, killTimeoutMs);
|
||||
return { stopped, forced: true };
|
||||
}
|
||||
|
||||
return { stopped, forced: false };
|
||||
}
|
||||
|
||||
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function resolveLocalPaseoHome(home?: string): string {
|
||||
return resolvePaseoHome(envWithHome(home));
|
||||
}
|
||||
|
||||
export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
const normalized = listen.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return `127.0.0.1:${normalized}`;
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...envWithHome(options.home),
|
||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||
// This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available.
|
||||
PASEO_LISTEN: undefined,
|
||||
PASEO_HOSTNAMES: undefined,
|
||||
PASEO_ALLOWED_HOSTS: undefined,
|
||||
PASEO_RELAY_ENABLED: undefined,
|
||||
PASEO_RELAY_ENDPOINT: undefined,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: undefined,
|
||||
PASEO_RELAY_USE_TLS: undefined,
|
||||
PASEO_RELAY_PUBLIC_USE_TLS: undefined,
|
||||
};
|
||||
const home = resolvePaseoHome(env);
|
||||
const config = loadConfig(home, { env });
|
||||
const pidPath = pidFilePath(home);
|
||||
const logPath = path.join(home, DAEMON_LOG_FILENAME);
|
||||
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
|
||||
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
|
||||
const listen = pidInfo?.listen ?? config.listen;
|
||||
|
||||
return {
|
||||
home,
|
||||
listen,
|
||||
relayEnabled: config.relayEnabled ?? true,
|
||||
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
|
||||
relayUseTls: config.relayUseTls ?? false,
|
||||
relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false,
|
||||
logPath,
|
||||
pidPath,
|
||||
pidInfo,
|
||||
running,
|
||||
stalePidFile: Boolean(pidInfo) && !running,
|
||||
};
|
||||
}
|
||||
|
||||
export function tailDaemonLog(home?: string, lines = 30): string | null {
|
||||
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
|
||||
return tailFile(logPath, lines);
|
||||
}
|
||||
|
||||
export async function startLocalDaemonDetached(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): Promise<DetachedStartResult> {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
|
||||
const paseoHome = runtime.resolveHome(childEnv);
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||
const child = runtime.spawnDetached(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
detached: true,
|
||||
envMode: "internal",
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
);
|
||||
|
||||
child.unref();
|
||||
|
||||
const startup = await new Promise<DetachedStartupResult>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: DetachedStartupResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
|
||||
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
finish(startupExited({ code: null, signal: null, error }));
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
finish(startupExited({ code, signal }));
|
||||
});
|
||||
});
|
||||
|
||||
if (startup.exitedEarly) {
|
||||
const reason = startup.error
|
||||
? startup.error.message
|
||||
: `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`;
|
||||
const recentLogs = tailFile(logPath);
|
||||
throw new Error(
|
||||
[
|
||||
`Daemon failed to start in background (${reason}).`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid ?? null,
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalDaemonForeground(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): number {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
const result = runtime.spawnForeground(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
async function requestLifecycleShutdown(
|
||||
state: LocalDaemonState,
|
||||
timeoutMs: number,
|
||||
): Promise<LifecycleShutdownAttempt> {
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
if (!host) {
|
||||
return {
|
||||
requested: false,
|
||||
reason: "daemon listen target is not TCP, falling back to owner PID signal",
|
||||
};
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
||||
const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) });
|
||||
if (!client) {
|
||||
return {
|
||||
requested: false,
|
||||
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) });
|
||||
return { requested: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
requested: false,
|
||||
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
|
||||
error,
|
||||
)}), falling back to owner PID signal`,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
process.removeListener("SIGINT", cancel);
|
||||
process.removeListener("SIGTERM", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopLocalDaemon(
|
||||
options: StopLocalDaemonOptions = {},
|
||||
): Promise<StopLocalDaemonResult> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
||||
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||
const state = resolveLocalDaemonState({ home: options.home });
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
||||
|
||||
const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs());
|
||||
const lifecycleRequested = shutdownAttempt.requested;
|
||||
|
||||
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
|
||||
const staleSuffix =
|
||||
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
|
||||
return createNotRunningStopResult(
|
||||
state,
|
||||
state.pidInfo?.pid ?? null,
|
||||
`Daemon is not running${staleSuffix}`,
|
||||
);
|
||||
export function parseTimeoutMs(raw: unknown, fallback = 600_000): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0)
|
||||
throw { code: "INVALID_TIMEOUT", message: "Timeout must be a positive number of seconds." };
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
const pid = state.pidInfo?.pid ?? null;
|
||||
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
|
||||
if (!lifecycleRequested) {
|
||||
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
|
||||
if (notRunningResult) return notRunningResult;
|
||||
}
|
||||
const REMOVED_LAUNCH_FLAGS: Record<string, string> = {
|
||||
"--port <port>": "daemon.listen",
|
||||
"--listen <listen>": "daemon.listen",
|
||||
"--relay": "daemon.relay.enabled",
|
||||
"--no-relay": "daemon.relay.enabled",
|
||||
"--relay-use-tls": "daemon.relay.useTls",
|
||||
"--no-mcp": "daemon.mcp.enabled",
|
||||
"--no-inject-mcp": "daemon.mcp.injectIntoAgents",
|
||||
"--web-ui": "features.webUi.enabled",
|
||||
"--no-web-ui": "features.webUi.enabled",
|
||||
"--hostnames <hosts>": "daemon.hostnames",
|
||||
"--allowed-hosts <hosts>": "daemon.hostnames",
|
||||
"--foreground": "",
|
||||
};
|
||||
|
||||
const { stopped, forced } = await waitForStopAfterRequest({
|
||||
state,
|
||||
pid,
|
||||
timeoutMs: remainingTimeoutMs(),
|
||||
killTimeoutMs,
|
||||
force: options.force,
|
||||
});
|
||||
if (!stopped) {
|
||||
throw createStopTimeoutError(state, pid, timeoutMs);
|
||||
}
|
||||
|
||||
if (lifecycleRequested) {
|
||||
removeStalePidFile(state);
|
||||
}
|
||||
|
||||
return {
|
||||
action: "stopped",
|
||||
home: state.home,
|
||||
pid,
|
||||
forced,
|
||||
usedLifecycleRpc: lifecycleRequested,
|
||||
reason: resolveStopReason(forced, lifecycleRequested),
|
||||
message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage),
|
||||
export function rejectRemovedLaunchFlags(command: Command): Command {
|
||||
for (const flag of Object.keys(REMOVED_LAUNCH_FLAGS))
|
||||
command.addOption(new Option(flag).hideHelp());
|
||||
command.hook("preAction", () => {
|
||||
for (const [flag, configPath] of Object.entries(REMOVED_LAUNCH_FLAGS)) {
|
||||
const name = new Option(flag).attributeName();
|
||||
if (command.getOptionValueSource(name) !== "cli") continue;
|
||||
throw {
|
||||
code: "REMOVED_LAUNCH_OPTION",
|
||||
message: `${flag.split(" ")[0]} was removed. ${configPath ? `Use paseo daemon config set ${configPath} <value> --home <path>, then start or restart.` : "Use paseo daemon run --home <path> for foreground deployment."} Deployment environment overrides belong to paseo daemon run.`,
|
||||
};
|
||||
}
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -1,139 +1,28 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "vitest";
|
||||
import { resolveLocalPairingOffer } from "./pair.js";
|
||||
|
||||
import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js";
|
||||
|
||||
const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null };
|
||||
const enabledOffer: PairingOffer = {
|
||||
relayEnabled: true,
|
||||
url: "https://app.paseo.sh/#offer=test",
|
||||
qr: null,
|
||||
};
|
||||
|
||||
interface RecordedPairCommandOutput extends PairCommandOutput {
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
successes: string[];
|
||||
exitCode: number | undefined;
|
||||
test("offline pairing requires relay consent and saves it in the selected home", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-offline-pair-"));
|
||||
const home = path.join(root, "home");
|
||||
try {
|
||||
expect(await resolveLocalPairingOffer({ paseoHome: home })).toMatchObject({
|
||||
relayEnabled: false,
|
||||
url: null,
|
||||
});
|
||||
expect(existsSync(home)).toBe(false);
|
||||
const offer = await resolveLocalPairingOffer({ paseoHome: home, enableRelay: true });
|
||||
expect(offer.relayEnabled).toBe(true);
|
||||
expect(offer.url).toContain("offer=");
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).daemon.relay.enabled,
|
||||
).toBe(true);
|
||||
expect(existsSync(path.join(home, "server-id"))).toBe(true);
|
||||
expect(existsSync(path.join(home, "daemon-keypair.json"))).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createRecordedOutput(): RecordedPairCommandOutput {
|
||||
return {
|
||||
columns: 80,
|
||||
stdout: [],
|
||||
stderr: [],
|
||||
successes: [],
|
||||
exitCode: undefined,
|
||||
writeStdout(message) {
|
||||
this.stdout.push(message);
|
||||
},
|
||||
writeStderr(message) {
|
||||
this.stderr.push(message);
|
||||
},
|
||||
setExitCode(code) {
|
||||
this.exitCode = code;
|
||||
},
|
||||
success(message) {
|
||||
this.successes.push(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("daemon pair workflow", () => {
|
||||
test("interactive decline prints direct guidance and creates no pairing output", async () => {
|
||||
const resolveOffer = vi.fn(async () => disabledOffer);
|
||||
const confirmRelay = vi.fn(async () => false);
|
||||
const printDirectGuidance = vi.fn();
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{},
|
||||
{ resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output },
|
||||
);
|
||||
|
||||
expect(confirmRelay).toHaveBeenCalledOnce();
|
||||
expect(printDirectGuidance).toHaveBeenCalledOnce();
|
||||
expect(output.stderr.join("")).toContain("No pairing QR was created");
|
||||
expect(output.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("interactive consent enables relay and prints the refreshed offer", async () => {
|
||||
const resolveOffer = vi
|
||||
.fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>()
|
||||
.mockResolvedValueOnce(disabledOffer)
|
||||
.mockResolvedValueOnce(enabledOffer);
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{},
|
||||
{
|
||||
resolveOffer,
|
||||
confirmRelay: async () => true,
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => true,
|
||||
output,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true }));
|
||||
expect(output.stdout.join("")).toContain(enabledOffer.url ?? "");
|
||||
expect(output.successes).toEqual(["Relay enabled"]);
|
||||
});
|
||||
|
||||
test("JSON mode never prompts and returns a structured relay-disabled error", async () => {
|
||||
const confirmRelay = vi.fn(async () => true);
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{ json: true },
|
||||
{
|
||||
resolveOffer: async () => disabledOffer,
|
||||
confirmRelay,
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => true,
|
||||
output,
|
||||
},
|
||||
);
|
||||
|
||||
expect(confirmRelay).not.toHaveBeenCalled();
|
||||
expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"');
|
||||
expect(output.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("explicit relay opts in without prompting", async () => {
|
||||
const resolveOffer = vi.fn(async () => enabledOffer);
|
||||
const confirmRelay = vi.fn(async () => false);
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{ relay: true, json: true },
|
||||
{
|
||||
resolveOffer,
|
||||
confirmRelay,
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => false,
|
||||
output,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true }));
|
||||
expect(confirmRelay).not.toHaveBeenCalled();
|
||||
expect(output.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
test("surfaces launch-override rejection", async () => {
|
||||
await expect(
|
||||
runPairCommand(
|
||||
{ relay: true },
|
||||
{
|
||||
resolveOffer: async () => {
|
||||
throw new Error("Relay is controlled by a daemon launch override");
|
||||
},
|
||||
confirmRelay: vi.fn(),
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => false,
|
||||
output: createRecordedOutput(),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("launch override");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,29 +3,23 @@ import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
generateLocalPairingOffer,
|
||||
getOrCreateServerId,
|
||||
loadConfig,
|
||||
resolvePaseoHome,
|
||||
readDaemonInstance,
|
||||
readPersistedConfig,
|
||||
editPersistedConfig,
|
||||
resolveConfigFromPersisted,
|
||||
} from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import { resolveLocalDaemonState } from "./local-daemon.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
|
||||
import { formatPairingInstructions } from "../../output/pairing.js";
|
||||
|
||||
interface PairOptions {
|
||||
daemonTarget: DaemonTarget;
|
||||
home?: string;
|
||||
json?: boolean;
|
||||
relay?: boolean;
|
||||
}
|
||||
|
||||
export interface PairCommandDependencies {
|
||||
resolveOffer: typeof resolveLocalPairingOffer;
|
||||
confirmRelay: typeof confirmRelayPairing;
|
||||
printDirectGuidance: typeof printDirectConnectionGuidance;
|
||||
isInteractive: () => boolean;
|
||||
output: PairCommandOutput;
|
||||
}
|
||||
|
||||
export interface PairCommandOutput {
|
||||
columns: number | undefined;
|
||||
writeStdout(message: string): void;
|
||||
@@ -62,33 +56,32 @@ function createProcessOutput(): PairCommandOutput {
|
||||
}
|
||||
|
||||
export function pairCommand(): Command {
|
||||
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
return addJsonAndDaemonHostOptions(
|
||||
new Command("pair").description("Print the daemon pairing QR code and link"),
|
||||
)
|
||||
.option("--relay", "Enable relay without prompting")
|
||||
.action(async (_options: PairOptions, command: Command) => {
|
||||
await runPairCommand(command.optsWithGlobals());
|
||||
});
|
||||
.action(
|
||||
withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveLocalPairingOffer(options: {
|
||||
paseoHome: string;
|
||||
enableRelay?: boolean;
|
||||
}): Promise<PairingOffer> {
|
||||
const state = resolveLocalDaemonState({ home: options.paseoHome });
|
||||
const serverId = getOrCreateServerId(state.home);
|
||||
const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay);
|
||||
if (daemonOffer) return daemonOffer;
|
||||
|
||||
if (state.running) {
|
||||
throw new Error(
|
||||
"The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.",
|
||||
const instance = await readDaemonInstance(options.paseoHome);
|
||||
if (instance)
|
||||
return resolveDaemonPairingOffer(
|
||||
{ kind: "instance", home: options.paseoHome },
|
||||
options.enableRelay,
|
||||
);
|
||||
if (options.enableRelay)
|
||||
editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true });
|
||||
const config = resolveConfigFromPersisted(
|
||||
options.paseoHome,
|
||||
readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }),
|
||||
{ env: {} },
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig(options.paseoHome);
|
||||
if (options.enableRelay && !config.relayEnabled) {
|
||||
throw new Error("Start the daemon before enabling relay for pairing.");
|
||||
}
|
||||
|
||||
return generateLocalPairingOffer({
|
||||
paseoHome: options.paseoHome,
|
||||
@@ -103,23 +96,16 @@ export async function resolveLocalPairingOffer(options: {
|
||||
}
|
||||
|
||||
async function resolveDaemonPairingOffer(
|
||||
listen: string,
|
||||
expectedServerId: string,
|
||||
target: DaemonTarget,
|
||||
enableRelay: boolean | undefined,
|
||||
): Promise<PairingOffer | null> {
|
||||
const client = await tryConnectToDaemon({
|
||||
host: listen,
|
||||
): Promise<PairingOffer> {
|
||||
const client = await connectToDaemon({
|
||||
target,
|
||||
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
||||
});
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (serverInfo?.serverId.trim() !== expectedServerId) {
|
||||
throw new Error(
|
||||
"The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.",
|
||||
);
|
||||
}
|
||||
if (serverInfo?.features?.daemonStatusRpc !== true) {
|
||||
throw new Error("Update the Paseo daemon before pairing from this command.");
|
||||
}
|
||||
@@ -132,9 +118,14 @@ async function resolveDaemonPairingOffer(
|
||||
throw new Error("Update the Paseo daemon before enabling relay from this command.");
|
||||
}
|
||||
await client.patchDaemonConfig({ relay: { enabled: true } });
|
||||
offer = await client.getDaemonPairingOffer({
|
||||
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
relayEnabled: offer.relayEnabled,
|
||||
@@ -164,40 +155,22 @@ export function printDirectConnectionGuidance(): void {
|
||||
console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`);
|
||||
}
|
||||
|
||||
export async function runPairCommand(
|
||||
options: PairOptions,
|
||||
dependencyOverrides: Partial<PairCommandDependencies> = {},
|
||||
): Promise<void> {
|
||||
if (options.home) process.env.PASEO_HOME = options.home;
|
||||
const dependencies: PairCommandDependencies = {
|
||||
resolveOffer: resolveLocalPairingOffer,
|
||||
confirmRelay: confirmRelayPairing,
|
||||
printDirectGuidance: printDirectConnectionGuidance,
|
||||
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
||||
output: createProcessOutput(),
|
||||
...dependencyOverrides,
|
||||
};
|
||||
export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
const output = createProcessOutput();
|
||||
const target = options.daemonTarget;
|
||||
const resolveOffer = (enableRelay: boolean) =>
|
||||
target.kind === "instance"
|
||||
? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay })
|
||||
: resolveDaemonPairingOffer(target, enableRelay);
|
||||
const offline = target.kind === "instance" && !(await readDaemonInstance(target.home));
|
||||
const pairing = await resolveOffer(options.relay === true);
|
||||
|
||||
const paseoHome = resolvePaseoHome();
|
||||
let pairing = await dependencies.resolveOffer({
|
||||
paseoHome,
|
||||
enableRelay: options.relay === true,
|
||||
});
|
||||
if (offline)
|
||||
output.writeStderr(
|
||||
`Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`,
|
||||
);
|
||||
|
||||
const canPrompt = dependencies.isInteractive() && options.json !== true;
|
||||
if (!pairing.relayEnabled && canPrompt) {
|
||||
const shouldEnable = await dependencies.confirmRelay();
|
||||
if (!shouldEnable) {
|
||||
dependencies.printDirectGuidance();
|
||||
dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`);
|
||||
dependencies.output.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true });
|
||||
dependencies.output.success("Relay enabled");
|
||||
}
|
||||
|
||||
outputPairingResult(pairing, options, dependencies.output);
|
||||
outputPairingResult(pairing, options, output);
|
||||
}
|
||||
|
||||
function outputPairingResult(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest";
|
||||
import { render } from "../../output/render.js";
|
||||
import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js";
|
||||
|
||||
const restartCommand = 'paseo daemon restart --home "/selected home"';
|
||||
|
||||
function result(data: DaemonReloadResult) {
|
||||
return { type: "single" as const, data, schema: daemonReloadSchema };
|
||||
}
|
||||
@@ -11,6 +13,7 @@ describe("daemon reload output", () => {
|
||||
expect(
|
||||
render(
|
||||
result({
|
||||
restartCommand,
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: ["daemon.listen"],
|
||||
overrideControlledPaths: ["app.baseUrl"],
|
||||
@@ -23,7 +26,7 @@ describe("daemon reload output", () => {
|
||||
"Warning: These changes require a daemon restart:",
|
||||
" daemon.listen",
|
||||
"",
|
||||
"Run: paseo daemon restart",
|
||||
`Run: ${restartCommand}`,
|
||||
"",
|
||||
"Warning: These settings are controlled by daemon launch overrides:",
|
||||
" app.baseUrl",
|
||||
@@ -34,6 +37,7 @@ describe("daemon reload output", () => {
|
||||
test("human output omits restart guidance for a live-only reload", () => {
|
||||
const output = render(
|
||||
result({
|
||||
restartCommand,
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -45,6 +49,7 @@ describe("daemon reload output", () => {
|
||||
|
||||
test("structured output preserves the daemon result", () => {
|
||||
const data = {
|
||||
restartCommand,
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: ["daemon.listen"],
|
||||
overrideControlledPaths: [],
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Command } from "commander";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { Command } from "commander";
|
||||
import { describeDaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
export interface DaemonReloadResult {
|
||||
restartCommand: string;
|
||||
appliedPaths: string[];
|
||||
restartRequiredPaths: string[];
|
||||
overrideControlledPaths: string[];
|
||||
@@ -20,7 +24,7 @@ export const daemonReloadSchema: OutputSchema<DaemonReloadResult> = {
|
||||
"Warning: These changes require a daemon restart:",
|
||||
...result.data.restartRequiredPaths.map((path) => ` ${path}`),
|
||||
"",
|
||||
"Run: paseo daemon restart",
|
||||
`Run: ${result.data.restartCommand}`,
|
||||
);
|
||||
}
|
||||
if (result.data.overrideControlledPaths.length > 0) {
|
||||
@@ -38,12 +42,13 @@ export async function runDaemonReloadCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<DaemonReloadResult>> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
try {
|
||||
const payload = await client.reloadDaemonConfig();
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
restartCommand: `paseo daemon restart ${options.daemonTarget.kind === "instance" ? `--home ${JSON.stringify(options.daemonTarget.home)}` : `--host ${JSON.stringify(describeDaemonTarget(options.daemonTarget))}`}`,
|
||||
appliedPaths: payload.appliedPaths,
|
||||
restartRequiredPaths: payload.restartRequiredPaths,
|
||||
overrideControlledPaths: payload.overrideControlledPaths,
|
||||
@@ -54,3 +59,9 @@ export async function runDaemonReloadCommand(
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function daemonReloadCommand(): Command {
|
||||
return addJsonAndDaemonHostOptions(
|
||||
new Command("reload").description("Reload config.json without restarting"),
|
||||
).action(withOutput(runDaemonReloadCommand));
|
||||
}
|
||||
|
||||
@@ -1,131 +1,122 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
startLocalDaemonDetached,
|
||||
stopLocalDaemon,
|
||||
DEFAULT_STOP_TIMEOUT_MS,
|
||||
type DaemonStartOptions,
|
||||
} from "./local-daemon.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
import { Command } from "commander";
|
||||
import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError } from "@getpaseo/server";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { withOutput, type CommandOptions } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { describeDaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js";
|
||||
|
||||
interface RestartResult {
|
||||
action: "restarted";
|
||||
home: string;
|
||||
pid: string;
|
||||
message: string;
|
||||
export function daemonRestartCommand(): Command {
|
||||
return rejectRemovedLaunchFlags(
|
||||
addJsonAndDaemonHostOptions(
|
||||
new Command("restart").description(
|
||||
"Restart the selected daemon worker, retaining its supervisor launch",
|
||||
),
|
||||
),
|
||||
)
|
||||
.option("--timeout <seconds>", "Replacement readiness deadline (default: 600)")
|
||||
.action(withOutput(runRestartCommand));
|
||||
}
|
||||
|
||||
const restartResultSchema: OutputSchema<RestartResult> = {
|
||||
idField: "action",
|
||||
columns: [
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "action",
|
||||
color: () => "green",
|
||||
},
|
||||
{ header: "HOME", field: "home" },
|
||||
{ header: "PID", field: "pid" },
|
||||
{ header: "MESSAGE", field: "message" },
|
||||
],
|
||||
};
|
||||
|
||||
export type RestartCommandResult = SingleResult<RestartResult>;
|
||||
|
||||
function parseTimeoutMs(raw: unknown): number {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
return DEFAULT_STOP_TIMEOUT_MS;
|
||||
export async function runRestartCommand(options: CommandOptions, _command: Command) {
|
||||
const target = options.daemonTarget;
|
||||
const deadline = Date.now() + parseTimeoutMs(options.timeout);
|
||||
const remaining = () => Math.max(1, deadline - Date.now());
|
||||
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
|
||||
async function checkSupervisor() {
|
||||
if (target.kind !== "instance") return;
|
||||
const current = await readDaemonInstance(target.home);
|
||||
if (!current || !instance || !isSameDaemonInstance(instance, current))
|
||||
throw new DaemonInstanceError(
|
||||
"DAEMON_REPLACED",
|
||||
`Supervisor exited or was replaced for ${target.home}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: `Invalid timeout value: ${raw}`,
|
||||
details: "Timeout must be a positive number of seconds",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function toStartOptions(options: CommandOptions): DaemonStartOptions {
|
||||
const startOptions: DaemonStartOptions = {
|
||||
home: typeof options.home === "string" ? options.home : undefined,
|
||||
listen: typeof options.listen === "string" ? options.listen : undefined,
|
||||
port: typeof options.port === "string" ? options.port : undefined,
|
||||
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
||||
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
||||
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
|
||||
webUi: typeof options.webUi === "boolean" ? options.webUi : undefined,
|
||||
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
|
||||
};
|
||||
|
||||
if (startOptions.listen && startOptions.port) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "Cannot use --listen and --port together",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return startOptions;
|
||||
}
|
||||
|
||||
export async function runRestartCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<RestartCommandResult> {
|
||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
||||
const force = options.force === true;
|
||||
const startOptions = toStartOptions(options);
|
||||
|
||||
try {
|
||||
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>;
|
||||
try {
|
||||
stopResult = await stopLocalDaemon({
|
||||
home: startOptions.home,
|
||||
timeoutMs,
|
||||
force,
|
||||
if (target.kind === "instance" && !instance)
|
||||
throw new DaemonInstanceError(
|
||||
"DAEMON_NOT_RUNNING",
|
||||
`Daemon is not running for ${target.home}.`,
|
||||
);
|
||||
const client = await connectToDaemon({
|
||||
target,
|
||||
instance: instance ?? undefined,
|
||||
timeout: remaining(),
|
||||
});
|
||||
} 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,
|
||||
let workerPid: number;
|
||||
const serverId = client.getLastServerInfoMessage()?.serverId;
|
||||
let acknowledged = false;
|
||||
try {
|
||||
workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid;
|
||||
await checkSupervisor();
|
||||
try {
|
||||
await client.restartServer("cli_restart", undefined, { timeout: remaining() });
|
||||
acknowledged = true;
|
||||
} catch (error) {
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
let lastError: unknown = "No replacement worker observed";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const replacement = await connectToDaemon({
|
||||
target,
|
||||
instance: instance ?? undefined,
|
||||
timeout: Math.min(1_000, remaining()),
|
||||
});
|
||||
} 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}`;
|
||||
|
||||
try {
|
||||
if (replacement.getLastServerInfoMessage()?.serverId !== serverId)
|
||||
throw new Error("Connected peer identity changed");
|
||||
const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) });
|
||||
if (status.pid !== workerPid) {
|
||||
await checkSupervisor();
|
||||
return {
|
||||
type: "single",
|
||||
type: "single" as const,
|
||||
data: {
|
||||
action: "restarted",
|
||||
home: stopResult.home,
|
||||
pid: startup.pid === null ? "-" : String(startup.pid),
|
||||
message: `Local daemon restarted (${before} -> ${after})`,
|
||||
target: describeDaemonTarget(target),
|
||||
supervisorPid: instance?.pid ?? null,
|
||||
previousWorkerPid: workerPid,
|
||||
workerPid: status.pid,
|
||||
acknowledged,
|
||||
},
|
||||
schema: {
|
||||
idField: "action" as const,
|
||||
columns: [],
|
||||
renderHuman: () =>
|
||||
`Restarted worker ${workerPid} → ${status.pid} at ${describeDaemonTarget(target)}. Supervisor launch retained.`,
|
||||
},
|
||||
schema: restartResultSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "RESTART_FAILED",
|
||||
message: `Failed to restart local daemon: ${message}`,
|
||||
}
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === "DAEMON_REPLACED" || code === "DAEMON_NOT_RUNNING") break;
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
}
|
||||
await delay(Math.min(100, remaining()));
|
||||
}
|
||||
throw {
|
||||
code: "RESTART_NOT_CONFIRMED",
|
||||
message: `Replacement was not confirmed for ${describeDaemonTarget(target)}. Restart acknowledged: ${acknowledged}. Last observation: ${String(lastError)}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
function isReconnectFailure(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
[
|
||||
"DAEMON_CONNECTION_LOST",
|
||||
"DAEMON_REQUEST_TIMEOUT",
|
||||
"DAEMON_UNREACHABLE",
|
||||
"DAEMON_NOT_READY",
|
||||
].includes(String(error.code)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Command } from "commander";
|
||||
import { isCancel, password as passwordPrompt } from "@clack/prompts";
|
||||
import {
|
||||
hashDaemonPassword,
|
||||
loadPersistedConfig,
|
||||
readPersistedConfig,
|
||||
resolvePaseoHome,
|
||||
savePersistedConfig,
|
||||
type PersistedConfig,
|
||||
} from "@getpaseo/server";
|
||||
@@ -14,7 +15,6 @@ import type {
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import { resolveLocalPaseoHome } from "./local-daemon.js";
|
||||
|
||||
const CONFIG_FILENAME = "config.json";
|
||||
|
||||
@@ -81,9 +81,9 @@ export async function setDaemonPasswordInConfig(
|
||||
newPassword: string,
|
||||
options: SetPasswordOptions = {},
|
||||
): Promise<SetPasswordResult> {
|
||||
const paseoHome = resolveLocalPaseoHome(options.home);
|
||||
const paseoHome = resolvePaseoHome({ PASEO_HOME: options.home });
|
||||
const configPath = path.join(paseoHome, CONFIG_FILENAME);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const persisted = readPersistedConfig(paseoHome);
|
||||
const nextConfig: PersistedConfig = {
|
||||
...persisted,
|
||||
daemon: {
|
||||
@@ -100,8 +100,8 @@ export async function setDaemonPasswordInConfig(
|
||||
return {
|
||||
action: "password_set",
|
||||
configPath,
|
||||
restartCommand: "paseo daemon restart",
|
||||
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`,
|
||||
restartCommand: `paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
|
||||
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function runSetPasswordCommand(
|
||||
: (message: string) => passwordPrompt({ message });
|
||||
const newPassword = await promptForPassword(promptPassword);
|
||||
const result = await setDaemonPasswordInConfig(newPassword, {
|
||||
home: typeof options.home === "string" ? options.home : undefined,
|
||||
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,71 +1,55 @@
|
||||
import { Command, Option } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
startLocalDaemonForeground,
|
||||
startLocalDaemonDetached,
|
||||
type DaemonStartOptions as StartOptions,
|
||||
} from "./local-daemon.js";
|
||||
import { getErrorMessage } from "../../utils/errors.js";
|
||||
|
||||
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
|
||||
|
||||
type RawStartCommandOptions = StartOptions & {
|
||||
allowedHosts?: string;
|
||||
};
|
||||
import { addLocalDaemonOptions } from "../../utils/command-options.js";
|
||||
import { Command } from "commander";
|
||||
import { daemonLogPath } from "@getpaseo/server";
|
||||
import { launchLocalDaemon, parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js";
|
||||
import { withOutput, type CommandOptions } from "../../output/index.js";
|
||||
|
||||
export function startCommand(): Command {
|
||||
return new Command("start")
|
||||
.description("Start the local Paseo daemon")
|
||||
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
|
||||
.option("--port <port>", "Port to listen on (default: 6767)")
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.option("--foreground", "Run in foreground (don't daemonize)")
|
||||
.option("--relay", "Enable relay connection")
|
||||
.option("--no-relay", "Disable relay connection")
|
||||
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||
.option("--web-ui", "Enable the bundled daemon web UI")
|
||||
.option("--no-web-ui", "Disable the bundled daemon web UI")
|
||||
.option(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(async (options: RawStartCommandOptions) => {
|
||||
await runStart({
|
||||
...options,
|
||||
hostnames: options.hostnames ?? options.allowedHosts,
|
||||
});
|
||||
return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("start")))
|
||||
.description("Start the local daemon from persistent configuration (local operation)")
|
||||
.option("--timeout <seconds>", "Readiness deadline (default: 600)")
|
||||
.action(withOutput(runStart));
|
||||
}
|
||||
|
||||
export async function runStart(options: CommandOptions, _command: Command) {
|
||||
if (options.daemonTarget.kind !== "instance") throw new Error("Start requires a local home");
|
||||
const home = options.daemonTarget.home;
|
||||
const result = await launchLocalDaemon({ home, timeoutMs: parseTimeoutMs(options.timeout) });
|
||||
const data = {
|
||||
action: result.spawned ? "started" : "already_running",
|
||||
home,
|
||||
pid: result.instance.pid,
|
||||
listen: result.instance.listen,
|
||||
logPath: daemonLogPath(home),
|
||||
};
|
||||
return {
|
||||
type: "single" as const,
|
||||
data,
|
||||
schema: {
|
||||
idField: "pid" as const,
|
||||
columns: [],
|
||||
renderHuman: () =>
|
||||
`${result.spawned ? "Started" : "Already running"}: PID ${data.pid}${data.listen ? `, listening on ${data.listen}` : ", not ready"}\nLogs: ${data.logPath}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function daemonRunCommand(): Command {
|
||||
return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("run")))
|
||||
.description("Run a local daemon in the foreground with deployment environment overrides")
|
||||
.action(
|
||||
withOutput(async (options: CommandOptions, _command: Command) => {
|
||||
if (options.daemonTarget.kind !== "instance") throw new Error("Run requires a local home");
|
||||
const result = await launchLocalDaemon({
|
||||
home: options.daemonTarget.home,
|
||||
foreground: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStart(options: StartOptions): Promise<void> {
|
||||
if (options.listen && options.port) {
|
||||
console.error(chalk.red("Cannot use --listen and --port together"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!options.foreground) {
|
||||
try {
|
||||
const startup = await startLocalDaemonDetached(options);
|
||||
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`));
|
||||
console.log(chalk.dim(`Logs: ${startup.logPath}`));
|
||||
} catch (err) {
|
||||
exitWithError(getErrorMessage(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = startLocalDaemonForeground(options);
|
||||
process.exit(status);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
exitWithError(`Failed to start daemon: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function exitWithError(message: string): never {
|
||||
console.error(chalk.red(message));
|
||||
process.exit(1);
|
||||
process.exitCode = result.exitCode ?? 0;
|
||||
return {
|
||||
type: "single" as const,
|
||||
data: { pid: result.instance.pid, action: result.spawned ? "exited" : "already_running" },
|
||||
schema: { idField: "pid" as const, columns: [] },
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { selectRelayStatus } from "./status.js";
|
||||
|
||||
describe("selectRelayStatus", () => {
|
||||
const persisted = {
|
||||
enabled: false,
|
||||
endpoint: "persisted.internal:443",
|
||||
publicEndpoint: "persisted.example.com:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
};
|
||||
|
||||
test("uses the running daemon relay state over persisted config", () => {
|
||||
expect(
|
||||
selectRelayStatus({
|
||||
persisted,
|
||||
live: {
|
||||
enabled: true,
|
||||
endpoint: "live.internal:443",
|
||||
publicEndpoint: "live.example.com:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
},
|
||||
}),
|
||||
).toBe("wss://live.example.com:443");
|
||||
});
|
||||
|
||||
test("falls back to persisted config when the daemon cannot report live state", () => {
|
||||
expect(selectRelayStatus({ persisted })).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -1,464 +1,134 @@
|
||||
import type { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState } from "./local-daemon.js";
|
||||
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
|
||||
import { Command } from "commander";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
readDaemonInstance,
|
||||
readPersistedConfig,
|
||||
resolveConfigFromPersisted,
|
||||
daemonLogPath,
|
||||
isSameDaemonInstance,
|
||||
DaemonInstanceError,
|
||||
} from "@getpaseo/server";
|
||||
import { connectToDaemon, buildDaemonConnectionCommandError } from "../../utils/client.js";
|
||||
import { withOutput, toCommandError, type CommandOptions } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { describeDaemonTarget, type DaemonTarget } from "../../utils/daemon-target.js";
|
||||
|
||||
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
|
||||
|
||||
interface ProviderBinaryStatus {
|
||||
label: string;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
source?: "daemon" | "local";
|
||||
export function daemonStatusCommand(): Command {
|
||||
return addJsonAndDaemonHostOptions(
|
||||
new Command("status").description("Observe the selected daemon and its published endpoint"),
|
||||
).action(withOutput(runStatusCommand));
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
serverId: string | null;
|
||||
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
|
||||
connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed";
|
||||
home: string;
|
||||
listen: string;
|
||||
relay: string;
|
||||
hostname: string | null;
|
||||
pid: number | null;
|
||||
startedAt: string | null;
|
||||
owner: string | null;
|
||||
logPath: string;
|
||||
daemonNode: string;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
daemonVersion: string | null;
|
||||
desktopManaged: boolean;
|
||||
providers: ProviderBinaryStatus[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface StatusRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CliPackageJson {
|
||||
version?: unknown;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function shortenMessage(message: string, max = 120): string {
|
||||
const normalized = message.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= max) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, max - 3)}...`;
|
||||
}
|
||||
|
||||
function appendNote(current: string | undefined, next: string | undefined): string | undefined {
|
||||
if (!next) return current;
|
||||
if (!current) return next;
|
||||
return `${current}; ${next}`;
|
||||
}
|
||||
|
||||
function resolveCliVersion(): string {
|
||||
try {
|
||||
const packageJson = require("../../../package.json") as CliPackageJson;
|
||||
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
|
||||
return packageJson.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
export async function runStatusCommand(options: CommandOptions, _command: Command) {
|
||||
const target = options.daemonTarget;
|
||||
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
|
||||
const local =
|
||||
target.kind === "instance"
|
||||
? localStatus(target.home, instance)
|
||||
: { host: describeDaemonTarget(target) };
|
||||
const observed =
|
||||
target.kind === "endpoint" || instance?.listen
|
||||
? await probeDaemonStatus(target, instance, local)
|
||||
: { connectedDaemon: "not_probed" };
|
||||
const data: Record<string, unknown> = { ...local, ...observed };
|
||||
return {
|
||||
idField: "key",
|
||||
columns: [
|
||||
{ header: "KEY", field: "key" },
|
||||
{
|
||||
header: "VALUE",
|
||||
field: "value",
|
||||
color: (_, item) => {
|
||||
if (item.key === "Local Daemon") {
|
||||
if (item.value === "running") return "green";
|
||||
if (item.value === "unresponsive") return "yellow";
|
||||
return "red";
|
||||
}
|
||||
if (item.key === "Connected Daemon") {
|
||||
if (item.value === "reachable") return "green";
|
||||
if (item.value === "not_probed" || item.value === "auth_required") return "yellow";
|
||||
return "red";
|
||||
}
|
||||
if (item.key.startsWith(" ")) {
|
||||
if (item.value === "not found" || item.value === "not found (daemon)") return "red";
|
||||
if (item.value.endsWith("(--version failed)")) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
return undefined;
|
||||
type: "single" as const,
|
||||
data,
|
||||
schema: {
|
||||
idField: () => "daemon",
|
||||
columns: [],
|
||||
renderHuman: () =>
|
||||
Object.entries(data)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`,
|
||||
)
|
||||
.join("\n"),
|
||||
},
|
||||
},
|
||||
],
|
||||
serialize: () => status,
|
||||
};
|
||||
}
|
||||
|
||||
function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
const rows: StatusRow[] = [
|
||||
{ key: "Server ID", value: status.serverId ?? "-" },
|
||||
{ key: "Local Daemon", value: status.localDaemon },
|
||||
{ key: "Connected Daemon", value: status.connectedDaemon },
|
||||
{ key: "Home", value: status.home },
|
||||
{ key: "Listen", value: status.listen },
|
||||
{ key: "Relay", value: status.relay },
|
||||
{ key: "Hostname", value: status.hostname ?? "-" },
|
||||
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
|
||||
{ key: "Started", value: status.startedAt ?? "-" },
|
||||
{ key: "Owner", value: status.owner ?? "-" },
|
||||
{ key: "Logs", value: status.logPath },
|
||||
{ key: "Daemon Node", value: status.daemonNode },
|
||||
{ key: "CLI Node", value: status.cliNode },
|
||||
{ key: "CLI", value: status.cliVersion },
|
||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||
];
|
||||
|
||||
if (status.note) {
|
||||
rows.push({ key: "Note", value: status.note });
|
||||
}
|
||||
|
||||
rows.push({ key: "", value: "" });
|
||||
rows.push({ key: "Providers", value: "" });
|
||||
for (const provider of status.providers) {
|
||||
if (provider.source === "daemon") {
|
||||
if (!provider.path) {
|
||||
rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" });
|
||||
} else {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` });
|
||||
}
|
||||
} else if (!provider.path) {
|
||||
rows.push({ key: ` ${provider.label}`, value: "not found" });
|
||||
} else if (!provider.version) {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
|
||||
} else {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` });
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
||||
{ label: "Claude", binary: "claude" },
|
||||
{ label: "Codex", binary: "codex" },
|
||||
{ label: "OpenCode", binary: "opencode" },
|
||||
];
|
||||
|
||||
async function checkProviderBinary(
|
||||
binary: string,
|
||||
): Promise<{ path: string | null; version: string | null }> {
|
||||
const binaryPath = await findExecutable(binary);
|
||||
if (!binaryPath) {
|
||||
return { path: null, version: null };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], {
|
||||
timeout: 5000,
|
||||
});
|
||||
return { path: binaryPath, version: stdout.trim() || null };
|
||||
} catch {
|
||||
return { path: binaryPath, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
|
||||
const results = await Promise.all(
|
||||
PROVIDER_BINARIES.map(async ({ label, binary }) => {
|
||||
const result = await checkProviderBinary(binary);
|
||||
return Object.assign({ label }, result);
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
|
||||
if (uid === undefined && !hostname) {
|
||||
return null;
|
||||
}
|
||||
const uidPart = uid === undefined ? "?" : String(uid);
|
||||
const hostPart = hostname ?? "unknown-host";
|
||||
return `${uidPart}@${hostPart}`;
|
||||
}
|
||||
|
||||
interface DaemonProbeResult {
|
||||
connectedDaemon: DaemonStatus["connectedDaemon"];
|
||||
localDaemonOverride?: DaemonStatus["localDaemon"];
|
||||
daemonVersion?: string | null;
|
||||
daemonNodeOverride?: string;
|
||||
daemonProviders?: ProviderBinaryStatus[];
|
||||
relayStatus?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type DaemonAuthProbeFailure = "auth_required" | "auth_failed";
|
||||
|
||||
function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null {
|
||||
if (!(error instanceof Error)) return null;
|
||||
if (error.message === "Password required") return "auth_required";
|
||||
if (error.message === "Incorrect password") return "auth_failed";
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string {
|
||||
if (failure === "auth_required") {
|
||||
return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`;
|
||||
}
|
||||
return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`;
|
||||
}
|
||||
|
||||
async function probeDaemonOverWebsocket(args: {
|
||||
host: string;
|
||||
state: ReturnType<typeof resolveLocalDaemonState>;
|
||||
}): Promise<DaemonProbeResult> {
|
||||
const { host, state } = args;
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>>;
|
||||
try {
|
||||
client = await connectToDaemon({ host, timeout: 1500 });
|
||||
} catch (error) {
|
||||
const authFailure = classifyDaemonAuthProbeFailure(error);
|
||||
if (authFailure) {
|
||||
return {
|
||||
connectedDaemon: authFailure,
|
||||
note: describeDaemonAuthProbeFailure(host, authFailure),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.running) {
|
||||
return {
|
||||
connectedDaemon: "unreachable",
|
||||
localDaemonOverride: "unresponsive",
|
||||
note: `Local daemon PID is running but websocket at ${host} is not reachable`,
|
||||
};
|
||||
}
|
||||
return { connectedDaemon: "unreachable" };
|
||||
}
|
||||
|
||||
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
try {
|
||||
const statusPayload = await client.getDaemonStatus({
|
||||
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
|
||||
const daemonProviders = statusPayload.providers.map((p) => ({
|
||||
label: labelMap.get(p.provider) ?? p.provider,
|
||||
path: p.available ? "available" : null,
|
||||
version: p.available ? null : (p.error ?? null),
|
||||
source: "daemon" as const,
|
||||
}));
|
||||
const relayStatus =
|
||||
statusPayload.relay == null
|
||||
? undefined
|
||||
: selectRelayStatus({
|
||||
persisted: relayConfigFromLocalState(state),
|
||||
live: statusPayload.relay,
|
||||
});
|
||||
|
||||
if (!state.running) {
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
daemonVersion: statusPayload.version ?? daemonVersion,
|
||||
daemonNodeOverride: statusPayload.nodePath,
|
||||
daemonProviders,
|
||||
relayStatus,
|
||||
note: state.pidInfo
|
||||
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
|
||||
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
daemonVersion: statusPayload.version ?? daemonVersion,
|
||||
daemonNodeOverride: statusPayload.nodePath,
|
||||
daemonProviders,
|
||||
relayStatus,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
daemonVersion,
|
||||
note: state.running
|
||||
? `Local daemon PID is running but daemon detail request to ${host} failed`
|
||||
: `Connected daemon websocket is reachable at ${host} but daemon status request failed`,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
interface ProbeMergeState {
|
||||
probe: DaemonProbeResult;
|
||||
connectedDaemon: DaemonStatus["connectedDaemon"];
|
||||
localDaemon: DaemonStatus["localDaemon"];
|
||||
daemonNode: string;
|
||||
daemonVersion: string | null;
|
||||
daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
relayStatus: string;
|
||||
note: string | undefined;
|
||||
}
|
||||
|
||||
function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "probe"> {
|
||||
const { probe } = input;
|
||||
return {
|
||||
connectedDaemon: probe.connectedDaemon,
|
||||
localDaemon: probe.localDaemonOverride ?? input.localDaemon,
|
||||
daemonNode: probe.daemonNodeOverride ?? input.daemonNode,
|
||||
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
|
||||
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
|
||||
relayStatus: probe.relayStatus ?? input.relayStatus,
|
||||
note: probe.note ? appendNote(input.note, probe.note) : input.note,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } {
|
||||
try {
|
||||
return { serverId: getOrCreateServerId(home), error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
serverId: null,
|
||||
error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDaemonNodeLabel(
|
||||
state: ReturnType<typeof resolveLocalDaemonState>,
|
||||
): Promise<string> {
|
||||
if (!state.running) return "-";
|
||||
if (!state.pidInfo?.pid) return "unknown (no PID available)";
|
||||
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
|
||||
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||
}
|
||||
|
||||
interface RelayStatusConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
}
|
||||
|
||||
function relayConfigFromLocalState(
|
||||
state: ReturnType<typeof resolveLocalDaemonState>,
|
||||
): RelayStatusConfig {
|
||||
return {
|
||||
enabled: state.relayEnabled,
|
||||
endpoint: state.relayEndpoint,
|
||||
publicEndpoint: state.relayEndpoint,
|
||||
useTls: state.relayUseTls,
|
||||
publicUseTls: state.relayPublicUseTls,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectRelayStatus(input: {
|
||||
persisted: RelayStatusConfig;
|
||||
live?: RelayStatusConfig;
|
||||
}): string {
|
||||
const relay = input.live ?? input.persisted;
|
||||
if (!relay.enabled) return "disabled";
|
||||
const scheme = relay.publicUseTls ? "wss" : "ws";
|
||||
return `${scheme}://${relay.publicEndpoint}`;
|
||||
}
|
||||
|
||||
export type StatusResult = ListResult<StatusRow>;
|
||||
|
||||
export async function runStatusCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<StatusResult> {
|
||||
const home = typeof options.home === "string" ? options.home : undefined;
|
||||
const state = resolveLocalDaemonState({ home });
|
||||
const daemonTarget = state.listen.trim();
|
||||
|
||||
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
|
||||
let daemonNode = await resolveDaemonNodeLabel(state);
|
||||
const cliNode = process.execPath;
|
||||
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
|
||||
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
|
||||
let daemonVersion: string | null = null;
|
||||
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) });
|
||||
async function probeDaemonStatus(
|
||||
target: DaemonTarget,
|
||||
instance: Awaited<ReturnType<typeof readDaemonInstance>>,
|
||||
local: Record<string, unknown>,
|
||||
) {
|
||||
let connectedDaemon = "unreachable";
|
||||
let note: string | undefined;
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
localDaemon = "stale_pid";
|
||||
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
|
||||
let live: Record<string, unknown> = {};
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 });
|
||||
} catch (error) {
|
||||
const failure = buildDaemonConnectionCommandError({ target, error });
|
||||
if (target.kind === "endpoint") throw failure;
|
||||
connectedDaemon = "unreachable";
|
||||
if (failure.code === "AUTH_REQUIRED") connectedDaemon = "auth_required";
|
||||
if (failure.code === "AUTH_FAILED") connectedDaemon = "auth_failed";
|
||||
note = failure.message;
|
||||
}
|
||||
|
||||
if (daemonTarget) {
|
||||
const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state });
|
||||
({
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
daemonProviders,
|
||||
relayStatus,
|
||||
note,
|
||||
} = applyProbeToStatus({
|
||||
probe,
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
daemonProviders,
|
||||
relayStatus,
|
||||
note,
|
||||
}));
|
||||
if (!client) return { connectedDaemon, note };
|
||||
try {
|
||||
let requestError: unknown;
|
||||
const status = await client.getDaemonStatus({ timeout: 1_500 }).catch((error: unknown) => {
|
||||
requestError = error;
|
||||
return null;
|
||||
});
|
||||
// Recheck even when the RPC times out before combining local and live observations.
|
||||
const current = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
|
||||
if (instance && (!current || !isSameDaemonInstance(instance, current)))
|
||||
throw new DaemonInstanceError(
|
||||
"DAEMON_REPLACED",
|
||||
"Supervisor exited or was replaced during status observation.",
|
||||
);
|
||||
const info = client.getLastServerInfoMessage();
|
||||
live = { serverId: info?.serverId, daemonVersion: info?.version };
|
||||
connectedDaemon = client.isConnected ? "reachable" : "unreachable";
|
||||
if (!status) {
|
||||
const failure = toCommandError(requestError);
|
||||
note = `Status details unavailable (${failure.code}): ${failure.message}`;
|
||||
if (
|
||||
target.kind === "endpoint" ||
|
||||
!client.isConnected ||
|
||||
!(requestError instanceof DaemonConnectionError) ||
|
||||
requestError.code !== "DAEMON_REQUEST_TIMEOUT"
|
||||
) {
|
||||
throw { ...failure, message: note, details: { ...local, ...live, connectedDaemon } };
|
||||
}
|
||||
|
||||
const cliVersion = resolveCliVersion();
|
||||
|
||||
const serverIdResult = resolveServerIdSafely(state.home);
|
||||
const serverId = serverIdResult.serverId;
|
||||
if (serverIdResult.error) {
|
||||
note = appendNote(note, serverIdResult.error);
|
||||
}
|
||||
|
||||
const providers = daemonProviders ?? (await checkProviderBinaries());
|
||||
|
||||
const daemonStatus: DaemonStatus = {
|
||||
serverId,
|
||||
localDaemon,
|
||||
connectedDaemon,
|
||||
home: state.home,
|
||||
listen: state.listen,
|
||||
relay: relayStatus,
|
||||
hostname: state.pidInfo?.hostname ?? null,
|
||||
pid: state.pidInfo?.pid ?? null,
|
||||
startedAt: state.pidInfo?.startedAt ?? null,
|
||||
owner,
|
||||
logPath: state.logPath,
|
||||
daemonNode,
|
||||
cliNode,
|
||||
cliVersion,
|
||||
daemonVersion,
|
||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||
providers,
|
||||
note,
|
||||
} else {
|
||||
live = {
|
||||
...live,
|
||||
daemonVersion: status.version,
|
||||
workerPid: status.pid,
|
||||
daemonNode: status.nodePath,
|
||||
providers: status.providers,
|
||||
relay: status.relay,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
return { ...live, connectedDaemon, note };
|
||||
}
|
||||
|
||||
function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemonInstance>>) {
|
||||
const config = resolveConfigFromPersisted(
|
||||
home,
|
||||
readPersistedConfig(home, { defaultsIfMissing: true }),
|
||||
{ env: {} },
|
||||
);
|
||||
let localDaemon = "stopped";
|
||||
if (instance) localDaemon = instance.listen ? "running" : "not_ready";
|
||||
return {
|
||||
type: "list",
|
||||
data: toStatusRows(daemonStatus),
|
||||
schema: createStatusSchema(daemonStatus),
|
||||
home,
|
||||
pid: instance?.pid ?? null,
|
||||
startedAt: instance?.startedAt ?? null,
|
||||
listen: instance?.listen ?? null,
|
||||
hostname: instance?.hostname ?? null,
|
||||
configuredListen: config.listen,
|
||||
localDaemon,
|
||||
desktopManaged: instance?.desktopManaged === true,
|
||||
logPath: daemonLogPath(home),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,94 +1,67 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
stopLocalDaemon,
|
||||
DEFAULT_STOP_TIMEOUT_MS,
|
||||
DEFAULT_KILL_TIMEOUT_MS,
|
||||
} from "./local-daemon.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
OutputSchema,
|
||||
CommandError,
|
||||
} from "../../output/index.js";
|
||||
import { Command } from "commander";
|
||||
import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { withOutput, type CommandOptions } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { describeDaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { parseTimeoutMs } from "./local-daemon.js";
|
||||
|
||||
interface StopResult {
|
||||
action: "stopped" | "not_running";
|
||||
home: string;
|
||||
pid: string;
|
||||
forced: boolean;
|
||||
usedLifecycleRpc: boolean;
|
||||
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
|
||||
message: string;
|
||||
export function daemonStopCommand(): Command {
|
||||
return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon"))
|
||||
.option("--timeout <seconds>", "Graceful exit deadline (default: 15)")
|
||||
.option("--force", "Permit forced local process cleanup")
|
||||
.option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)")
|
||||
.action(withOutput(runStopCommand));
|
||||
}
|
||||
|
||||
const stopResultSchema: OutputSchema<StopResult> = {
|
||||
idField: "action",
|
||||
columns: [
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "action",
|
||||
color: (value) => (value === "stopped" ? "green" : "yellow"),
|
||||
},
|
||||
{ header: "HOME", field: "home" },
|
||||
{ header: "PID", field: "pid" },
|
||||
{ header: "MESSAGE", field: "message" },
|
||||
],
|
||||
};
|
||||
|
||||
export type StopCommandResult = SingleResult<StopResult>;
|
||||
|
||||
function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
return fallbackMs;
|
||||
}
|
||||
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: `Invalid ${label} value: ${raw}`,
|
||||
details: `${label} must be a positive number of seconds`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
export async function runStopCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<StopCommandResult> {
|
||||
const home = typeof options.home === "string" ? options.home : undefined;
|
||||
const force = options.force === true;
|
||||
const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout");
|
||||
const killTimeoutMs = parseSecondsOption(
|
||||
options.killTimeout,
|
||||
DEFAULT_KILL_TIMEOUT_MS,
|
||||
"kill-timeout",
|
||||
);
|
||||
|
||||
export async function runStopCommand(options: CommandOptions, _command: Command) {
|
||||
const target = options.daemonTarget;
|
||||
const timeoutMs = parseTimeoutMs(options.timeout, 15_000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remaining = () => Math.max(1, deadline - Date.now());
|
||||
const requestShutdown = async (instance?: DaemonInstance) => {
|
||||
const client = await connectToDaemon({ target, timeout: remaining(), instance });
|
||||
try {
|
||||
const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs });
|
||||
await client.shutdownServer({ timeout: remaining() });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
};
|
||||
if (target.kind === "endpoint" && options.force)
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--force requires a local --home; an endpoint gives no remote process authority.",
|
||||
};
|
||||
const result: {
|
||||
action: string;
|
||||
home?: string;
|
||||
host?: string;
|
||||
pid?: number | null;
|
||||
forced?: boolean;
|
||||
usedLifecycleRpc?: boolean;
|
||||
} =
|
||||
target.kind === "instance"
|
||||
? {
|
||||
...(await stopDaemonInstance(target.home, {
|
||||
force: options.force === true,
|
||||
timeoutMs,
|
||||
killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000),
|
||||
requestShutdown,
|
||||
})),
|
||||
home: target.home,
|
||||
}
|
||||
: (await requestShutdown(),
|
||||
{ action: "shutdown_requested", host: describeDaemonTarget(target) });
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
action: result.action,
|
||||
home: result.home,
|
||||
pid: result.pid === null ? "-" : String(result.pid),
|
||||
forced: result.forced,
|
||||
usedLifecycleRpc: result.usedLifecycleRpc,
|
||||
reason: result.reason,
|
||||
message: result.message,
|
||||
type: "single" as const,
|
||||
data: result,
|
||||
schema: {
|
||||
idField: "action" as const,
|
||||
columns: [],
|
||||
renderHuman: () =>
|
||||
target.kind === "endpoint"
|
||||
? "Shutdown requested; remote process exit was not verified."
|
||||
: `${result.action.replaceAll("_", " ")}: ${target.home}`,
|
||||
},
|
||||
schema: stopResultSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "STOP_FAILED",
|
||||
message: `Failed to stop local daemon: ${message}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ async function runCreateHeartbeat(
|
||||
if (!cron) {
|
||||
throw new Error("--cron is required");
|
||||
}
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined;
|
||||
if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) {
|
||||
@@ -105,7 +105,7 @@ async function runUpdateHeartbeat(
|
||||
if (!cron) {
|
||||
throw new Error("--cron is required");
|
||||
}
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
await requireOwnedHeartbeat(client, id, agentId);
|
||||
const payload = await client.scheduleUpdate({
|
||||
@@ -133,7 +133,7 @@ async function runDeleteHeartbeat(
|
||||
_command: Command,
|
||||
): Promise<SingleResult<HeartbeatDeleteRow>> {
|
||||
const agentId = requireCallerAgentId();
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
await requireOwnedHeartbeat(client, id, agentId);
|
||||
const payload = await client.scheduleDelete({ id });
|
||||
|
||||
@@ -13,6 +13,7 @@ import { addHubResolutionHelp } from "./help.js";
|
||||
interface HubConnectOptions {
|
||||
apiKey?: string;
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
json?: boolean;
|
||||
permission?: readonly string[];
|
||||
permissions?: readonly string[];
|
||||
@@ -41,7 +42,7 @@ export async function runHubConnect(
|
||||
const credential = resolveHubCredential({ ...resolution, origin });
|
||||
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
|
||||
const permissions = options.permissions ?? options.permission ?? [];
|
||||
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
|
||||
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => {
|
||||
const response = await daemon.connectHub(origin, token, permissions);
|
||||
if (
|
||||
response.status.hubOrigin !== null &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
@@ -33,19 +34,19 @@ export interface HubDaemonClient {
|
||||
}
|
||||
|
||||
export interface HubDaemonConnection {
|
||||
connect(host: string | undefined): Promise<HubDaemonClient>;
|
||||
connect(target: DaemonTarget): Promise<HubDaemonClient>;
|
||||
}
|
||||
|
||||
export const productionHubDaemonConnection: HubDaemonConnection = {
|
||||
connect: (host) => connectToDaemon({ host }),
|
||||
connect: (target) => connectToDaemon({ target }),
|
||||
};
|
||||
|
||||
export async function withHubDaemon<T>(
|
||||
connection: HubDaemonConnection,
|
||||
host: string | undefined,
|
||||
target: DaemonTarget,
|
||||
action: (client: HubDaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await connection.connect(host);
|
||||
const client = await connection.connect(target);
|
||||
try {
|
||||
return await action(client);
|
||||
} finally {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hubStatusResult } from "./status-output.js";
|
||||
interface HubDisconnectOptions {
|
||||
force?: boolean;
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ export function runHubDisconnect(
|
||||
options: HubDisconnectOptions,
|
||||
dependencies: HubDisconnectDependencies,
|
||||
) {
|
||||
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
|
||||
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
|
||||
const current = (await client.getHubStatus()).status;
|
||||
if (current.hubOrigin !== null) {
|
||||
reportHubProgress(
|
||||
|
||||
@@ -59,7 +59,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
|
||||
flow: environment.login,
|
||||
reporter: environment.reporter,
|
||||
isInteractive: environment.isInteractive,
|
||||
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
|
||||
continueGuidedSetup: (origin, daemonTarget) =>
|
||||
continueHubGuidedSetup(origin, { ...environment, daemonTarget }),
|
||||
});
|
||||
addHubInitCommand(hub, environment);
|
||||
addHubConnectCommand(hub, {
|
||||
@@ -71,8 +72,11 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
|
||||
});
|
||||
addJsonAndDaemonHostOptions(hub.command("status")).action(
|
||||
withOutput(async (...args) => {
|
||||
const options = args.at(-2) as { host?: string };
|
||||
return withHubDaemon(environment.daemon, options.host, async (client) =>
|
||||
const options = args.at(-2) as {
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
};
|
||||
return withHubDaemon(environment.daemon, options.daemonTarget, async (client) =>
|
||||
hubStatusResult((await client.getHubStatus()).status),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { withGlobalOptions } from "../../utils/command-options.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
@@ -49,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250;
|
||||
const PROVIDER_READY_TIMEOUT_MS = 60_000;
|
||||
|
||||
export interface HubGuidedSetupEnvironment {
|
||||
daemonTarget: DaemonTarget;
|
||||
env: Readonly<Record<string, string | undefined>>;
|
||||
credentials: HubCredentialStore;
|
||||
hub: HubHttpClient;
|
||||
@@ -80,13 +84,17 @@ function initErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void {
|
||||
export function addHubInitCommand(
|
||||
parent: Command,
|
||||
environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">,
|
||||
): void {
|
||||
parent
|
||||
.command("init")
|
||||
.description("Create and optionally deploy a safe starter Hub trigger")
|
||||
.action(async () => {
|
||||
.action(
|
||||
withGlobalOptions(async (options: CommandOptions) => {
|
||||
try {
|
||||
await runHubInit(environment);
|
||||
await runHubInit({ ...environment, daemonTarget: options.daemonTarget });
|
||||
} catch (error) {
|
||||
if (error instanceof HubInitCancelledError) {
|
||||
cancel(error.message);
|
||||
@@ -95,7 +103,8 @@ export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEn
|
||||
cancel(initErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> {
|
||||
@@ -175,8 +184,10 @@ export async function continueHubGuidedSetup(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<void> {
|
||||
const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
||||
daemon.getHubStatus().then((response) => response.status),
|
||||
const currentStatus = await withHubDaemon(
|
||||
environment.daemon,
|
||||
environment.daemonTarget,
|
||||
async (daemon) => daemon.getHubStatus().then((response) => response.status),
|
||||
);
|
||||
const current = resolveHubInitConnection(currentStatus, origin);
|
||||
if (current.kind === "connected") {
|
||||
@@ -187,7 +198,7 @@ export async function continueHubGuidedSetup(
|
||||
}.`,
|
||||
);
|
||||
} else if (current.kind === "pending") {
|
||||
await waitForDaemonReady(origin, environment.daemon);
|
||||
await waitForDaemonReady(origin, environment);
|
||||
} else if (current.kind === "conflict") {
|
||||
reportMessage(
|
||||
environment,
|
||||
@@ -273,7 +284,7 @@ async function ensureDaemonConnection(
|
||||
confirmed = false,
|
||||
permissions: readonly string[] = ["hub.execute"],
|
||||
): Promise<string> {
|
||||
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
||||
const status = await withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) =>
|
||||
daemon.getHubStatus().then((response) => response.status),
|
||||
);
|
||||
const connection = resolveHubInitConnection(status, origin);
|
||||
@@ -287,7 +298,7 @@ async function ensureDaemonConnection(
|
||||
return connection.daemonId;
|
||||
}
|
||||
if (connection.kind === "pending") {
|
||||
return waitForDaemonReady(origin, environment.daemon);
|
||||
return waitForDaemonReady(origin, environment);
|
||||
}
|
||||
if (connection.kind === "conflict") {
|
||||
throw new HubCommandError(
|
||||
@@ -315,7 +326,7 @@ async function connectDaemon(
|
||||
): Promise<string> {
|
||||
await runHubConnect(
|
||||
origin,
|
||||
{ permissions },
|
||||
{ permissions, daemonTarget: environment.daemonTarget },
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
@@ -324,15 +335,15 @@ async function connectDaemon(
|
||||
reporter: environment.reporter,
|
||||
},
|
||||
);
|
||||
return waitForDaemonReady(origin, environment.daemon);
|
||||
return waitForDaemonReady(origin, environment);
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(
|
||||
origin: string,
|
||||
connection: HubDaemonConnection,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<string> {
|
||||
return withSpinner("Waiting for the daemon to connect", async (reporter) =>
|
||||
withHubDaemon(connection, undefined, async (daemon) => {
|
||||
withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => {
|
||||
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const status = (await daemon.getHubStatus()).status;
|
||||
@@ -436,7 +447,7 @@ async function waitForStarterAgentProviders(
|
||||
cwd: string,
|
||||
): Promise<readonly HubStarterAgentProvider[]> {
|
||||
return withSpinner("Discovering agent runtimes", async (reporter) =>
|
||||
withHubDaemon(environment.daemon, undefined, async (daemon) => {
|
||||
withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => {
|
||||
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const snapshot = await daemon.getProvidersSnapshot({ cwd });
|
||||
@@ -750,7 +761,9 @@ async function requiredSelect<T extends string>(
|
||||
return answer;
|
||||
}
|
||||
|
||||
function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
|
||||
function requireInteractiveTerminal(
|
||||
environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">,
|
||||
): void {
|
||||
if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
|
||||
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import type { Command } from "commander";
|
||||
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
@@ -26,12 +28,12 @@ interface HubLoginDependencies {
|
||||
flow: Pick<CliLoginFlow, "authorize">;
|
||||
reporter: HubReporter;
|
||||
isInteractive?(): boolean;
|
||||
continueGuidedSetup?(origin: string): Promise<void>;
|
||||
continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise<void>;
|
||||
}
|
||||
|
||||
export async function runHubLogin(
|
||||
originInput: string | undefined,
|
||||
options: { json?: boolean },
|
||||
options: Pick<CommandOptions, "json" | "daemonTarget">,
|
||||
dependencies: HubLoginDependencies,
|
||||
): Promise<SingleResult<HubLoginResult>> {
|
||||
const origin = resolveHubOrigin({
|
||||
@@ -48,7 +50,7 @@ export async function runHubLogin(
|
||||
dependencies.isInteractive?.() &&
|
||||
dependencies.continueGuidedSetup !== undefined
|
||||
) {
|
||||
await dependencies.continueGuidedSetup(origin);
|
||||
await dependencies.continueGuidedSetup(origin, options.daemonTarget);
|
||||
}
|
||||
return { type: "single", data: { origin, status: "logged_in" }, schema };
|
||||
}
|
||||
@@ -64,7 +66,7 @@ export function addHubLoginCommand(parent: Command, dependencies: HubLoginDepend
|
||||
).action(
|
||||
withOutput(async (...args) => {
|
||||
const origin = args[0] as string | undefined;
|
||||
const options = args.at(-2) as { json?: boolean };
|
||||
const options = args.at(-2) as CommandOptions;
|
||||
return runHubLogin(origin, options, dependencies);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ interface HubLogoutOptions {
|
||||
disconnectDaemon?: boolean;
|
||||
force?: boolean;
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ export async function runHubLogout(
|
||||
let daemonStatus: HubStatus | null = null;
|
||||
const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt;
|
||||
if (shouldInspectDaemon) {
|
||||
daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) =>
|
||||
daemonStatus = await withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) =>
|
||||
daemon.getHubStatus().then((response) => response.status),
|
||||
);
|
||||
}
|
||||
@@ -72,9 +73,13 @@ export async function runHubLogout(
|
||||
options,
|
||||
`Disconnecting this daemon from ${active.origin}`,
|
||||
);
|
||||
const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
|
||||
const disconnect = await withHubDaemon(
|
||||
dependencies.daemon,
|
||||
options.daemonTarget,
|
||||
async (daemon) => {
|
||||
return daemon.disconnectHub(options.force ?? false);
|
||||
});
|
||||
},
|
||||
);
|
||||
dependencies.credentials.logoutActive();
|
||||
return logoutResult({
|
||||
origin: active.origin,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { hubStatusResult } from "./status-output.js";
|
||||
|
||||
interface HubPermissionsOptions {
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ export function runHubPermissionsList(
|
||||
options: HubPermissionsOptions,
|
||||
dependencies: HubPermissionsDependencies,
|
||||
): Promise<ListResult<PermissionRow>> {
|
||||
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
|
||||
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
|
||||
const status = (await client.getHubStatus()).status;
|
||||
requireConnectedHub(status);
|
||||
return {
|
||||
@@ -53,7 +54,7 @@ export function runHubPermissionChange(
|
||||
options: HubPermissionsOptions,
|
||||
dependencies: HubPermissionsDependencies,
|
||||
) {
|
||||
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
|
||||
return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => {
|
||||
const current = (await client.getHubStatus()).status;
|
||||
requireConnectedHub(current);
|
||||
const response = await client.updateHubPermissions(
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
||||
import { addLocalDaemonOptions } from "../utils/command-options.js";
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro } from "@clack/prompts";
|
||||
import { Command, Option } from "commander";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server";
|
||||
import {
|
||||
resolveLocalPaseoHome,
|
||||
resolveLocalDaemonState,
|
||||
resolveTcpHostFromListen,
|
||||
startLocalDaemonDetached,
|
||||
tailDaemonLog,
|
||||
type DaemonStartOptions,
|
||||
} from "./daemon/local-daemon.js";
|
||||
import { tryConnectToDaemon } from "../utils/client.js";
|
||||
readPersistedConfig as loadPersistedConfig,
|
||||
savePersistedConfig,
|
||||
readDaemonInstance,
|
||||
waitForDaemonReady,
|
||||
type PersistedConfig,
|
||||
} from "@getpaseo/server";
|
||||
import { withGlobalOptions } from "../utils/command-options.js";
|
||||
import type { CommandOptions } from "../output/index.js";
|
||||
import { launchLocalDaemon, parseTimeoutMs } from "./daemon/local-daemon.js";
|
||||
import { connectToDaemon } from "../utils/client.js";
|
||||
import { formatPairingInstructions } from "../output/pairing.js";
|
||||
import {
|
||||
confirmRelayPairing,
|
||||
@@ -19,7 +20,12 @@ import {
|
||||
resolveLocalPairingOffer,
|
||||
} from "./daemon/pair.js";
|
||||
|
||||
interface OnboardOptions extends DaemonStartOptions {
|
||||
interface OnboardOptions extends CommandOptions {
|
||||
port?: string;
|
||||
listen?: string;
|
||||
relay?: boolean;
|
||||
mcp?: boolean;
|
||||
hostnames?: string;
|
||||
timeout?: string;
|
||||
voice?: "ask" | "enable" | "disable";
|
||||
}
|
||||
@@ -39,41 +45,12 @@ type OnboardPersistedConfig = PersistedConfig & {
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const READY_PROBE_TIMEOUT_MS = 1200;
|
||||
|
||||
class OnboardCancelledError extends Error {}
|
||||
|
||||
const plainNoteFormat = (line: string): string => line;
|
||||
|
||||
function renderNote(message: string, title: string): void {
|
||||
note(message, title, { format: plainNoteFormat });
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function parseTimeoutMs(raw: string | undefined): number {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const seconds = Number(raw);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
throw new Error(`Invalid timeout value: ${raw}`);
|
||||
}
|
||||
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function applyVoiceSelection(
|
||||
config: OnboardPersistedConfig,
|
||||
enabled: boolean,
|
||||
@@ -135,134 +112,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boo
|
||||
return answer;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
modelId: string | null;
|
||||
pct: number | null;
|
||||
}
|
||||
|
||||
function parseDownloadProgress(logTail: string): DownloadProgress | null {
|
||||
const lines = logTail.split("\n").filter(Boolean);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
if (!line || !line.includes("Downloading model artifact")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/);
|
||||
const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/);
|
||||
|
||||
return {
|
||||
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
|
||||
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderProgressLine(progress: DownloadProgress): string {
|
||||
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : "";
|
||||
if (progress.pct === null) {
|
||||
return `Downloading speech model${modelSuffix}...`;
|
||||
}
|
||||
return `Downloading speech model${modelSuffix}: ${progress.pct}%`;
|
||||
}
|
||||
|
||||
type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" };
|
||||
|
||||
async function probeDaemonReady(home: string, timeoutMs: number): Promise<ProbeResult> {
|
||||
const state = resolveLocalDaemonState({ home });
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
||||
|
||||
if (state.running && host) {
|
||||
const client = await tryConnectToDaemon({
|
||||
host,
|
||||
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
if (client) {
|
||||
try {
|
||||
await client.fetchAgents({
|
||||
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
return { kind: "ready", listen: state.listen, host };
|
||||
} catch {
|
||||
// Daemon process is alive but not API-ready yet.
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
} else if (state.running && !host) {
|
||||
return { kind: "ready", listen: state.listen, host: null };
|
||||
}
|
||||
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
lastStatus: string;
|
||||
lastPrintedAt: number;
|
||||
}
|
||||
|
||||
function announceProgress(
|
||||
home: string,
|
||||
state: ProgressState,
|
||||
onStatus: ((message: string) => void) | undefined,
|
||||
): ProgressState {
|
||||
const progress = parseDownloadProgress(tailDaemonLog(home, 120) ?? "");
|
||||
const progressLine = progress ? renderProgressLine(progress) : null;
|
||||
const statusMessage = progressLine ?? "Waiting for daemon to become ready...";
|
||||
|
||||
if (statusMessage !== state.lastStatus) {
|
||||
onStatus?.(statusMessage);
|
||||
return { lastStatus: statusMessage, lastPrintedAt: Date.now() };
|
||||
}
|
||||
if (!onStatus && Date.now() - state.lastPrintedAt >= 3000) {
|
||||
console.log(statusMessage);
|
||||
return { lastStatus: state.lastStatus, lastPrintedAt: Date.now() };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(args: {
|
||||
home: string;
|
||||
timeoutMs: number;
|
||||
onStatus?: (message: string) => void;
|
||||
}): Promise<{ listen: string; host: string | null }> {
|
||||
const deadline = Date.now() + args.timeoutMs;
|
||||
const createTimeoutError = () => {
|
||||
const recentLogs = tailDaemonLog(args.home, 60);
|
||||
return new Error(
|
||||
[
|
||||
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
);
|
||||
};
|
||||
|
||||
async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> {
|
||||
if (Date.now() >= deadline) {
|
||||
throw createTimeoutError();
|
||||
}
|
||||
const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now()));
|
||||
if (probe.kind === "ready") {
|
||||
return { listen: probe.listen, host: probe.host };
|
||||
}
|
||||
const nextState = announceProgress(args.home, state, args.onStatus);
|
||||
if (Date.now() >= deadline) {
|
||||
throw createTimeoutError();
|
||||
}
|
||||
await sleep(200);
|
||||
return poll(nextState);
|
||||
}
|
||||
|
||||
return poll({ lastStatus: "", lastPrintedAt: 0 });
|
||||
}
|
||||
|
||||
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
|
||||
const daemonLogPath = path.join(paseoHome, "daemon.log");
|
||||
const nextStepsLines = [
|
||||
@@ -272,13 +121,13 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
|
||||
"2. Web app: https://app.paseo.sh",
|
||||
"3. Desktop app: https://github.com/getpaseo/paseo/releases/latest",
|
||||
"4. Docs: https://paseo.sh/docs",
|
||||
'5. Example: paseo run --output-schema schema.json "extract fields"',
|
||||
`5. Example: paseo run --home ${JSON.stringify(paseoHome)} --output-schema schema.json "extract fields"`,
|
||||
];
|
||||
const quickReferenceLines = [
|
||||
"1. paseo --help",
|
||||
"2. paseo ls",
|
||||
'3. paseo run "your prompt"',
|
||||
"4. paseo status",
|
||||
`2. paseo ls --home ${JSON.stringify(paseoHome)}`,
|
||||
`3. paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`,
|
||||
`4. paseo status --home ${JSON.stringify(paseoHome)}`,
|
||||
`5. Daemon logs: ${daemonLogPath}`,
|
||||
];
|
||||
|
||||
@@ -301,11 +150,10 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo
|
||||
}
|
||||
|
||||
export function onboardCommand(): Command {
|
||||
return new Command("onboard")
|
||||
return addLocalDaemonOptions(new Command("onboard"))
|
||||
.description("Run first-time setup, start daemon, and print pairing instructions")
|
||||
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
|
||||
.option("--port <port>", "Port to listen on (default: 6767)")
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.option("--relay", "Enable relay connection without prompting")
|
||||
.option("--no-relay", "Disable relay connection")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
@@ -316,12 +164,15 @@ export function onboardCommand(): Command {
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
|
||||
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
|
||||
.action(async (options: RawOnboardOptions) => {
|
||||
.action(
|
||||
withGlobalOptions(async (options: RawOnboardOptions, command: Command) => {
|
||||
await runOnboard({
|
||||
...options,
|
||||
mcp: command.getOptionValueSource("mcp") === "cli" ? options.mcp : undefined,
|
||||
hostnames: options.hostnames ?? options.allowedHosts,
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveAndPersistVoice(
|
||||
@@ -354,70 +205,30 @@ async function resolveAndPersistVoice(
|
||||
return voiceEnabled;
|
||||
}
|
||||
|
||||
async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise<void> {
|
||||
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
|
||||
if (stateBeforeStart.running) {
|
||||
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const startSpinner = richUi ? spinner() : null;
|
||||
try {
|
||||
if (startSpinner) {
|
||||
startSpinner.start("Starting daemon...");
|
||||
} else {
|
||||
log.message("Starting daemon...");
|
||||
}
|
||||
const startup = await startLocalDaemonDetached(options);
|
||||
if (startSpinner) {
|
||||
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
} else {
|
||||
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
|
||||
}
|
||||
log.message(`Logs: ${startup.logPath}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (startSpinner) {
|
||||
startSpinner.error(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemonReadyWithUi(args: {
|
||||
home: string;
|
||||
timeoutMs: number;
|
||||
richUi: boolean;
|
||||
}): Promise<{ listen: string; host: string | null }> {
|
||||
const readySpinner = args.richUi ? spinner() : null;
|
||||
try {
|
||||
if (readySpinner) {
|
||||
readySpinner.start("Waiting for daemon to become ready...");
|
||||
} else {
|
||||
log.message("Waiting for daemon to become ready...");
|
||||
}
|
||||
const readyState = await waitForDaemonReady({
|
||||
home: args.home,
|
||||
timeoutMs: args.timeoutMs,
|
||||
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
|
||||
});
|
||||
if (readySpinner) {
|
||||
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
|
||||
} else {
|
||||
log.message(`Daemon ready on ${readyState.listen}`);
|
||||
}
|
||||
return readyState;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (readySpinner) {
|
||||
readySpinner.error(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
return process.exit(1);
|
||||
function persistSetupChoices(paseoHome: string, options: OnboardOptions): void {
|
||||
const persisted = loadPersistedConfig(paseoHome, { defaultsIfMissing: true });
|
||||
if (options.listen || options.port) {
|
||||
persisted.daemon = {
|
||||
...persisted.daemon,
|
||||
listen: options.listen ?? `127.0.0.1:${options.port}`,
|
||||
};
|
||||
}
|
||||
if (options.relay !== undefined)
|
||||
persisted.daemon = {
|
||||
...persisted.daemon,
|
||||
relay: { ...persisted.daemon?.relay, enabled: options.relay },
|
||||
};
|
||||
if (options.mcp !== undefined)
|
||||
persisted.daemon = {
|
||||
...persisted.daemon,
|
||||
mcp: { ...persisted.daemon?.mcp, enabled: options.mcp },
|
||||
};
|
||||
if (options.hostnames)
|
||||
persisted.daemon = {
|
||||
...persisted.daemon,
|
||||
hostnames: options.hostnames === "true" ? true : options.hostnames.split(","),
|
||||
};
|
||||
savePersistedConfig(paseoHome, persisted);
|
||||
}
|
||||
|
||||
export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
@@ -431,16 +242,12 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let timeoutMs = DEFAULT_READY_TIMEOUT_MS;
|
||||
try {
|
||||
timeoutMs = parseTimeoutMs(options.timeout);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cancel(message);
|
||||
process.exit(1);
|
||||
}
|
||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
||||
|
||||
const paseoHome = resolveLocalPaseoHome(options.home);
|
||||
if (options.daemonTarget.kind !== "instance") throw new Error("Onboarding requires a local home");
|
||||
const paseoHome = options.daemonTarget.home;
|
||||
const alreadyRunning = await readDaemonInstance(paseoHome);
|
||||
persistSetupChoices(paseoHome, options);
|
||||
if (richUi) {
|
||||
renderNote(paseoHome, "Paseo home");
|
||||
}
|
||||
@@ -452,12 +259,19 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
: "Voice features disabled. Local speech models will not be downloaded.",
|
||||
);
|
||||
|
||||
await ensureDaemonStarted(options, richUi);
|
||||
await waitForDaemonReadyWithUi({
|
||||
home: options.home ?? paseoHome,
|
||||
timeoutMs,
|
||||
richUi,
|
||||
});
|
||||
if (alreadyRunning) {
|
||||
log.message(`Daemon already running (PID ${alreadyRunning.pid}); retaining its supervisor.`);
|
||||
const client = await connectToDaemon({ target: options.daemonTarget, timeout: timeoutMs });
|
||||
try {
|
||||
log.message(JSON.stringify(await client.reloadDaemonConfig()));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} else {
|
||||
await launchLocalDaemon({ home: paseoHome, timeoutMs });
|
||||
}
|
||||
const ready = await waitForDaemonReady(paseoHome, { timeoutMs });
|
||||
log.message(`Daemon ready on ${ready.listen}`);
|
||||
|
||||
if (options.relay === false) {
|
||||
log.message("Relay pairing skipped because --no-relay was provided.");
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise<void>
|
||||
try {
|
||||
launchDesktop([projectPath]);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Permission response item for display */
|
||||
@@ -46,8 +46,6 @@ export async function runAllowCommand(
|
||||
options: PermitAllowOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitAllowResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// No validation needed - if no reqId provided, allow all by default
|
||||
|
||||
// Parse input JSON if provided
|
||||
@@ -65,18 +63,7 @@ export async function runAllowCommand(
|
||||
}
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
|
||||
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
|
||||
|
||||
@@ -19,8 +19,6 @@ export async function runDenyCommand(
|
||||
options: PermitDenyOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitDenyResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
// Validate arguments
|
||||
if (!options.all && !reqId) {
|
||||
const error: CommandError = {
|
||||
@@ -31,18 +29,7 @@ export async function runDenyCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Permission list item for display */
|
||||
@@ -48,20 +48,7 @@ export async function runLsCommand(
|
||||
options: PermitLsOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitLsResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
const listPlugins = vi.fn(async () => [
|
||||
{
|
||||
id: "git-plugin",
|
||||
@@ -68,7 +70,9 @@ describe("plugin management commands", () => {
|
||||
});
|
||||
|
||||
it("requires host support before attempting a management RPC", async () => {
|
||||
await expect(runPluginListCommand(undefined, {}, {} as never)).rejects.toMatchObject({
|
||||
await expect(
|
||||
runPluginListCommand(undefined, { daemonTarget }, {} as never),
|
||||
).rejects.toMatchObject({
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to use plugin management.",
|
||||
});
|
||||
@@ -89,7 +93,7 @@ describe("plugin management commands", () => {
|
||||
it("lists runtime state and the installed commit without an upstream commit", async () => {
|
||||
features.pluginManagement = true;
|
||||
|
||||
const result = await runPluginListCommand(undefined, {}, {} as never);
|
||||
const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never);
|
||||
const output = render(result, { noColor: true });
|
||||
|
||||
expect(output).toContain("SOURCE");
|
||||
@@ -102,13 +106,15 @@ describe("plugin management commands", () => {
|
||||
it("filters the shared ls and status command by plugin ID", async () => {
|
||||
features.pluginManagement = true;
|
||||
|
||||
const result = await runPluginListCommand("legacy-plugin", {}, {} as never);
|
||||
const result = await runPluginListCommand("legacy-plugin", { daemonTarget }, {} as never);
|
||||
|
||||
expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]);
|
||||
});
|
||||
|
||||
it("requires plugin log support before attempting the RPC", async () => {
|
||||
await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({
|
||||
await expect(
|
||||
runPluginLogsCommand("example", { daemonTarget }, {} as never),
|
||||
).rejects.toMatchObject({
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to view plugin logs.",
|
||||
});
|
||||
@@ -117,7 +123,7 @@ describe("plugin management commands", () => {
|
||||
|
||||
it("returns readable and JSON plugin log output", async () => {
|
||||
features.pluginLogs = true;
|
||||
const result = await runPluginLogsCommand("example", {}, {} as never);
|
||||
const result = await runPluginLogsCommand("example", { daemonTarget }, {} as never);
|
||||
|
||||
expect(getPluginLogs).toHaveBeenCalledWith("example");
|
||||
expect(render(result, { noColor: true })).toContain("ready");
|
||||
|
||||
@@ -90,7 +90,9 @@ export async function runPluginListCommand(
|
||||
options: PluginOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<PluginListItem>> {
|
||||
const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins());
|
||||
const plugins = await withPluginManagementClient(options.daemonTarget, (client) =>
|
||||
client.listPlugins(),
|
||||
);
|
||||
const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins;
|
||||
if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`);
|
||||
return { type: "list", data, schema: pluginSchema };
|
||||
@@ -101,7 +103,9 @@ export async function runPluginLogsCommand(
|
||||
options: PluginOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<PluginLogEntry>> {
|
||||
const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId));
|
||||
const data = await withPluginLogsClient(options.daemonTarget, (client) =>
|
||||
client.getPluginLogs(pluginId),
|
||||
);
|
||||
return { type: "list", data, schema: pluginLogsSchema };
|
||||
}
|
||||
|
||||
@@ -126,10 +130,10 @@ async function install(
|
||||
isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path;
|
||||
const sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const data = canUseLegacyDirectoryInstall
|
||||
? await withPluginManagementClient(options.host, (client) =>
|
||||
? await withPluginManagementClient(options.daemonTarget, (client) =>
|
||||
client.installDirectoryPlugin(source, options.id),
|
||||
)
|
||||
: await withPluginSourceClient(options.host, (client) =>
|
||||
: await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.installPluginSource({
|
||||
source: sourceReference,
|
||||
...(options.id ? { id: options.id } : {}),
|
||||
@@ -147,7 +151,7 @@ async function update(
|
||||
if ((pluginId === undefined) === (options.all !== true)) {
|
||||
throw new Error("Choose one plugin ID or pass --all");
|
||||
}
|
||||
const data = await withPluginSourceClient(options.host, (client) =>
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.updatePluginSources(pluginId),
|
||||
);
|
||||
return { type: "list", data, schema: pluginUpdateSchema };
|
||||
@@ -158,7 +162,7 @@ async function act(
|
||||
pluginId: string,
|
||||
options: PluginOptions,
|
||||
): Promise<SingleResult<PluginListItem>> {
|
||||
const data = await withPluginManagementClient(options.host, (client) =>
|
||||
const data = await withPluginManagementClient(options.daemonTarget, (client) =>
|
||||
client[`${action}Plugin`](pluginId),
|
||||
);
|
||||
return { type: "single", data, schema: pluginSchema };
|
||||
@@ -169,7 +173,7 @@ async function remove(
|
||||
options: PluginOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<PluginListItem>> {
|
||||
const data = await withPluginManagementClient(options.host, async (client) => {
|
||||
const data = await withPluginManagementClient(options.daemonTarget, async (client) => {
|
||||
const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId);
|
||||
if (!current) throw new Error(`Plugin is not configured: ${pluginId}`);
|
||||
await client.removePlugin(pluginId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CommandError } from "../../output/index.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
@@ -5,12 +6,12 @@ import { connectToDaemon } from "../../utils/client.js";
|
||||
type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement";
|
||||
|
||||
async function withPluginClient<T>(
|
||||
host: string | undefined,
|
||||
target: DaemonTarget,
|
||||
feature: PluginFeature,
|
||||
updateMessage: string,
|
||||
run: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await connectToDaemon({ host });
|
||||
const client = await connectToDaemon({ target });
|
||||
if (client.getLastServerInfoMessage()?.features?.[feature] !== true) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw {
|
||||
@@ -26,12 +27,12 @@ async function withPluginClient<T>(
|
||||
}
|
||||
|
||||
export async function withPluginManagementClient<T>(
|
||||
host: string | undefined,
|
||||
target: DaemonTarget,
|
||||
run: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14.
|
||||
return withPluginClient(
|
||||
host,
|
||||
target,
|
||||
"pluginManagement",
|
||||
"Update the host to use plugin management.",
|
||||
run,
|
||||
@@ -39,20 +40,20 @@ export async function withPluginManagementClient<T>(
|
||||
}
|
||||
|
||||
export async function withPluginLogsClient<T>(
|
||||
host: string | undefined,
|
||||
target: DaemonTarget,
|
||||
run: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
||||
return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run);
|
||||
return withPluginClient(target, "pluginLogs", "Update the host to view plugin logs.", run);
|
||||
}
|
||||
|
||||
export async function withPluginSourceClient<T>(
|
||||
host: string | undefined,
|
||||
target: DaemonTarget,
|
||||
run: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
// COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26.
|
||||
return withPluginClient(
|
||||
host,
|
||||
target,
|
||||
"pluginGitManagement",
|
||||
"Update the host to install and update Git plugins.",
|
||||
run,
|
||||
|
||||
@@ -31,10 +31,10 @@ export async function runCreateCommand(
|
||||
const projectPath = resolveProjectPath({
|
||||
pathArg,
|
||||
cwd: process.cwd(),
|
||||
daemonTarget: options.host ?? process.env.PASEO_HOST,
|
||||
daemonTarget: options.daemonTarget.kind === "endpoint" ? options.daemonTarget.host : undefined,
|
||||
});
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ host: options.host, error });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -44,6 +44,7 @@ export async function runCreateCommand(
|
||||
}
|
||||
return { type: "single", data: toProjectRow(payload.project), schema: projectSchema };
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function runDeleteCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ProjectDeleteResult>> {
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ host: options.host, error });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -36,6 +36,7 @@ export async function runDeleteCommand(
|
||||
schema: projectDeleteSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
|
||||
@@ -7,8 +7,8 @@ export async function runLsCommand(
|
||||
options: CommandOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ProjectRow>> {
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ host: options.host, error });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -47,7 +47,11 @@ describe("project commands", () => {
|
||||
});
|
||||
|
||||
it("creates a project from an absolute directory path", async () => {
|
||||
const result = await runCreateCommand("relative/project", {}, {} as never);
|
||||
const result = await runCreateCommand(
|
||||
"relative/project",
|
||||
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project"));
|
||||
expect(result.data).toEqual({
|
||||
@@ -77,7 +81,10 @@ describe("project commands", () => {
|
||||
});
|
||||
|
||||
it("lists projects", async () => {
|
||||
const result = await runLsCommand({}, {} as never);
|
||||
const result = await runLsCommand(
|
||||
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(listProjects).toHaveBeenCalled();
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -88,7 +95,12 @@ describe("project commands", () => {
|
||||
expect(resolveProjectName({ name: " New name " })).toBe("New name");
|
||||
expect(resolveProjectName({ reset: true })).toBeNull();
|
||||
|
||||
const result = await runRenameCommand("project-1", " New name ", {}, {} as never);
|
||||
const result = await runRenameCommand(
|
||||
"project-1",
|
||||
" New name ",
|
||||
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
|
||||
{} as never,
|
||||
);
|
||||
expect(renameProject).toHaveBeenCalledWith("project-1", "New name");
|
||||
expect(result.data).toEqual({ projectId: "project-1", name: "New name" });
|
||||
});
|
||||
@@ -98,7 +110,11 @@ describe("project commands", () => {
|
||||
});
|
||||
|
||||
it("deletes a project", async () => {
|
||||
const result = await runDeleteCommand("project-1", {}, {} as never);
|
||||
const result = await runDeleteCommand(
|
||||
"project-1",
|
||||
{ daemonTarget: { kind: "instance", home: "/tmp/project-test" } },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(removeProject).toHaveBeenCalledWith("project-1");
|
||||
expect(result.data).toEqual({
|
||||
|
||||
@@ -44,8 +44,8 @@ export async function runRenameCommand(
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ProjectRenameResult>> {
|
||||
const name = resolveProjectName({ name: nameArg, reset: options.reset });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ host: options.host, error });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => {
|
||||
throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error });
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -56,6 +56,7 @@ export async function runRenameCommand(
|
||||
schema: projectRenameSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function runDiagnosticCommand(
|
||||
options: ProviderDiagnosticOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ProviderDiagnosticResult>> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider.trim().toLowerCase());
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
export interface ProviderListItem {
|
||||
provider: ProviderSnapshotEntry["provider"];
|
||||
@@ -13,20 +12,6 @@ export interface ProviderListItem {
|
||||
modes: string;
|
||||
}
|
||||
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
|
||||
defaultMode: def.defaultModeId ?? "-",
|
||||
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
||||
}));
|
||||
|
||||
function getStaticProviders(): ProviderListItem[] {
|
||||
return PROVIDERS;
|
||||
}
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: "provider",
|
||||
@@ -59,15 +44,7 @@ export async function runLsCommand(
|
||||
options: ProviderLsOptions,
|
||||
_command: Command,
|
||||
): Promise<ProviderLsResult> {
|
||||
const client = await tryConnectToDaemon({ host: options.host });
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
}
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
@@ -83,12 +60,6 @@ export async function runLsCommand(
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function runModelsCommand(
|
||||
): Promise<ProviderModelsResult> {
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
try {
|
||||
const result = await client.listProviderModels(normalizedProvider);
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ export async function runCreateCommand(
|
||||
mode: options.mode,
|
||||
thinking: options.thinking,
|
||||
cwd: options.cwd,
|
||||
host: options.host,
|
||||
daemonTarget: options.daemonTarget,
|
||||
maxRuns: options.maxRuns,
|
||||
expiresIn: options.expiresIn,
|
||||
runNow,
|
||||
});
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
const payload = await client.scheduleCreate(input);
|
||||
if (payload.error || !payload.schedule) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function runDeleteCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleDeleteRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
const payload = await client.scheduleDelete({ id });
|
||||
if (payload.error) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function runInspectCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ScheduleInspectRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
const payload = await client.scheduleInspect({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function runLogsCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ScheduleLogRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleLogs({ id });
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function runLsCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
const payload = await client.scheduleList();
|
||||
if (payload.error) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function runPauseCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.schedulePause({ id });
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function runResumeCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleResume({ id });
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function runRunOnceCommand(
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
const { client } = await connectScheduleClient(options.daemonTarget);
|
||||
try {
|
||||
await requireNewAgentSchedule(client, id);
|
||||
const payload = await client.scheduleRunOnce({ id });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { selectDaemonTarget } from "../../utils/daemon-target.js";
|
||||
|
||||
import {
|
||||
compileEveryPresetToCron,
|
||||
@@ -7,32 +9,25 @@ import {
|
||||
} from "./shared.js";
|
||||
|
||||
const baseOptions = {
|
||||
daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}),
|
||||
prompt: "do the thing",
|
||||
every: "5m",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const baseCron = {
|
||||
daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}),
|
||||
prompt: "do the thing",
|
||||
cron: "0 9 * * *",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/local/project");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("no host, no cwd → defaults to process.cwd()", () => {
|
||||
const input = parseScheduleCreateInput(baseOptions);
|
||||
expect(input.target).toEqual({
|
||||
type: "new-agent",
|
||||
config: { provider: "claude", cwd: "/local/project" },
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +42,7 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
test("host with cwd → uses provided cwd", () => {
|
||||
const input = parseScheduleCreateInput({
|
||||
...baseOptions,
|
||||
host: "dev:6767",
|
||||
daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}),
|
||||
cwd: "/remote/project",
|
||||
});
|
||||
expect(input.target).toEqual({
|
||||
@@ -57,7 +52,12 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
});
|
||||
|
||||
test("host without cwd → throws MISSING_CWD", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, host: "dev:6767" })).toThrow(
|
||||
expect(() =>
|
||||
parseScheduleCreateInput({
|
||||
...baseOptions,
|
||||
daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}),
|
||||
}),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "MISSING_CWD",
|
||||
message: expect.stringContaining("--cwd is required when --host is specified"),
|
||||
@@ -66,9 +66,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
});
|
||||
|
||||
test("PASEO_HOST without cwd → throws MISSING_CWD", () => {
|
||||
vi.stubEnv("PASEO_HOST", "dev:6767");
|
||||
const daemonTarget = selectDaemonTarget({}, { PASEO_HOST: "dev:12345" });
|
||||
|
||||
expect(() => parseScheduleCreateInput(baseOptions)).toThrow(
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, daemonTarget })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "MISSING_CWD",
|
||||
message: expect.stringContaining("--cwd is required"),
|
||||
@@ -76,9 +76,21 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit home beats PASEO_HOST and defaults cwd locally", () => {
|
||||
const daemonTarget = selectDaemonTarget({ home: process.cwd() }, { PASEO_HOST: "dev:12345" });
|
||||
expect(parseScheduleCreateInput({ ...baseOptions, daemonTarget }).target).toEqual({
|
||||
type: "new-agent",
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
});
|
||||
});
|
||||
|
||||
test("host with whitespace-only cwd → throws MISSING_CWD", () => {
|
||||
expect(() =>
|
||||
parseScheduleCreateInput({ ...baseOptions, host: "dev:6767", cwd: " " }),
|
||||
parseScheduleCreateInput({
|
||||
...baseOptions,
|
||||
daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}),
|
||||
cwd: " ",
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "MISSING_CWD" }));
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user