mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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.
This commit is contained in:
@@ -1,14 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { ensurePrivateDirectory } from "@getpaseo/server";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const CLIENT_SESSION_KEY_FILE = join(
|
||||
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
|
||||
"cli-client-id",
|
||||
);
|
||||
|
||||
let cachedClientId: string | null = null;
|
||||
import { join } from "node:path";
|
||||
|
||||
function normalizeClientId(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
@@ -19,15 +12,12 @@ function generateClientId(): string {
|
||||
return `cid_${randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
export async function getOrCreateCliClientId(): Promise<string> {
|
||||
if (cachedClientId) {
|
||||
return cachedClientId;
|
||||
}
|
||||
export async function getOrCreateCliClientId(home: string): Promise<string> {
|
||||
const clientSessionKeyFile = join(home, "cli-client-id");
|
||||
|
||||
try {
|
||||
const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8"));
|
||||
const existing = normalizeClientId(await readFile(clientSessionKeyFile, "utf8"));
|
||||
if (existing) {
|
||||
cachedClientId = existing;
|
||||
return existing;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -38,8 +28,7 @@ export async function getOrCreateCliClientId(): Promise<string> {
|
||||
}
|
||||
|
||||
const nextValue = generateClientId();
|
||||
await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true });
|
||||
await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 });
|
||||
cachedClientId = nextValue;
|
||||
ensurePrivateDirectory(home);
|
||||
await writeFile(clientSessionKeyFile, nextValue, { mode: 0o600 });
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ describe("CLI SSH transport", () => {
|
||||
});
|
||||
|
||||
it("routes an SSH host through a local tunnel", async () => {
|
||||
await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" });
|
||||
await connectToDaemon({
|
||||
target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" },
|
||||
});
|
||||
|
||||
expect(mocks.createSshTunnel).toHaveBeenCalledWith({
|
||||
host: "deploy@build-box",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||
import { waitForDaemonReady, resolvePaseoHome, type DaemonInstance } from "@getpaseo/server";
|
||||
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
|
||||
export type { DaemonTarget } from "./daemon-target.js";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
@@ -13,57 +14,44 @@ import {
|
||||
} from "@getpaseo/protocol/connection-offer";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import { WebSocket } from "ws";
|
||||
import { getOrCreateCliClientId } from "./client-id.js";
|
||||
import { resolveCliVersion } from "../version.js";
|
||||
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string;
|
||||
target: DaemonTarget;
|
||||
timeout?: number;
|
||||
instance?: DaemonInstance;
|
||||
}
|
||||
|
||||
export interface DaemonConnectionCommandError {
|
||||
code: "DAEMON_NOT_RUNNING";
|
||||
message: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "localhost:6767";
|
||||
const DEFAULT_TIMEOUT = 15000;
|
||||
const PID_FILENAME = "paseo.pid";
|
||||
type TransportTarget =
|
||||
| { type: "tcp"; url: string }
|
||||
| { type: "ipc"; url: string; socketPath: string };
|
||||
|
||||
type DaemonTarget =
|
||||
| {
|
||||
type: "tcp";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: "ipc";
|
||||
url: string;
|
||||
socketPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the daemon host from environment or options
|
||||
*/
|
||||
export function getDaemonHost(options?: ConnectOptions): string {
|
||||
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
|
||||
export function getDaemonHost(options: ConnectOptions): string {
|
||||
return describeDaemonTarget(options.target);
|
||||
}
|
||||
|
||||
export function buildDaemonConnectionCommandError(options: {
|
||||
host?: string;
|
||||
error: unknown;
|
||||
}): DaemonConnectionCommandError {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const message = options.error instanceof Error ? options.error.message : String(options.error);
|
||||
export function buildDaemonConnectionCommandError(options: ConnectOptions & { error: unknown }) {
|
||||
const error = options.error;
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
if (error && typeof error === "object" && "message" in error) message = String(error.message);
|
||||
if (options.target.kind === "endpoint")
|
||||
message = message.replaceAll(options.target.host, describeDaemonTarget(options.target));
|
||||
if (message.startsWith("Cannot connect to daemon at "))
|
||||
return error as { code: string; message: string; details: string };
|
||||
let code = "DAEMON_UNREACHABLE";
|
||||
if (typeof error === "object" && error !== null && "code" in error) code = String(error.code);
|
||||
else if (message === "Password required") code = "AUTH_REQUIRED";
|
||||
else if (message === "Incorrect password") code = "AUTH_FAILED";
|
||||
return {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: host.trim().startsWith("ssh://")
|
||||
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
|
||||
: "Start the daemon with: paseo daemon start",
|
||||
code,
|
||||
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details:
|
||||
options.target.kind === "instance"
|
||||
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
|
||||
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,100 +106,13 @@ export function normalizeDaemonHost(raw: string): string | null {
|
||||
return trimmed.includes(":") ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function isIpcDaemonHost(host: string | null): host is string {
|
||||
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
|
||||
}
|
||||
|
||||
function isTcpDaemonHost(host: string | null): host is string {
|
||||
return host !== null && !isIpcDaemonHost(host);
|
||||
}
|
||||
|
||||
function readPidSocketTarget(paseoHome: string): string | null {
|
||||
const pidPath = path.join(paseoHome, PID_FILENAME);
|
||||
if (!existsSync(pidPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
|
||||
listen?: unknown;
|
||||
sockPath?: unknown;
|
||||
};
|
||||
if (typeof parsed.listen === "string") return parsed.listen;
|
||||
if (typeof parsed.sockPath === "string") return parsed.sockPath;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
|
||||
if (isIpcDaemonHost(directEnvHost)) {
|
||||
return directEnvHost;
|
||||
}
|
||||
|
||||
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
|
||||
if (isIpcDaemonHost(pidHost)) {
|
||||
return pidHost;
|
||||
}
|
||||
|
||||
const config = loadConfig(paseoHome, { env });
|
||||
const configuredHost = normalizeDaemonHost(config.listen);
|
||||
return isIpcDaemonHost(configuredHost) ? configuredHost : null;
|
||||
}
|
||||
|
||||
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
|
||||
if (!isTcpDaemonHost(configuredHost)) {
|
||||
return null;
|
||||
}
|
||||
return configuredHost === "127.0.0.1:6767" ? null : configuredHost;
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const paseoHome = resolvePaseoHome(env);
|
||||
const candidates: string[] = [];
|
||||
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
|
||||
if (configuredIpcHost) {
|
||||
candidates.push(configuredIpcHost);
|
||||
}
|
||||
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
|
||||
if (configuredTcpHost) {
|
||||
candidates.push(configuredTcpHost);
|
||||
}
|
||||
candidates.push(DEFAULT_HOST);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
|
||||
const explicitHost = getExplicitDaemonHost(options?.host);
|
||||
if (explicitHost) {
|
||||
return [explicitHost];
|
||||
}
|
||||
|
||||
return resolveDefaultDaemonHosts();
|
||||
}
|
||||
|
||||
export function getExplicitDaemonHost(
|
||||
host: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const explicitHost = host ?? env.PASEO_HOST;
|
||||
return explicitHost?.trim() ? explicitHost : undefined;
|
||||
}
|
||||
|
||||
function stripIpcPrefix(trimmed: string): string {
|
||||
if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim();
|
||||
if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
export function resolveDaemonTarget(host: string): TransportTarget {
|
||||
const trimmed = normalizeDaemonHost(host);
|
||||
if (!trimmed) {
|
||||
throw new Error(`Invalid daemon target: ${host}`);
|
||||
@@ -363,12 +264,21 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
const clientId = await getOrCreateCliClientId();
|
||||
async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonClient> {
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
||||
const deadline = Date.now() + timeout;
|
||||
const explicitHost =
|
||||
options.target.kind === "endpoint"
|
||||
? options.target.host
|
||||
: (
|
||||
await waitForDaemonReady(options.target.home, {
|
||||
timeoutMs: timeout,
|
||||
instance: options.instance,
|
||||
})
|
||||
).listen;
|
||||
const clientId = await getOrCreateCliClientId(resolvePaseoHome({}));
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||
|
||||
const explicitHost = getExplicitDaemonHost(options?.host);
|
||||
if (explicitHost?.trim().startsWith("ssh://")) {
|
||||
const target = parseSshTransportUri(explicitHost.trim());
|
||||
const tunnel = await createSshTunnel(target);
|
||||
@@ -377,10 +287,20 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
||||
tunnel.endpoint,
|
||||
password,
|
||||
clientId,
|
||||
timeout,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
if ("client" in result) return result.client;
|
||||
if ("client" in result) {
|
||||
const close = result.client.close.bind(result.client);
|
||||
result.client.close = async () => {
|
||||
try {
|
||||
await close();
|
||||
} finally {
|
||||
tunnel.close();
|
||||
}
|
||||
};
|
||||
return result.client;
|
||||
}
|
||||
|
||||
const failure = tunnel.failureDetail();
|
||||
tunnel.close();
|
||||
@@ -389,32 +309,37 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
||||
}
|
||||
const offer = parseHostOfferOrNull(explicitHost);
|
||||
if (offer) {
|
||||
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
|
||||
return connectViaRelayOffer(
|
||||
offer,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
}
|
||||
|
||||
const hosts = resolveDaemonHostCandidates(options);
|
||||
const result = await tryConnectHost(
|
||||
explicitHost,
|
||||
resolveDaemonPassword(explicitHost),
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
if ("client" in result) return result.client;
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
|
||||
if (index >= hosts.length) {
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
|
||||
}
|
||||
const host = hosts[index];
|
||||
const password = resolveDaemonPassword(host);
|
||||
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
|
||||
if ("client" in result) {
|
||||
return result.client;
|
||||
}
|
||||
return tryNext(index + 1, result.error);
|
||||
export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> {
|
||||
try {
|
||||
return await connectSelectedDaemon(options);
|
||||
} catch (error) {
|
||||
throw buildDaemonConnectionCommandError({ ...options, error });
|
||||
}
|
||||
|
||||
return tryNext(0, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to connect to the daemon, returns null if connection fails
|
||||
*/
|
||||
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
|
||||
export async function tryConnectToDaemon(options: ConnectOptions): Promise<DaemonClient | null> {
|
||||
try {
|
||||
return await connectToDaemon(options);
|
||||
} catch {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addDaemonHostOption, withGlobalOptions } from "./command-options.js";
|
||||
import { createCli } from "../cli.js";
|
||||
import { withGlobalOptions } from "./command-options.js";
|
||||
|
||||
async function parseHost(argv: string[]): Promise<string | undefined> {
|
||||
const program = new Command().exitOverride().option("--host <host>");
|
||||
const program = createCli().exitOverride();
|
||||
let receivedHost: string | undefined;
|
||||
|
||||
addDaemonHostOption(program.command("ls")).action(
|
||||
withGlobalOptions((options: { host?: string }) => {
|
||||
receivedHost = options.host;
|
||||
}),
|
||||
);
|
||||
program.commands
|
||||
.find((command) => command.name() === "ls")!
|
||||
.action(
|
||||
withGlobalOptions((options: { host?: string }) => {
|
||||
receivedHost = options.host;
|
||||
}),
|
||||
);
|
||||
|
||||
await program.parseAsync(argv, { from: "user" });
|
||||
return receivedHost;
|
||||
@@ -25,10 +28,10 @@ describe("global command options", () => {
|
||||
await expect(parseHost(["ls", "--host", "local:6767"])).resolves.toBe("local:6767");
|
||||
});
|
||||
|
||||
it("uses the last explicit host", async () => {
|
||||
await expect(parseHost(["--host", "first:6767", "ls", "--host", "last:6767"])).resolves.toBe(
|
||||
"last:6767",
|
||||
);
|
||||
it("rejects conflicting parent and command selectors", async () => {
|
||||
await expect(
|
||||
parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]),
|
||||
).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" });
|
||||
});
|
||||
|
||||
it("lets local-only commands ignore a global host", async () => {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { Command } from "commander";
|
||||
import { selectDaemonTarget } from "./daemon-target.js";
|
||||
|
||||
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
|
||||
const DAEMON_HOST_OPTION_DESCRIPTION =
|
||||
"Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
|
||||
"Explicit daemon endpoint: host:port, TCP, socket, SSH, or pairing URL (default: local home)";
|
||||
|
||||
const localCommands = new WeakSet<Command>();
|
||||
|
||||
export function addLocalDaemonOptions<T extends Command>(command: T): T {
|
||||
localCommands.add(command);
|
||||
command.option("--home <path>", "Local daemon home (default: ~/.paseo)");
|
||||
return command;
|
||||
}
|
||||
|
||||
export function collectMultiple(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value]);
|
||||
@@ -15,6 +24,8 @@ export function addJsonOption<T extends Command>(command: T): T {
|
||||
|
||||
export function addDaemonHostOption<T extends Command>(command: T): T {
|
||||
command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION);
|
||||
if (!command.options.some((option) => option.long === "--home"))
|
||||
command.option("--home <path>", "Select a local daemon home (default: ~/.paseo)");
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -28,7 +39,19 @@ export function withGlobalOptions<Args extends unknown[], Result>(
|
||||
return (...args) => {
|
||||
const command = args.at(-1) as Command;
|
||||
const mergedArgs = [...args];
|
||||
mergedArgs[mergedArgs.length - 2] = command.optsWithGlobals();
|
||||
const options = command.optsWithGlobals();
|
||||
const selectors = { home: new Set<string>(), host: new Set<string>() };
|
||||
for (let current: Command | null = command; current; current = current.parent) {
|
||||
for (const key of ["home", "host"] as const) {
|
||||
const value = current.opts()[key];
|
||||
if (typeof value === "string") selectors[key].add(value);
|
||||
}
|
||||
}
|
||||
if (selectors.home.size > 1 || selectors.host.size > 1)
|
||||
throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." };
|
||||
const localOnly = localCommands.has(command);
|
||||
options.daemonTarget = selectDaemonTarget(options, process.env, localOnly);
|
||||
mergedArgs[mergedArgs.length - 2] = options;
|
||||
return handler(...(mergedArgs as Args));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
|
||||
|
||||
test("explicit selectors win over both environment selectors", () => {
|
||||
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
|
||||
expect(selectDaemonTarget({ home: "/tmp/b" }, env)).toEqual({ kind: "instance", home: "/tmp/b" });
|
||||
expect(selectDaemonTarget({ host: "chosen:23456" }, env)).toEqual({
|
||||
kind: "endpoint",
|
||||
host: "chosen:23456",
|
||||
});
|
||||
expect(() => selectDaemonTarget({}, env)).toThrow();
|
||||
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
|
||||
});
|
||||
|
||||
test("local operations ignore routing environment but reject an explicit endpoint", () => {
|
||||
expect(
|
||||
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
|
||||
).toEqual({ kind: "instance", home: "/tmp/b" });
|
||||
expect(() => selectDaemonTarget({ host: "chosen:23456" }, {}, true)).toThrow();
|
||||
});
|
||||
|
||||
test("endpoint descriptions redact pairing material and credentials", () => {
|
||||
expect(
|
||||
describeDaemonTarget({
|
||||
kind: "endpoint",
|
||||
host: "tcp://user:private@example.test:23456?password=secret",
|
||||
}),
|
||||
).not.toMatch(/private|secret/);
|
||||
expect(
|
||||
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
|
||||
).not.toContain("private");
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { resolvePaseoHome } from "@getpaseo/server";
|
||||
|
||||
export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string };
|
||||
|
||||
export function selectDaemonTarget(
|
||||
options: { home?: string; host?: string },
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
localOnly = false,
|
||||
): DaemonTarget {
|
||||
for (const key of ["home", "host"] as const) {
|
||||
if (options[key] !== undefined && !options[key]!.trim())
|
||||
throw { code: "TARGET_INVALID", message: `--${key} requires a non-empty value.` };
|
||||
}
|
||||
if (options.home !== undefined && options.host !== undefined)
|
||||
throw { code: "TARGET_AMBIGUOUS", message: "Choose either --home or --host, not both." };
|
||||
if (localOnly) {
|
||||
if (options.host !== undefined)
|
||||
throw {
|
||||
code: "LOCAL_OPERATION",
|
||||
message: "This is a local operation; use --home. --host is not supported.",
|
||||
};
|
||||
return {
|
||||
kind: "instance",
|
||||
home: resolvePaseoHome({ PASEO_HOME: options.home ?? env.PASEO_HOME }),
|
||||
};
|
||||
}
|
||||
if (options.home !== undefined)
|
||||
return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: options.home }) };
|
||||
if (options.host !== undefined) return { kind: "endpoint", host: options.host };
|
||||
if (env.PASEO_HOME && env.PASEO_HOST)
|
||||
throw {
|
||||
code: "TARGET_AMBIGUOUS",
|
||||
message: "PASEO_HOME and PASEO_HOST are both set. Choose --home or --host explicitly.",
|
||||
};
|
||||
if (env.PASEO_HOST) return { kind: "endpoint", host: env.PASEO_HOST };
|
||||
return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: env.PASEO_HOME }) };
|
||||
}
|
||||
|
||||
export function describeDaemonTarget(target: DaemonTarget): string {
|
||||
if (target.kind === "instance") return `home ${target.home}`;
|
||||
try {
|
||||
const url = new URL(target.host);
|
||||
if (url.password) url.password = "REDACTED";
|
||||
for (const key of url.searchParams.keys())
|
||||
if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED");
|
||||
if (url.hash) url.hash = "REDACTED";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user