diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2edc0eed7..54e948f41 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -634,6 +634,22 @@ function OpenProjectListener() { void openProject(pathToOpen).catch(() => undefined); }; + // Pull any path that was passed on cold start (before the listener existed). + // Store in the ref even if this effect instance is disposed — the next + // effect run picks it up via maybeOpenProject(pendingPathRef.current). + void getDesktopHost() + ?.getPendingOpenProject?.() + ?.then((pending) => { + if (pending) { + pendingPathRef.current = pending; + } + if (!disposed && pending) { + maybeOpenProject(pending); + } + }) + .catch(() => undefined); + + // Listen for hot-start paths relayed via the second-instance event. void listenToDesktopEvent("open-project", (payload) => { if (disposed) { return; diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 57bd496f6..5c16bfcdd 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -79,6 +79,7 @@ export interface DesktopInvokeBridge { export interface DesktopHostBridge { platform?: string; invoke?: DesktopInvokeBridge["invoke"]; + getPendingOpenProject?: () => Promise; events?: DesktopEventsBridge; window?: DesktopWindowModuleBridge; dialog?: DesktopDialogBridge; diff --git a/packages/cli/src/classify.test.ts b/packages/cli/src/classify.test.ts new file mode 100644 index 000000000..996adbc86 --- /dev/null +++ b/packages/cli/src/classify.test.ts @@ -0,0 +1,188 @@ +import { mkdirSync, mkdtempSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "./classify.js"; + +const knownCommands = new Set(["ls", "run", "status"]); + +describe("classifyInvocation", () => { + it("classifies no args as CLI mode", () => { + expect( + classifyInvocation({ + argv: [], + knownCommands, + cwd: process.cwd(), + }), + ).toEqual({ kind: "cli", argv: [] }); + }); + + it("classifies flags as CLI mode", () => { + expect( + classifyInvocation({ + argv: ["--version"], + knownCommands, + cwd: process.cwd(), + }), + ).toEqual({ kind: "cli", argv: ["--version"] }); + }); + + it("classifies known commands as CLI mode", () => { + expect( + classifyInvocation({ + argv: ["ls", "--json"], + knownCommands, + cwd: process.cwd(), + }), + ).toEqual({ kind: "cli", argv: ["ls", "--json"] }); + }); + + it("classifies '.' as an open-project invocation", () => { + const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-dot-")); + + expect( + classifyInvocation({ + argv: ["."], + knownCommands, + cwd: projectDir, + }), + ).toEqual({ + kind: "open-project", + resolvedPath: projectDir, + }); + }); + + it("classifies '..' as an open-project invocation", () => { + const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-parent-")); + const childDir = path.join(parentDir, "child"); + mkdirSync(childDir); + + expect( + classifyInvocation({ + argv: [".."], + knownCommands, + cwd: childDir, + }), + ).toEqual({ + kind: "open-project", + resolvedPath: parentDir, + }); + }); + + it("classifies './myproject' as an open-project invocation", () => { + const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-relative-")); + const projectDir = path.join(parentDir, "myproject"); + mkdirSync(projectDir); + + expect( + classifyInvocation({ + argv: ["./myproject"], + knownCommands, + cwd: parentDir, + }), + ).toEqual({ + kind: "open-project", + resolvedPath: projectDir, + }); + }); + + it("classifies an absolute path as an open-project invocation", () => { + const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-absolute-")); + + expect( + classifyInvocation({ + argv: [projectDir], + knownCommands, + cwd: process.cwd(), + }), + ).toEqual({ + kind: "open-project", + resolvedPath: projectDir, + }); + }); + + it("classifies a home-relative path as an open-project invocation", () => { + const projectDir = mkdtempSync(path.join(homedir(), "paseo-classify-home-")); + const relativeToHome = `~/${path.basename(projectDir)}`; + + expect( + classifyInvocation({ + argv: [relativeToHome], + knownCommands, + cwd: process.cwd(), + }), + ).toEqual({ + kind: "open-project", + resolvedPath: projectDir, + }); + }); + + it("classifies an existing directory name as an open-project invocation", () => { + const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-existing-")); + const projectDir = path.join(parentDir, "myproject"); + mkdirSync(projectDir); + + expect( + classifyInvocation({ + argv: ["myproject"], + knownCommands, + cwd: parentDir, + }), + ).toEqual({ + kind: "open-project", + resolvedPath: projectDir, + }); + }); + + it("keeps known commands in CLI mode even when a matching directory exists", () => { + const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-command-")); + mkdirSync(path.join(parentDir, "status")); + + expect( + classifyInvocation({ + argv: ["status"], + knownCommands, + cwd: parentDir, + }), + ).toEqual({ + kind: "cli", + argv: ["status"], + }); + }); + + it("classifies nonexistent args as CLI mode", () => { + expect( + classifyInvocation({ + argv: ["nonexistent"], + knownCommands, + cwd: process.cwd(), + }), + ).toEqual({ + kind: "cli", + argv: ["nonexistent"], + }); + }); +}); + +describe("path helpers", () => { + it("detects path-like prefixes", () => { + expect(isPathLikeArg(".")).toBe(true); + expect(isPathLikeArg("..")).toBe(true); + expect(isPathLikeArg("./project")).toBe(true); + expect(isPathLikeArg("../project")).toBe(true); + expect(isPathLikeArg("/tmp/project")).toBe(true); + expect(isPathLikeArg("~")).toBe(true); + expect(isPathLikeArg("~/project")).toBe(true); + expect(isPathLikeArg("C:\\project")).toBe(true); + expect(isPathLikeArg("status")).toBe(false); + }); + + it("detects existing directories relative to cwd", () => { + const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-helper-")); + const projectDir = path.join(parentDir, "project"); + mkdirSync(projectDir); + + expect(isExistingDirectory({ pathArg: "project", cwd: parentDir })).toBe(true); + expect(isExistingDirectory({ pathArg: "missing", cwd: parentDir })).toBe(false); + }); +}); diff --git a/packages/cli/src/classify.ts b/packages/cli/src/classify.ts new file mode 100644 index 000000000..b9337b9ad --- /dev/null +++ b/packages/cli/src/classify.ts @@ -0,0 +1,70 @@ +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +export type CliInvocation = + | { kind: "cli"; argv: string[] } + | { kind: "open-project"; resolvedPath: string }; + +export function isPathLikeArg(arg: string): boolean { + return ( + arg === "." || + arg === ".." || + arg.startsWith("./") || + arg.startsWith("../") || + arg.startsWith("/") || + arg === "~" || + arg.startsWith("~/") || + /^[A-Za-z]:[\\/]/.test(arg) + ); +} + +export function expandUserPath(inputPath: string): string { + if (inputPath === "~") { + return homedir(); + } + + if (inputPath.startsWith("~/")) { + return path.join(homedir(), inputPath.slice(2)); + } + + return inputPath; +} + +export function isExistingDirectory(input: { pathArg: string; cwd: string }): boolean { + const resolvedPath = path.resolve(input.cwd, expandUserPath(input.pathArg)); + + if (!existsSync(resolvedPath)) { + return false; + } + + return statSync(resolvedPath).isDirectory(); +} + +export function classifyInvocation(input: { + argv: string[]; + knownCommands: ReadonlySet; + cwd: string; +}): CliInvocation { + const [firstArg] = input.argv; + if (!firstArg) { + return { kind: "cli", argv: input.argv }; + } + + if (firstArg.startsWith("-")) { + return { kind: "cli", argv: input.argv }; + } + + if (input.knownCommands.has(firstArg)) { + return { kind: "cli", argv: input.argv }; + } + + if (isExistingDirectory({ pathArg: firstArg, cwd: input.cwd })) { + return { + kind: "open-project", + resolvedPath: path.resolve(input.cwd, expandUserPath(firstArg)), + }; + } + + return { kind: "cli", argv: input.argv }; +} diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 29fa7f616..ae5d64c6a 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -1,73 +1,8 @@ -import { existsSync, statSync } from "node:fs"; +import { existsSync } from "node:fs"; import { spawn } from "node:child_process"; import { homedir } from "node:os"; import path from "node:path"; -const DESKTOP_GUI_FLAG = "--open-project"; - -export function isPathLikeArg(arg: string): boolean { - return ( - arg === "." || - arg === ".." || - arg.startsWith("./") || - arg.startsWith("../") || - arg.startsWith("/") || - arg === "~" || - arg.startsWith("~/") || - /^[A-Za-z]:[\\/]/.test(arg) - ); -} - -export function isExistingDirectoryArg(arg: string): boolean { - const absolutePath = path.resolve(expandUserPath(arg)); - - if (!existsSync(absolutePath)) { - return false; - } - - return statSync(absolutePath).isDirectory(); -} - -export function shouldOpenProjectArg(input: { - arg: string; - knownCommands: ReadonlySet; -}): boolean { - if (input.arg.startsWith("-")) { - return false; - } - - if (input.knownCommands.has(input.arg)) { - return false; - } - - return isPathLikeArg(input.arg) || isExistingDirectoryArg(input.arg); -} - -function expandUserPath(inputPath: string): string { - if (inputPath === "~") { - return homedir(); - } - if (inputPath.startsWith("~/")) { - return path.join(homedir(), inputPath.slice(2)); - } - return inputPath; -} - -function resolveProjectDirectory(inputPath: string): string { - const absolutePath = path.resolve(expandUserPath(inputPath)); - - if (!existsSync(absolutePath)) { - throw new Error(`Path does not exist: ${absolutePath}`); - } - - const stat = statSync(absolutePath); - if (!stat.isDirectory()) { - throw new Error(`Not a directory: ${absolutePath}`); - } - - return absolutePath; -} - function findDesktopApp(): string | null { if (process.platform === "darwin") { const candidates = [ @@ -113,17 +48,25 @@ function findDesktopApp(): string | null { return null; } +function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv { + const env = { ...process.env }; + // The CLI runs via ELECTRON_RUN_AS_NODE=1. On Linux/Windows the spawned + // desktop process inherits the env directly, so we must strip it or the + // desktop app would start as a bare Node process instead of Electron. + delete env.ELECTRON_RUN_AS_NODE; + return env; +} + function spawnDetached(command: string, args: string[]): void { spawn(command, args, { detached: true, stdio: "ignore", + env: cleanEnvForDesktopLaunch(), }).unref(); } -export async function openDesktopWithProject(pathArg: string): Promise { +export async function openDesktopWithProject(projectPath: string): Promise { try { - const projectPath = resolveProjectDirectory(pathArg); - if (process.env.PASEO_DESKTOP_CLI === "1") { throw new Error( "Cannot open a desktop project while running in desktop CLI passthrough mode.", @@ -138,11 +81,16 @@ export async function openDesktopWithProject(pathArg: string): Promise { } if (process.platform === "darwin") { - spawnDetached("open", ["-a", desktopApp, "--args", DESKTOP_GUI_FLAG, projectPath]); + // -n forces a new instance even if the app is already running. + // The new instance hits requestSingleInstanceLock(), fails, and relays + // the argv to the first instance via the second-instance event. + // -g keeps the terminal in the foreground (better CLI UX). + // Without -n, macOS just activates the existing window and drops --args. + spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]); return; } - spawnDetached(desktopApp, [DESKTOP_GUI_FLAG, projectPath]); + spawnDetached(desktopApp, [projectPath]); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bf5a3e63a..8288c5e4c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,15 +1,26 @@ import { createCli } from "./cli.js"; -import { openDesktopWithProject, shouldOpenProjectArg } from "./commands/open.js"; +import { classifyInvocation } from "./classify.js"; +import { openDesktopWithProject } from "./commands/open.js"; const program = createCli(); const knownCommands = new Set(program.commands.map((command) => command.name())); -const firstArg = process.argv[2]; -if (firstArg && shouldOpenProjectArg({ arg: firstArg, knownCommands })) { - await openDesktopWithProject(firstArg); -} else { - if (process.argv.length <= 2) { - process.argv.push("onboard"); +const invocation = classifyInvocation({ + argv: process.argv.slice(2), + knownCommands, + cwd: process.cwd(), +}); + +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; } - program.parse(process.argv, { from: "node" }); + case "open-project": + await openDesktopWithProject(invocation.resolvedPath); + break; } diff --git a/packages/cli/tests/32-open-project.test.ts b/packages/cli/tests/32-open-project.test.ts index 14d436d01..b08d0f336 100644 --- a/packages/cli/tests/32-open-project.test.ts +++ b/packages/cli/tests/32-open-project.test.ts @@ -5,15 +5,15 @@ import { mkdir, mkdtemp } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { - isExistingDirectoryArg, + classifyInvocation, + isExistingDirectory, isPathLikeArg, - openDesktopWithProject, - shouldOpenProjectArg, -} from "../src/commands/open.ts"; +} from "../src/classify.ts"; +import { openDesktopWithProject } from "../src/commands/open.ts"; console.log("šŸ“‹ Phase 32: Open Project CLI Tests\n"); -console.log(" Testing path-like detection exports..."); + console.log(" Testing path-like detection exports..."); assert.equal(isPathLikeArg("."), true); assert.equal(isPathLikeArg("./app"), true); assert.equal(isPathLikeArg("/tmp/app"), true); @@ -28,21 +28,28 @@ await mkdir(existingProject); const originalCwd = process.cwd(); process.chdir(join(existingProject, "..")); -assert.equal(isExistingDirectoryArg("project"), true); +assert.equal(isExistingDirectory({ pathArg: "project", cwd: process.cwd() }), true); assert.equal( - shouldOpenProjectArg({ arg: "project", knownCommands: new Set(["run", "status"]) }), - true, + classifyInvocation({ + argv: ["project"], + knownCommands: new Set(["run", "status"]), + cwd: process.cwd(), + }).kind, + "open-project", ); assert.equal( - shouldOpenProjectArg({ arg: "run", knownCommands: new Set(["run", "status"]) }), - false, + classifyInvocation({ + argv: ["run"], + knownCommands: new Set(["run", "status"]), + cwd: process.cwd(), + }).kind, + "cli", ); process.chdir(originalCwd); console.log(" āœ… existing directories open as projects, but known commands still win"); -console.log(" Testing nonexistent project path errors..."); -const missingProject = join(tmpdir(), "paseo-open-project-missing"); +console.log(" Testing desktop CLI passthrough guard..."); const originalWrite = process.stderr.write.bind(process.stderr); const stderrChunks: string[] = []; process.stderr.write = ((chunk: string | Uint8Array) => { @@ -52,13 +59,16 @@ process.stderr.write = ((chunk: string | Uint8Array) => { const previousExitCode = process.exitCode; process.exitCode = undefined; +const previousDesktopCli = process.env.PASEO_DESKTOP_CLI; +process.env.PASEO_DESKTOP_CLI = "1"; -await openDesktopWithProject(missingProject); +await openDesktopWithProject(existingProject); process.stderr.write = originalWrite; assert.equal(process.exitCode, 1); -assert.match(stderrChunks.join(""), /Path does not exist:/); +assert.match(stderrChunks.join(""), /desktop CLI passthrough mode/); process.exitCode = previousExitCode; -console.log(" āœ… nonexistent paths fail with a helpful error"); +process.env.PASEO_DESKTOP_CLI = previousDesktopCli; +console.log(" āœ… desktop CLI passthrough mode is rejected"); console.log("\nāœ… Phase 32: Open Project CLI Tests PASSED"); diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index b83994b0f..040eb2d90 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -31,30 +31,7 @@ else exit 1 fi -case "${1:-}" in - ./*|../*|.|..|/*|~|~/*) - case "$1" in - ~) ARG_PATH="$HOME" ;; - ~/*) ARG_PATH="$HOME/${1#\~/}" ;; - *) ARG_PATH="$1" ;; - esac - if RESOLVED_PATH=$(CDPATH= cd -- "$ARG_PATH" 2>/dev/null && pwd); then - : - elif command -v realpath >/dev/null 2>&1; then - RESOLVED_PATH=$(realpath "$ARG_PATH" 2>/dev/null || printf "%s" "$ARG_PATH") - else - RESOLVED_PATH=$ARG_PATH - fi - if [ ! -e "${RESOLVED_PATH}" ]; then - echo "Path does not exist: ${RESOLVED_PATH}" >&2 - exit 1 - fi - if [ ! -d "${RESOLVED_PATH}" ]; then - echo "Not a directory: ${RESOLVED_PATH}" >&2 - exit 1 - fi - exec "${APP_EXECUTABLE}" --open-project "${RESOLVED_PATH}" - ;; -esac +RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js" +CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" -exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@" +exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" diff --git a/packages/desktop/bin/paseo.cmd b/packages/desktop/bin/paseo.cmd index 24984e38f..d2a1679c1 100644 --- a/packages/desktop/bin/paseo.cmd +++ b/packages/desktop/bin/paseo.cmd @@ -9,33 +9,6 @@ if not exist "%APP_EXECUTABLE%" ( exit /b 1 ) -set "FIRST_ARG=%~1" -if "%FIRST_ARG%"=="." goto :open_project -if "%FIRST_ARG%"==".." goto :open_project -if "%FIRST_ARG:~0,2%"==".\" goto :open_project -if "%FIRST_ARG:~0,2%"=="./" goto :open_project -if "%FIRST_ARG:~0,3%"=="..\" goto :open_project -if "%FIRST_ARG:~0,3%"=="../" goto :open_project -if "%FIRST_ARG:~0,1%"=="\" goto :open_project -if "%FIRST_ARG:~0,1%"=="/" goto :open_project -if "%FIRST_ARG:~1,2%"==":\" goto :open_project -if "%FIRST_ARG:~1,2%"==":/" goto :open_project -goto :cli_mode - -:open_project -for %%I in ("%~1") do set "RESOLVED_PATH=%%~fI" -if not exist "%RESOLVED_PATH%" ( - echo Path does not exist: %RESOLVED_PATH% 1>&2 - exit /b 1 -) -if not exist "%RESOLVED_PATH%\NUL" ( - echo Not a directory: %RESOLVED_PATH% 1>&2 - exit /b 1 -) -"%APP_EXECUTABLE%" --open-project "%RESOLVED_PATH%" -exit /b %errorlevel% - -:cli_mode set "ELECTRON_RUN_AS_NODE=1" -"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* +"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* exit /b %errorlevel% diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 342cfe9c3..b60fd1621 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -7,6 +7,8 @@ directories: output: release files: - dist/**/* +asarUnpack: + - dist/daemon/node-entrypoint-runner.js extraResources: - from: ../app/dist to: app-dist diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts index b71e76bec..9a6232870 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts @@ -95,7 +95,7 @@ describe("node-entrypoint-launcher", () => { isPackaged: true, packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js", entrypoint: CLI_ENTRYPOINT, - argvMode: "bare", + argvMode: "node-script", args: ["ls", "--json"], baseEnv: { PATH: "/usr/bin" }, }), @@ -103,7 +103,7 @@ describe("node-entrypoint-launcher", () => { command: "/Applications/Paseo.app/Contents/MacOS/Paseo", args: [ "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js", - "bare", + "node-script", "/tmp/paseo-cli.js", "ls", "--json", @@ -122,7 +122,7 @@ describe("node-entrypoint-launcher", () => { isPackaged: false, packagedRunnerPath: null, entrypoint: CLI_ENTRYPOINT, - argvMode: "bare", + argvMode: "node-script", args: ["ls"], baseEnv: { PATH: "/usr/bin" }, }), diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index d464ddc22..b20c84f81 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -76,7 +76,7 @@ function resolvePackagedAsarPath(): string { } function resolvePackagedNodeEntrypointRunnerPath(): string { - return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js"); + return path.join(process.resourcesPath, "app.asar.unpacked", "dist", "daemon", "node-entrypoint-runner.js"); } function assertPathExists(input: { label: string; filePath: string }): string { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index c1417c7de..70f3f5489 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true }); import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; -import { app, BrowserWindow, nativeImage, net, protocol } from "electron"; +import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron"; import { registerDaemonManager } from "./daemon/daemon-manager.js"; import { parseCliPassthroughArgsFromArgv, @@ -34,10 +34,22 @@ const APP_SCHEME = "paseo"; const OPEN_PROJECT_EVENT = "paseo:event:open-project"; app.setName("Paseo"); -const pendingOpenProjectPath = parseOpenProjectPathFromArgv({ +let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, - cwd: process.cwd(), +}); + +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. +ipcMain.handle("paseo:get-pending-open-project", () => { + log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath); + const result = pendingOpenProjectPath; + pendingOpenProjectPath = null; + return result; }); protocol.registerSchemesAsPrivileged([ @@ -136,10 +148,12 @@ async function createMainWindow(): Promise { function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void { const send = () => { + log.info("[open-project] sending event to renderer:", projectPath); win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath }); }; if (win.webContents.isLoadingMainFrame()) { + log.info("[open-project] waiting for did-finish-load before sending event"); win.webContents.once("did-finish-load", send); return; } @@ -158,12 +172,13 @@ function setupSingleInstanceLock(): boolean { return false; } - app.on("second-instance", (_event, commandLine, workingDirectory) => { + app.on("second-instance", (_event, commandLine) => { + log.info("[open-project] second-instance commandLine:", commandLine); const openProjectPath = parseOpenProjectPathFromArgv({ argv: commandLine, isDefaultApp: false, - cwd: workingDirectory || process.cwd(), }); + log.info("[open-project] second-instance openProjectPath:", openProjectPath); const win = BrowserWindow.getAllWindows()[0]; if (win) { win.show(); @@ -243,10 +258,6 @@ async function bootstrap(): Promise { registerNotificationHandlers(); registerOpenerHandlers(); await createMainWindow(); - const mainWindow = BrowserWindow.getAllWindows()[0]; - if (mainWindow && pendingOpenProjectPath) { - sendOpenProjectEvent(mainWindow, pendingOpenProjectPath); - } app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/packages/desktop/src/open-project-routing.test.ts b/packages/desktop/src/open-project-routing.test.ts index 1a5390f41..33d004844 100644 --- a/packages/desktop/src/open-project-routing.test.ts +++ b/packages/desktop/src/open-project-routing.test.ts @@ -1,44 +1,69 @@ -import os from "node:os"; +import { mkdtempSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { mkdirSync, mkdtempSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { parseOpenProjectPathFromArgv } from "./open-project-routing"; describe("open-project-routing", () => { - it("keeps CLI flags out of project-open mode", () => { + it("returns a bare absolute path argument", () => { + const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-")); + + expect( + parseOpenProjectPathFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", projectPath], + isDefaultApp: false, + }), + ).toBe(projectPath); + }); + + it("finds a bare absolute path even when Chromium noise args appear first", () => { + const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-")); + + expect( + parseOpenProjectPathFromArgv({ + argv: [ + "/Applications/Paseo.app/Contents/MacOS/Paseo", + "--allow-file-access-from-files", + "--no-sandbox", + projectPath, + ], + isDefaultApp: false, + }), + ).toBe(projectPath); + }); + + it("does not treat flags as project paths", () => { + const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-")); + const flagLikeDirectory = path.join(projectPath, "--version"); + mkdirSync(flagLikeDirectory); + + expect( + parseOpenProjectPathFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version", flagLikeDirectory], + isDefaultApp: false, + }), + ).toBe(flagLikeDirectory); + expect( parseOpenProjectPathFromArgv({ argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"], isDefaultApp: false, - cwd: process.cwd(), }), ).toBeNull(); }); - it("resolves a lone existing directory argument to an absolute project path", () => { - const parentDir = mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-open-")); - const projectDir = path.join(parentDir, "project"); - mkdirSync(projectDir); + it("returns the path from an explicit --open-project flag for backward compatibility", () => { + const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-")); expect( parseOpenProjectPathFromArgv({ - argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "project"], + argv: [ + "/Applications/Paseo.app/Contents/MacOS/Paseo", + "--open-project", + projectPath, + ], isDefaultApp: false, - cwd: parentDir, }), - ).toBe(projectDir); - }); - - it("keeps known CLI commands in passthrough mode even if a directory matches", () => { - const parentDir = mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-command-")); - mkdirSync(path.join(parentDir, "status")); - - expect( - parseOpenProjectPathFromArgv({ - argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "status"], - isDefaultApp: false, - cwd: parentDir, - }), - ).toBeNull(); + ).toBe(projectPath); }); }); diff --git a/packages/desktop/src/open-project-routing.ts b/packages/desktop/src/open-project-routing.ts index 51c595f52..12ab75f16 100644 --- a/packages/desktop/src/open-project-routing.ts +++ b/packages/desktop/src/open-project-routing.ts @@ -1,126 +1,45 @@ import { existsSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import path from "node:path"; const OPEN_PROJECT_FLAG = "--open-project"; const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"]; -const TOP_LEVEL_CLI_COMMANDS = new Set([ - "ls", - "run", - "attach", - "logs", - "stop", - "delete", - "send", - "inspect", - "wait", - "archive", - "onboard", - "start", - "status", - "restart", - "agent", - "daemon", - "chat", - "terminal", - "loop", - "schedule", - "permit", - "provider", - "speech", - "worktree", - "help", -]); -function expandUserPath(inputPath: string): string { - if (inputPath === "~") { - return homedir(); - } - - if (inputPath.startsWith("~/")) { - return path.join(homedir(), inputPath.slice(2)); - } - - return inputPath; -} - -function isPathLikeArg(arg: string): boolean { - return ( - arg === "." || - arg === ".." || - arg.startsWith("./") || - arg.startsWith("../") || - arg.startsWith("/") || - arg === "~" || - arg.startsWith("~/") || - /^[A-Za-z]:[\\/]/.test(arg) - ); -} - -function isExistingDirectoryArg(input: { arg: string; cwd: string }): boolean { - const absolutePath = path.resolve(input.cwd, expandUserPath(input.arg)); - - if (!existsSync(absolutePath)) { +function isExistingDirectoryAbsolutePath(candidate: string): boolean { + if (!path.isAbsolute(candidate) || !existsSync(candidate)) { return false; } - return statSync(absolutePath).isDirectory(); -} - -function resolveProjectPath(input: { arg: string; cwd: string }): string { - return path.resolve(input.cwd, expandUserPath(input.arg)); -} - -function getEffectiveArgs(input: { argv: string[]; isDefaultApp: boolean }): string[] { - const startIndex = input.isDefaultApp ? 2 : 1; - const effective: string[] = []; - - for (const arg of input.argv.slice(startIndex)) { - if (OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) { - continue; - } - effective.push(arg); + try { + return statSync(candidate).isDirectory(); + } catch { + return false; } - - return effective; } export function parseOpenProjectPathFromArgv(input: { argv: string[]; isDefaultApp: boolean; - cwd: string; }): string | null { - const effectiveArgs = getEffectiveArgs({ - argv: input.argv, - isDefaultApp: input.isDefaultApp, - }); + const effectiveArgs = input.argv + .slice(input.isDefaultApp ? 2 : 1) + .filter( + (arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)), + ); - for (let index = 0; index < effectiveArgs.length; index += 1) { - const arg = effectiveArgs[index]; - if (arg !== OPEN_PROJECT_FLAG) { - break; - } - - const pathArg = effectiveArgs[index + 1]; - return pathArg ?? null; + const positionalProjectPath = effectiveArgs.find( + (arg) => !arg.startsWith("-") && isExistingDirectoryAbsolutePath(arg), + ); + if (positionalProjectPath) { + return positionalProjectPath; } - if (effectiveArgs.length !== 1) { + const openProjectIndex = effectiveArgs.indexOf(OPEN_PROJECT_FLAG); + if (openProjectIndex === -1) { return null; } - const [arg] = effectiveArgs; - if (arg.startsWith("-") || TOP_LEVEL_CLI_COMMANDS.has(arg)) { - return null; - } - - if (!isPathLikeArg(arg) && !isExistingDirectoryArg({ arg, cwd: input.cwd })) { - return null; - } - - if (!isExistingDirectoryArg({ arg, cwd: input.cwd })) { - return null; - } - - return resolveProjectPath({ arg, cwd: input.cwd }); + const flaggedProjectPath = effectiveArgs[openProjectIndex + 1]; + return flaggedProjectPath && isExistingDirectoryAbsolutePath(flaggedProjectPath) + ? flaggedProjectPath + : null; } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 728b641b4..8b7b02b5f 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { platform: process.platform, invoke: (command: string, args?: Record) => ipcRenderer.invoke("paseo:invoke", command, args), + getPendingOpenProject: () => + ipcRenderer.invoke("paseo:get-pending-open-project") as Promise, events: { on: (event: string, handler: EventHandler): Promise<() => void> => { const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {