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:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user