Fix desktop CLI passthrough tty handling (#791)

* Fix desktop CLI passthrough tty handling

* Fix desktop daemon manager test drift

* Update daemon stop test after main merge

* test(cli): isolate npx cache per CLI test
This commit is contained in:
Mohamed Boudra
2026-05-07 15:14:18 +08:00
committed by GitHub
parent 6fb2fe2283
commit faf664d803
20 changed files with 868 additions and 461 deletions
+4 -24
View File
@@ -1,26 +1,6 @@
import { createCli } from "./cli.js";
import { classifyInvocation } from "./classify.js";
import { openDesktopWithProject } from "./commands/open.js";
import { runCli } from "./run.js";
const program = createCli();
const knownCommands = new Set(program.commands.map((command) => command.name()));
const invocation = classifyInvocation({
argv: process.argv.slice(2),
knownCommands,
cwd: process.cwd(),
const exitCode = await runCli(process.argv.slice(2), {
nodeArgv: [process.argv[0] ?? "node", process.argv[1] ?? "paseo"],
});
switch (invocation.kind) {
case "cli": {
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
if (invocation.argv.length === 0) {
argv.push("onboard");
}
program.parse(argv, { from: "node" });
break;
}
case "open-project":
await openDesktopWithProject(invocation.resolvedPath);
break;
}
process.exitCode = exitCode;
+48
View File
@@ -0,0 +1,48 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createCliParseArgv } from "./run";
describe("runCli", () => {
it("defaults an empty CLI invocation to onboard", () => {
expect(
createCliParseArgv({
argv: [],
cwd: process.cwd(),
nodeArgv: ["node", "paseo"],
}),
).toEqual(["node", "paseo", "onboard"]);
});
it("preserves known CLI command argv", () => {
expect(
createCliParseArgv({
argv: ["daemon", "set-password"],
cwd: process.cwd(),
nodeArgv: ["node", "paseo"],
}),
).toEqual(["node", "paseo", "daemon", "set-password"]);
});
it("classifies existing unknown directories as open-project invocations", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
const project = path.join(root, "project");
mkdirSync(project);
try {
expect(
createCliParseArgv({
argv: ["project"],
cwd: root,
nodeArgv: ["node", "paseo"],
}),
).toEqual({
kind: "open-project",
resolvedPath: project,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { createCli } from "./cli.js";
import { classifyInvocation } from "./classify.js";
import { openDesktopWithProject } from "./commands/open.js";
export interface RunCliOptions {
cwd?: string;
nodeArgv?: [string, string];
}
export function createCliParseArgv(input: {
argv: string[];
cwd: string;
nodeArgv?: [string, string];
}): string[] | { kind: "open-project"; resolvedPath: string } {
const program = createCli();
const knownCommands = new Set(program.commands.map((command) => command.name()));
const invocation = classifyInvocation({
argv: input.argv,
knownCommands,
cwd: input.cwd,
});
if (invocation.kind === "open-project") {
return invocation;
}
const nodeArgv = input.nodeArgv ?? ["paseo", "paseo"];
const cliArgv = invocation.argv.length === 0 ? ["onboard"] : invocation.argv;
return [...nodeArgv, ...cliArgv];
}
export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
const parseArgv = createCliParseArgv({
argv,
cwd: options.cwd ?? process.cwd(),
nodeArgv: options.nodeArgv,
});
if (!Array.isArray(parseArgv)) {
await openDesktopWithProject(parseArgv.resolvedPath);
return typeof process.exitCode === "number" ? process.exitCode : 0;
}
const program = createCli();
await program.parseAsync(parseArgv, { from: "node" });
return typeof process.exitCode === "number" ? process.exitCode : 0;
}
+52 -45
View File
@@ -10,7 +10,8 @@
import { spawn } from "child_process";
import { $ } from "zx";
import { readdir, writeFile } from "fs/promises";
import { mkdtemp, readdir, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join, dirname, delimiter } from "path";
import { fileURLToPath } from "url";
@@ -192,55 +193,61 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
const testPath = join(__dirname, testFile);
const testName = testFile.replace(/\.test\.ts$/, "");
const startedAt = Date.now();
const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-"));
return new Promise<TestOutcome>((resolve) => {
const proc = spawn("npx", ["tsx", testPath], {
env: {
...process.env,
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: testEnvDefaults.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: testEnvDefaults.PASEO_VOICE_MODE_ENABLED,
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
return await new Promise<TestOutcome>((resolve) => {
const proc = spawn("npx", ["tsx", testPath], {
env: {
...process.env,
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
npm_config_cache: npmCache,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: testEnvDefaults.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: testEnvDefaults.PASEO_VOICE_MODE_ENABLED,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
proc.on("error", (err) => {
const durationMs = Date.now() - startedAt;
const message = err instanceof Error ? err.message : String(err);
flushTestBlock(testName, durationMs, false, stdout, stderr || message);
resolve({
status: "failed",
durationMs,
failure: { test: testName, error: message },
proc.on("error", (err) => {
const durationMs = Date.now() - startedAt;
const message = err instanceof Error ? err.message : String(err);
flushTestBlock(testName, durationMs, false, stdout, stderr || message);
resolve({
status: "failed",
durationMs,
failure: { test: testName, error: message },
});
});
proc.on("exit", (code) => {
const durationMs = Date.now() - startedAt;
const exitCode = code ?? 1;
if (exitCode === 0) {
flushTestBlock(testName, durationMs, true, stdout, stderr);
resolve({ status: "passed", durationMs });
return;
}
flushTestBlock(testName, durationMs, false, stdout, stderr);
resolve({
status: "failed",
durationMs,
failure: { test: testName, error: stderr || `Exit code: ${exitCode}` },
});
});
});
proc.on("exit", (code) => {
const durationMs = Date.now() - startedAt;
const exitCode = code ?? 1;
if (exitCode === 0) {
flushTestBlock(testName, durationMs, true, stdout, stderr);
resolve({ status: "passed", durationMs });
return;
}
flushTestBlock(testName, durationMs, false, stdout, stderr);
resolve({
status: "failed",
durationMs,
failure: { test: testName, error: stderr || `Exit code: ${exitCode}` },
});
});
});
} finally {
await rm(npmCache, { recursive: true, force: true });
}
}
function flushTestBlock(
@@ -0,0 +1,80 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
import type { NodeEntrypointSpec } from "../node-entrypoint-launcher.js";
import {
assertPathExists,
findPackageRootFromResolvedPath,
resolvePackagedAsarPath,
} from "../package-paths.js";
const CLI_PACKAGE_NAME = "@getpaseo/cli";
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
const CLI_RUN_ENTRY = `${CLI_PACKAGE_NAME}/dist/run.js`;
const esmRequire = createRequire(__filename);
function resolveCliPackageRoot(): string {
return findPackageRootFromResolvedPath({
resolvedPath: esmRequire.resolve(CLI_BIN_ENTRY),
packageName: CLI_PACKAGE_NAME,
}).root;
}
export function resolveExternalCliEntrypoint(): NodeEntrypointSpec {
if (app.isPackaged) {
return {
entryPath: assertPathExists({
label: "Bundled external CLI entrypoint",
filePath: path.join(
resolvePackagedAsarPath(),
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js",
),
}),
execArgv: [],
};
}
const cliRoot = resolveCliPackageRoot();
const distEntry = path.join(cliRoot, "dist", "index.js");
if (existsSync(distEntry)) {
return {
entryPath: distEntry,
execArgv: [],
};
}
return {
entryPath: assertPathExists({
label: "External CLI source entrypoint",
filePath: path.join(cliRoot, "src", "index.ts"),
}),
execArgv: ["--import", "tsx"],
};
}
export function resolvePassthroughCliEntrypoint(): string {
if (app.isPackaged) {
return assertPathExists({
label: "Bundled passthrough CLI entrypoint",
filePath: path.join(
resolvePackagedAsarPath(),
"node_modules",
"@getpaseo",
"cli",
"dist",
"run.js",
),
});
}
return assertPathExists({
label: "Passthrough CLI entrypoint",
filePath: esmRequire.resolve(CLI_RUN_ENTRY),
});
}
@@ -0,0 +1,91 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./external";
const mocks = vi.hoisted(() => ({
createNodeEntrypointInvocation: vi.fn(() => ({
command: "node",
args: ["runner.js", "node-script", "cli.js"],
env: { PASEO_NODE_ENV: "production" },
})),
resolveExternalCliEntrypoint: vi.fn(() => ({
entryPath: "cli.js",
execArgv: [],
})),
spawnProcess: vi.fn(),
}));
vi.mock("@getpaseo/server", () => ({
spawnProcess: mocks.spawnProcess,
}));
vi.mock("electron-log/main", () => ({
default: { warn: vi.fn() },
}));
vi.mock("../runtime-paths.js", () => ({
createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation,
}));
vi.mock("./entrypoints.js", () => ({
resolveExternalCliEntrypoint: mocks.resolveExternalCliEntrypoint,
}));
function mockExternalCliOutput(input: {
stdout: string;
stderr?: string;
exitCode?: number;
}): void {
mocks.spawnProcess.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
process.nextTick(() => {
if (input.stdout.length > 0) {
child.stdout.emit("data", Buffer.from(input.stdout));
}
if (input.stderr && input.stderr.length > 0) {
child.stderr.emit("data", Buffer.from(input.stderr));
}
child.emit("close", input.exitCode ?? 0);
});
return child;
});
}
describe("external CLI", () => {
it("runs text commands through an isolated CLI process", async () => {
mockExternalCliOutput({ stdout: "daemon running\n" });
await expect(runExternalCliTextCommand(["daemon", "status"])).resolves.toBe("daemon running");
expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith({
entrypoint: { entryPath: "cli.js", execArgv: [] },
argvMode: "node-script",
args: ["daemon", "status"],
baseEnv: process.env,
});
expect(mocks.spawnProcess).toHaveBeenCalledWith(
"node",
["runner.js", "node-script", "cli.js"],
{
envMode: "internal",
env: { PASEO_NODE_ENV: "production" },
stdio: ["ignore", "pipe", "pipe"],
},
);
});
it("parses JSON output from an isolated CLI process", async () => {
mockExternalCliOutput({ stdout: '{"localDaemon":"running"}\n' });
await expect(runExternalCliJsonCommand(["daemon", "status", "--json"])).resolves.toEqual({
localDaemon: "running",
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { spawnProcess } from "@getpaseo/server";
import log from "electron-log/main";
import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js";
import { createNodeEntrypointInvocation } from "../runtime-paths.js";
import { resolveExternalCliEntrypoint } from "./entrypoints.js";
function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation {
return createNodeEntrypointInvocation({
entrypoint: resolveExternalCliEntrypoint(),
argvMode: "node-script",
args,
baseEnv: process.env,
});
}
function spawnExternalCli(
invocation: NodeEntrypointInvocation,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawnProcess(invocation.command, invocation.args, {
envMode: "internal",
env: invocation.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
});
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({ stdout, stderr, exitCode });
});
});
}
function externalCliFailureMessage(
exitCode: number | null,
stdout: string,
stderr: string,
): string {
if (stderr.length > 0) {
return stderr;
}
return `CLI command failed with exit code ${exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`;
}
export async function runExternalCliTextCommand(args: string[]): Promise<string> {
const invocation = createExternalCliInvocation(args);
const result = await spawnExternalCli(invocation);
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
log.warn("[desktop external-cli]", "CLI text command failed", {
args,
exitCode: result.exitCode,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
});
throw new Error(externalCliFailureMessage(result.exitCode, stdout, stderr));
}
return result.stdout.trimEnd();
}
export async function runExternalCliJsonCommand(args: string[]): Promise<unknown> {
const invocation = createExternalCliInvocation(args);
const result = await spawnExternalCli(invocation);
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
log.warn("[desktop external-cli]", "CLI JSON command failed", {
args,
exitCode: result.exitCode,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
command: invocation.command,
});
throw new Error(externalCliFailureMessage(result.exitCode, stdout, stderr));
}
const stdout = result.stdout.trim();
if (stdout.length === 0) {
log.warn("[desktop external-cli]", "CLI command produced no output", { args });
throw new Error("CLI command did not produce JSON output.");
}
const jsonStart = stdout.search(/[{[]/);
if (jsonStart < 0) {
log.warn("[desktop external-cli]", "CLI command output contained no JSON", {
args,
stdout: stdout.slice(0, 500),
});
throw new Error(`CLI command output contained no JSON. Output: ${stdout.slice(0, 200)}`);
}
try {
return JSON.parse(stdout.slice(jsonStart)) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
parsePassthroughCliArgs,
parsePassthroughCliArgsFromArgv,
runPassthroughCli,
} from "./passthrough";
const originalDefaultApp = process.defaultApp;
const originalDesktopCli = process.env.PASEO_DESKTOP_CLI;
function setDefaultApp(value: boolean): void {
Object.defineProperty(process, "defaultApp", {
configurable: true,
value,
});
}
describe("passthrough CLI", () => {
afterEach(() => {
setDefaultApp(originalDefaultApp);
if (originalDesktopCli === undefined) {
delete process.env.PASEO_DESKTOP_CLI;
} else {
process.env.PASEO_DESKTOP_CLI = originalDesktopCli;
}
});
it("returns null when no CLI args are provided", () => {
expect(
parsePassthroughCliArgs({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores macOS GUI launch arguments", () => {
expect(
parsePassthroughCliArgs({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "-psn_0_12345"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores --no-sandbox injected by Linux wrapper", () => {
expect(
parsePassthroughCliArgs({
argv: ["/usr/bin/Paseo", "--no-sandbox", "status"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["status"]);
});
it("returns null when only --no-sandbox is present", () => {
expect(
parsePassthroughCliArgs({
argv: ["/usr/bin/Paseo", "--no-sandbox"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("preserves CLI flags for direct app invocations", () => {
expect(
parsePassthroughCliArgs({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--version"]);
});
it("passes --open-project through as a normal CLI arg", () => {
expect(
parsePassthroughCliArgs({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--open-project", "/tmp/project"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--open-project", "/tmp/project"]);
});
it("forces CLI mode for shim launches even without args", () => {
expect(
parsePassthroughCliArgs({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: true,
}),
).toEqual([]);
});
it("parses terminal args for direct app CLI passthrough", () => {
setDefaultApp(false);
delete process.env.PASEO_DESKTOP_CLI;
expect(
parsePassthroughCliArgsFromArgv([
"/Applications/Paseo.app/Contents/MacOS/Paseo",
"daemon",
"set-password",
]),
).toEqual(["daemon", "set-password"]);
});
it("runs passthrough CLI through the programmatic entrypoint", async () => {
const runCli = vi.fn(async () => 7);
await expect(runPassthroughCli(["daemon", "set-password"], { runCli })).resolves.toBe(7);
expect(runCli).toHaveBeenCalledWith(["daemon", "set-password"]);
});
});
@@ -0,0 +1,56 @@
import { pathToFileURL } from "node:url";
import { resolvePassthroughCliEntrypoint } from "./entrypoints.js";
const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI";
const IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
export type PassthroughCliRunner = (argv: string[]) => Promise<number>;
export function parsePassthroughCliArgs(input: {
argv: string[];
isDefaultApp: boolean;
forceCli: boolean;
}): string[] | null {
const startIndex = input.isDefaultApp ? 2 : 1;
const effective: string[] = [];
for (const arg of input.argv.slice(startIndex)) {
if (IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
continue;
}
effective.push(arg);
}
if (input.forceCli) {
return effective;
}
return effective.length > 0 ? effective : null;
}
export function parsePassthroughCliArgsFromArgv(argv: string[]): string[] | null {
return parsePassthroughCliArgs({
argv,
isDefaultApp: process.defaultApp,
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
});
}
async function importPassthroughCliRunner(): Promise<PassthroughCliRunner> {
const entrypoint = resolvePassthroughCliEntrypoint();
const imported = (await import(pathToFileURL(entrypoint).href)) as {
runCli?: unknown;
};
if (typeof imported.runCli !== "function") {
throw new Error(`Passthrough CLI entrypoint did not export runCli: ${entrypoint}`);
}
return imported.runCli as PassthroughCliRunner;
}
export async function runPassthroughCli(
args: string[],
options: { runCli?: PassthroughCliRunner } = {},
): Promise<number> {
const runCli = options.runCli ?? (await importPassthroughCliRunner());
return await runCli(args);
}
@@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({
keepRunningAfterQuit: true,
},
},
runCliJsonCommand: vi.fn(),
runCliTextCommand: vi.fn(),
runExternalCliJsonCommand: vi.fn(),
runExternalCliTextCommand: vi.fn(),
spawnProcess: vi.fn(),
}));
@@ -53,8 +53,11 @@ vi.mock("./runtime-paths.js", () => ({
entryPath: "/tmp/daemon.js",
execArgv: [],
})),
runCliJsonCommand: mocks.runCliJsonCommand,
runCliTextCommand: mocks.runCliTextCommand,
}));
vi.mock("./cli/external.js", () => ({
runExternalCliJsonCommand: mocks.runExternalCliJsonCommand,
runExternalCliTextCommand: mocks.runExternalCliTextCommand,
}));
function desktopSettingsWithManagement(enabled: boolean) {
@@ -70,8 +73,8 @@ function desktopSettingsWithManagement(enabled: boolean) {
describe("daemon-manager commands", () => {
beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
mocks.runCliJsonCommand.mockReset();
mocks.runCliTextCommand.mockReset();
mocks.runExternalCliJsonCommand.mockReset();
mocks.runExternalCliTextCommand.mockReset();
mocks.spawnProcess.mockReset();
});
@@ -86,13 +89,13 @@ describe("daemon-manager commands", () => {
"Built-in daemon management is disabled.",
);
expect(mocks.runCliJsonCommand).not.toHaveBeenCalled();
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("keeps stop callable while built-in daemon management is disabled", async () => {
mocks.settings = desktopSettingsWithManagement(false);
mocks.runCliJsonCommand.mockResolvedValue({
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
serverId: "",
});
@@ -110,11 +113,11 @@ describe("daemon-manager commands", () => {
error: null,
});
expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
});
it("routes running desktop daemon stops through CLI daemon stop", async () => {
mocks.runCliJsonCommand
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
serverId: "server-1",
@@ -141,8 +144,12 @@ describe("daemon-manager commands", () => {
error: null,
});
expect(mocks.runCliJsonCommand).toHaveBeenNthCalledWith(1, ["daemon", "status", "--json"]);
expect(mocks.runCliJsonCommand).toHaveBeenNthCalledWith(2, [
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(1, [
"daemon",
"status",
"--json",
]);
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [
"daemon",
"stop",
"--json",
@@ -152,6 +159,10 @@ describe("daemon-manager commands", () => {
"--kill-timeout",
"5",
]);
expect(mocks.runCliJsonCommand).toHaveBeenNthCalledWith(3, ["daemon", "status", "--json"]);
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [
"daemon",
"status",
"--json",
]);
});
});
+6 -10
View File
@@ -28,12 +28,8 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import {
createNodeEntrypointInvocation,
resolveDaemonRunnerEntrypoint,
runCliJsonCommand,
runCliTextCommand,
} from "./runtime-paths.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js";
import {
createDesktopSettingsCommandHandlers,
type DesktopCommandHandler,
@@ -109,7 +105,7 @@ export function isDesktopManagedDaemonRunningSync(): boolean {
}
export async function stopDesktopDaemonViaCli(): Promise<void> {
await runCliJsonCommand([
await runExternalCliJsonCommand([
"daemon",
"stop",
"--json",
@@ -195,7 +191,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
const home = getPaseoHome();
try {
const payload = (await runCliJsonCommand(["daemon", "status", "--json"])) as Record<
const payload = (await runExternalCliJsonCommand(["daemon", "status", "--json"])) as Record<
string,
unknown
>;
@@ -425,7 +421,7 @@ function getDaemonLogs(): DesktopDaemonLogs {
}
async function getCliDaemonStatus(): Promise<string> {
return await runCliTextCommand(["daemon", "status"]);
return await runExternalCliTextCommand(["daemon", "status"]);
}
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
@@ -439,7 +435,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
try {
const payload = await runCliJsonCommand(["daemon", "pair", "--json"]);
const payload = await runExternalCliJsonCommand(["daemon", "pair", "--json"]);
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createNodeEntrypointInvocation,
parseCliPassthroughArgsFromArgv,
type NodeEntrypointSpec,
} from "./node-entrypoint-launcher";
@@ -11,78 +10,6 @@ const CLI_ENTRYPOINT: NodeEntrypointSpec = {
};
describe("node-entrypoint-launcher", () => {
describe("parseCliPassthroughArgsFromArgv", () => {
it("returns null when no CLI args are provided", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores macOS GUI launch arguments", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "-psn_0_12345"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores --no-sandbox injected by Linux wrapper", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/usr/bin/Paseo", "--no-sandbox", "status"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["status"]);
});
it("returns null when only --no-sandbox is present", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/usr/bin/Paseo", "--no-sandbox"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("preserves CLI flags for direct app invocations", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--version"]);
});
it("passes --open-project through as a normal CLI arg", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--open-project", "/tmp/project"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--open-project", "/tmp/project"]);
});
it("forces CLI mode for shim launches even without args", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: true,
}),
).toEqual([]);
});
});
describe("createNodeEntrypointInvocation", () => {
it("uses the packaged runner when the desktop app is packaged", () => {
expect(
@@ -1,6 +1,3 @@
const IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
export const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI";
const PASEO_NODE_ENV = "PASEO_NODE_ENV";
export interface NodeEntrypointSpec {
@@ -26,12 +23,6 @@ interface CreateNodeEntrypointInvocationInput {
baseEnv: NodeJS.ProcessEnv;
}
interface ParseCliPassthroughArgsFromArgvInput {
argv: string[];
isDefaultApp: boolean;
forceCli: boolean;
}
export function createElectronNodeEnv(
baseEnv: NodeJS.ProcessEnv,
options?: { isPackaged?: boolean },
@@ -43,26 +34,6 @@ export function createElectronNodeEnv(
};
}
export function parseCliPassthroughArgsFromArgv(
input: ParseCliPassthroughArgsFromArgvInput,
): string[] | null {
const startIndex = input.isDefaultApp ? 2 : 1;
const effective: string[] = [];
for (const arg of input.argv.slice(startIndex)) {
if (IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
continue;
}
effective.push(arg);
}
if (input.forceCli) {
return effective;
}
return effective.length > 0 ? effective : null;
}
export function createNodeEntrypointInvocation(
input: CreateNodeEntrypointInvocationInput,
): NodeEntrypointInvocation {
@@ -1,9 +1,5 @@
import { pathToFileURL } from "node:url";
process.stderr.write(
`[paseo-runner] argv=${JSON.stringify(process.argv)} run-as-node=${process.env.ELECTRON_RUN_AS_NODE ?? "<unset>"} execPath=${process.execPath}\n`,
);
export async function main(): Promise<void> {
const [argvMode, entryPath, ...args] = process.argv.slice(2);
if (argvMode !== "bare" && argvMode !== "node-script") {
@@ -0,0 +1,51 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
export interface PackageInfo {
root: string;
}
export function assertPathExists(input: { label: string; filePath: string }): string {
if (!existsSync(input.filePath)) {
throw new Error(`${input.label} is missing at ${input.filePath}`);
}
return input.filePath;
}
export function findPackageRootFromResolvedPath(input: {
resolvedPath: string;
packageName: string;
}): PackageInfo {
let currentDir = path.dirname(input.resolvedPath);
while (true) {
const packageJsonPath = path.join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
name?: string;
};
if (pkg.name === input.packageName) {
return {
root: currentDir,
};
}
} catch {
// Ignore malformed package metadata while walking up.
}
}
const parent = path.dirname(currentDir);
if (parent === currentDir) {
break;
}
currentDir = parent;
}
throw new Error(`Unable to resolve ${input.packageName} package root`);
}
export function resolvePackagedAsarPath(): string {
return path.join(process.resourcesPath, "app.asar");
}
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveNodeExecPath } from "./runtime-paths";
const mocks = vi.hoisted(() => ({
existsSync: vi.fn(),
app: {
isPackaged: true,
},
}));
vi.mock("node:fs", () => ({
existsSync: mocks.existsSync,
readFileSync: vi.fn(),
}));
vi.mock("electron", () => ({
app: mocks.app,
}));
vi.mock("electron-log/main", () => ({
default: { warn: vi.fn() },
}));
vi.mock("@getpaseo/server", () => ({
spawnProcess: vi.fn(),
}));
const originalPlatform = process.platform;
const originalExecPath = process.execPath;
const originalResourcesPath = process.resourcesPath;
function setProcessRuntime(input: {
platform: NodeJS.Platform;
execPath: string;
resourcesPath?: string;
}): void {
Object.defineProperty(process, "platform", {
configurable: true,
value: input.platform,
});
Object.defineProperty(process, "execPath", {
configurable: true,
value: input.execPath,
});
Object.defineProperty(process, "resourcesPath", {
configurable: true,
value: input.resourcesPath,
});
}
describe("runtime-paths", () => {
beforeEach(() => {
mocks.app.isPackaged = true;
mocks.existsSync.mockReturnValue(true);
setProcessRuntime({
platform: "darwin",
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
resourcesPath: "/Applications/Paseo.app/Contents/Resources",
});
});
afterEach(() => {
vi.restoreAllMocks();
setProcessRuntime({
platform: originalPlatform,
execPath: originalExecPath,
resourcesPath: originalResourcesPath,
});
});
it("uses the macOS Helper executable for packaged daemon node launches", () => {
expect(resolveNodeExecPath()).toBe(
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
);
});
});
+10 -245
View File
@@ -1,62 +1,24 @@
import { existsSync, readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { spawnProcess } from "@getpaseo/server";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
import log from "electron-log/main";
import {
DESKTOP_CLI_ENV,
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
parseCliPassthroughArgsFromArgv as parseCliPassthroughArgs,
type NodeEntrypointArgvMode,
type NodeEntrypointInvocation,
type NodeEntrypointSpec,
} from "./node-entrypoint-launcher.js";
import {
assertPathExists,
findPackageRootFromResolvedPath,
resolvePackagedAsarPath,
type PackageInfo,
} from "./package-paths.js";
const CLI_PACKAGE_NAME = "@getpaseo/cli";
const SERVER_PACKAGE_NAME = "@getpaseo/server";
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
interface PackageInfo {
root: string;
}
const esmRequire = createRequire(__filename);
function findPackageRootFromResolvedPath(input: {
resolvedPath: string;
packageName: string;
}): PackageInfo {
let currentDir = path.dirname(input.resolvedPath);
while (true) {
const packageJsonPath = path.join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
name?: string;
};
if (pkg.name === input.packageName) {
return {
root: currentDir,
};
}
} catch {
// Ignore malformed package metadata while walking up.
}
}
const parent = path.dirname(currentDir);
if (parent === currentDir) {
break;
}
currentDir = parent;
}
throw new Error(`Unable to resolve ${input.packageName} package root`);
}
function resolveServerPackageInfo(): PackageInfo {
const serverExportPath = esmRequire.resolve(SERVER_PACKAGE_NAME);
return findPackageRootFromResolvedPath({
@@ -65,19 +27,7 @@ function resolveServerPackageInfo(): PackageInfo {
});
}
function resolveCliPackageInfo(): PackageInfo {
const cliBinPath = esmRequire.resolve(CLI_BIN_ENTRY);
return findPackageRootFromResolvedPath({
resolvedPath: cliBinPath,
packageName: CLI_PACKAGE_NAME,
});
}
function resolvePackagedAsarPath(): string {
return path.join(process.resourcesPath, "app.asar");
}
function resolvePackagedNodeEntrypointRunnerPath(): string {
export function resolvePackagedNodeEntrypointRunnerPath(): string {
return path.join(
process.resourcesPath,
"app.asar.unpacked",
@@ -87,22 +37,6 @@ function resolvePackagedNodeEntrypointRunnerPath(): string {
);
}
function assertPathExists(input: { label: string; filePath: string }): string {
if (!existsSync(input.filePath)) {
throw new Error(`${input.label} is missing at ${input.filePath}`);
}
return input.filePath;
}
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
return parseCliPassthroughArgs({
argv,
isDefaultApp: process.defaultApp,
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
});
}
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
if (app.isPackaged) {
return {
@@ -140,50 +74,14 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
};
}
export function resolveCliEntrypoint(): NodeEntrypointSpec {
if (app.isPackaged) {
return {
entryPath: assertPathExists({
label: "Bundled CLI entrypoint",
filePath: path.join(
resolvePackagedAsarPath(),
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js",
),
}),
execArgv: [],
};
}
const cliPackage = resolveCliPackageInfo();
const distEntry = path.join(cliPackage.root, "dist", "index.js");
if (existsSync(distEntry)) {
return {
entryPath: distEntry,
execArgv: [],
};
}
return {
entryPath: assertPathExists({
label: "CLI source entrypoint",
filePath: path.join(cliPackage.root, "src", "index.ts"),
}),
execArgv: ["--import", "tsx"],
};
}
function resolveNodeExecPath(): string {
export function resolveNodeExecPath(): string {
if (app.isPackaged && process.platform === "darwin") {
const marker = ".app/Contents/MacOS/";
const markerIndex = process.execPath.indexOf(marker);
if (markerIndex !== -1) {
const bundleRoot = process.execPath.substring(0, markerIndex + ".app".length);
const name = path.basename(process.execPath);
const helperPath = path.join(
const helperPath = path.posix.join(
bundleRoot,
"Contents",
"Frameworks",
@@ -221,136 +119,3 @@ export function createNodeEntrypointInvocation(input: {
baseEnv: input.baseEnv,
});
}
function createCliInvocation(args: string[]): NodeEntrypointInvocation {
const cli = resolveCliEntrypoint();
return createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "node-script",
args,
baseEnv: process.env,
});
}
export function runCliPassthroughCommand(args: string[]): number {
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
windowsHide: true,
});
if (result.error) {
throw result.error;
}
if (typeof result.status === "number") {
return result.status;
}
return result.signal ? 1 : 0;
}
function spawnAsync(
command: string,
args: string[],
options: { env: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawnProcess(command, args, {
envMode: "internal",
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
});
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({ stdout, stderr, exitCode });
});
});
}
export async function runCliTextCommand(args: string[]): Promise<string> {
const invocation = createCliInvocation(args);
const result = await spawnAsync(invocation.command, invocation.args, {
env: invocation.env,
});
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
log.warn("[desktop cli]", `CLI text command failed`, {
args,
exitCode: result.exitCode,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
});
throw new Error(
stderr.length > 0
? stderr
: `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`,
);
}
return result.stdout.trimEnd();
}
export async function runCliJsonCommand(args: string[]): Promise<unknown> {
const invocation = createCliInvocation(args);
const result = await spawnAsync(invocation.command, invocation.args, {
env: invocation.env,
});
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
log.warn("[desktop cli]", `CLI command failed`, {
args,
exitCode: result.exitCode,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
command: invocation.command,
});
throw new Error(
stderr.length > 0
? stderr
: `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`,
);
}
const stdout = result.stdout.trim();
if (stdout.length === 0) {
log.warn("[desktop cli]", `CLI command produced no output`, { args });
throw new Error("CLI command did not produce JSON output.");
}
// The stdout may contain non-JSON preamble (e.g. Node deprecation warnings).
// Extract the first valid JSON object or array from the output.
const jsonStart = stdout.search(/[{[]/);
if (jsonStart < 0) {
log.warn("[desktop cli]", `CLI command output contained no JSON`, {
args,
stdout: stdout.slice(0, 500),
});
throw new Error(`CLI command output contained no JSON. Output: ${stdout.slice(0, 200)}`);
}
const jsonText = stdout.slice(jsonStart);
try {
return JSON.parse(jsonText) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import { runDesktopStartup } from "./desktop-startup";
describe("desktop startup", () => {
it("runs CLI passthrough before GUI login-shell env inheritance", async () => {
const calls: string[] = [];
await runDesktopStartup({
hasPendingOpenProjectPath: false,
runCliPassthroughIfRequested: vi.fn(async () => {
calls.push("cli");
return true;
}),
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
bootstrapGui: vi.fn(async () => {
calls.push("gui");
}),
});
expect(calls).toEqual(["cli"]);
});
it("keeps login-shell env inheritance on normal GUI startup", async () => {
const calls: string[] = [];
await runDesktopStartup({
hasPendingOpenProjectPath: false,
runCliPassthroughIfRequested: vi.fn(async () => {
calls.push("cli");
return false;
}),
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
bootstrapGui: vi.fn(async () => {
calls.push("gui");
}),
});
expect(calls).toEqual(["cli", "env", "gui"]);
});
it("does not route open-project launches through CLI passthrough", async () => {
const runCliPassthroughIfRequested = vi.fn(async () => true);
const calls: string[] = [];
await runDesktopStartup({
hasPendingOpenProjectPath: true,
runCliPassthroughIfRequested,
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
bootstrapGui: vi.fn(async () => {
calls.push("gui");
}),
});
expect(runCliPassthroughIfRequested).not.toHaveBeenCalled();
expect(calls).toEqual(["env", "gui"]);
});
});
+15
View File
@@ -0,0 +1,15 @@
export interface DesktopStartupDependencies {
hasPendingOpenProjectPath: boolean;
runCliPassthroughIfRequested: () => Promise<boolean>;
inheritLoginShellEnv: () => void;
bootstrapGui: () => Promise<void>;
}
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
if (!deps.hasPendingOpenProjectPath && (await deps.runCliPassthroughIfRequested())) {
return;
}
deps.inheritLoginShellEnv();
await deps.bootstrapGui();
}
+20 -17
View File
@@ -1,9 +1,10 @@
process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";
log.transports.console.level = "info";
log.initialize({ spyRendererConsole: true });
import { inheritLoginShellEnv } from "./login-shell-env.js";
inheritLoginShellEnv();
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -11,10 +12,7 @@ import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { app, BrowserWindow, Menu, ipcMain, nativeImage, net, protocol, session } from "electron";
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import {
parseCliPassthroughArgsFromArgv,
runCliPassthroughCommand,
} from "./daemon/runtime-paths.js";
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import {
registerWindowManager,
@@ -51,9 +49,11 @@ import {
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
import { runDesktopStartup } from "./desktop-startup.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
if (!value) {
@@ -241,9 +241,11 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
isDefaultApp: process.defaultApp,
});
log.info("[open-project] argv:", process.argv);
log.info("[open-project] isDefaultApp:", process.defaultApp);
log.info("[open-project] pendingOpenProjectPath:", pendingOpenProjectPath);
if (PASEO_DEBUG) {
log.info("[open-project] argv:", process.argv);
log.info("[open-project] isDefaultApp:", process.defaultApp);
log.info("[open-project] pendingOpenProjectPath:", pendingOpenProjectPath);
}
// The renderer pulls the pending path on mount via IPC — this avoids
// a race where the push event arrives before React registers its listener.
@@ -549,18 +551,18 @@ function setupSingleInstanceLock(): boolean {
}
async function runCliPassthroughIfRequested(): Promise<boolean> {
const cliArgs = parseCliPassthroughArgsFromArgv(process.argv);
const cliArgs = parsePassthroughCliArgsFromArgv(process.argv);
if (!cliArgs) {
return false;
}
try {
const exitCode = runCliPassthroughCommand(cliArgs);
process.exit(exitCode);
const exitCode = await runPassthroughCli(cliArgs);
app.exit(exitCode);
} catch (error) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
app.exit(1);
}
return true;
@@ -614,10 +616,6 @@ function waitForDesktopSmokeStopRequest(): Promise<void> {
}
async function bootstrap(): Promise<void> {
if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) {
return;
}
if (!setupSingleInstanceLock()) {
return;
}
@@ -676,7 +674,12 @@ async function bootstrap(): Promise<void> {
});
}
void bootstrap().catch((error) => {
void runDesktopStartup({
hasPendingOpenProjectPath: Boolean(pendingOpenProjectPath),
runCliPassthroughIfRequested,
inheritLoginShellEnv,
bootstrapGui: bootstrap,
}).catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);