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