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