diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8288c5e4c..12b604a44 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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; diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts new file mode 100644 index 000000000..cb92d1c43 --- /dev/null +++ b/packages/cli/src/run.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts new file mode 100644 index 000000000..a6a85910d --- /dev/null +++ b/packages/cli/src/run.ts @@ -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 { + 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; +} diff --git a/packages/cli/tests/run-all.ts b/packages/cli/tests/run-all.ts index 2617c4ba7..b2b09b68d 100644 --- a/packages/cli/tests/run-all.ts +++ b/packages/cli/tests/run-all.ts @@ -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 { 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((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((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( diff --git a/packages/desktop/src/daemon/cli/entrypoints.ts b/packages/desktop/src/daemon/cli/entrypoints.ts new file mode 100644 index 000000000..9b983c4df --- /dev/null +++ b/packages/desktop/src/daemon/cli/entrypoints.ts @@ -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), + }); +} diff --git a/packages/desktop/src/daemon/cli/external.test.ts b/packages/desktop/src/daemon/cli/external.test.ts new file mode 100644 index 000000000..3a17c6291 --- /dev/null +++ b/packages/desktop/src/daemon/cli/external.test.ts @@ -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", + }); + }); +}); diff --git a/packages/desktop/src/daemon/cli/external.ts b/packages/desktop/src/daemon/cli/external.ts new file mode 100644 index 000000000..424ef1d16 --- /dev/null +++ b/packages/desktop/src/daemon/cli/external.ts @@ -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 { + 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 { + 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 }, + ); + } +} diff --git a/packages/desktop/src/daemon/cli/passthrough.test.ts b/packages/desktop/src/daemon/cli/passthrough.test.ts new file mode 100644 index 000000000..da25d0c53 --- /dev/null +++ b/packages/desktop/src/daemon/cli/passthrough.test.ts @@ -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"]); + }); +}); diff --git a/packages/desktop/src/daemon/cli/passthrough.ts b/packages/desktop/src/daemon/cli/passthrough.ts new file mode 100644 index 000000000..4244263c9 --- /dev/null +++ b/packages/desktop/src/daemon/cli/passthrough.ts @@ -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; + +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 { + 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 { + const runCli = options.runCli ?? (await importPassthroughCliRunner()); + return await runCli(args); +} diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 88d4348b0..7de15c449 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -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", + ]); }); }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index ee974b62b..27df78d2d 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -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 { - await runCliJsonCommand([ + await runExternalCliJsonCommand([ "daemon", "stop", "--json", @@ -195,7 +191,7 @@ export async function resolveDesktopDaemonStatus(): Promise 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 { - return await runCliTextCommand(["daemon", "status"]); + return await runExternalCliTextCommand(["daemon", "status"]); } async function getDaemonPairing(): Promise { @@ -439,7 +435,7 @@ async function getDaemonPairing(): Promise { } 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."); } diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts index 30b58b924..f56086d71 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts @@ -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( diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.ts index f15947f46..302a87ddc 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.ts @@ -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 { diff --git a/packages/desktop/src/daemon/node-entrypoint-runner.ts b/packages/desktop/src/daemon/node-entrypoint-runner.ts index 21771ba83..dae45077d 100644 --- a/packages/desktop/src/daemon/node-entrypoint-runner.ts +++ b/packages/desktop/src/daemon/node-entrypoint-runner.ts @@ -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 ?? ""} execPath=${process.execPath}\n`, -); - export async function main(): Promise { const [argvMode, entryPath, ...args] = process.argv.slice(2); if (argvMode !== "bare" && argvMode !== "node-script") { diff --git a/packages/desktop/src/daemon/package-paths.ts b/packages/desktop/src/daemon/package-paths.ts new file mode 100644 index 000000000..36529627a --- /dev/null +++ b/packages/desktop/src/daemon/package-paths.ts @@ -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"); +} diff --git a/packages/desktop/src/daemon/runtime-paths.test.ts b/packages/desktop/src/daemon/runtime-paths.test.ts new file mode 100644 index 000000000..dbb456df8 --- /dev/null +++ b/packages/desktop/src/daemon/runtime-paths.test.ts @@ -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", + ); + }); +}); diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 3d9cdb8ec..70c98404f 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -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 { - 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 { - 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 }, - ); - } -} diff --git a/packages/desktop/src/desktop-startup.test.ts b/packages/desktop/src/desktop-startup.test.ts new file mode 100644 index 000000000..c766a22a7 --- /dev/null +++ b/packages/desktop/src/desktop-startup.test.ts @@ -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"]); + }); +}); diff --git a/packages/desktop/src/desktop-startup.ts b/packages/desktop/src/desktop-startup.ts new file mode 100644 index 000000000..6ee8a0f0f --- /dev/null +++ b/packages/desktop/src/desktop-startup.ts @@ -0,0 +1,15 @@ +export interface DesktopStartupDependencies { + hasPendingOpenProjectPath: boolean; + runCliPassthroughIfRequested: () => Promise; + inheritLoginShellEnv: () => void; + bootstrapGui: () => Promise; +} + +export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise { + if (!deps.hasPendingOpenProjectPath && (await deps.runCliPassthroughIfRequested())) { + return; + } + + deps.inheritLoginShellEnv(); + await deps.bootstrapGui(); +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 670403c64..7ac3ce88b 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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 { - 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 { } async function bootstrap(): Promise { - if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) { - return; - } - if (!setupSingleInstanceLock()) { return; } @@ -676,7 +674,12 @@ async function bootstrap(): Promise { }); } -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);