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
@@ -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);
}