From d69fcb861da7dbd6e9ede4f518c76e3d7884542e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 15:37:41 +0700 Subject: [PATCH] Fix CLI arg routing and desktop install links --- packages/cli/src/commands/open.ts | 25 ++++ packages/cli/src/index.ts | 5 +- packages/cli/tests/32-open-project.test.ts | 27 +++- .../src/integrations/cli-install-path.test.ts | 47 +++++++ .../src/integrations/cli-install-path.ts | 24 ++++ .../src/integrations/integrations-manager.ts | 12 +- packages/desktop/src/main.ts | 37 ++--- .../desktop/src/open-project-routing.test.ts | 44 ++++++ packages/desktop/src/open-project-routing.ts | 126 ++++++++++++++++++ 9 files changed, 317 insertions(+), 30 deletions(-) create mode 100644 packages/desktop/src/integrations/cli-install-path.test.ts create mode 100644 packages/desktop/src/integrations/cli-install-path.ts create mode 100644 packages/desktop/src/open-project-routing.test.ts create mode 100644 packages/desktop/src/open-project-routing.ts diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index a023072bd..29fa7f616 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -18,6 +18,31 @@ export function isPathLikeArg(arg: string): boolean { ); } +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(); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1d848284f..bf5a3e63a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,11 @@ import { createCli } from "./cli.js"; -import { isPathLikeArg, openDesktopWithProject } from "./commands/open.js"; +import { openDesktopWithProject, shouldOpenProjectArg } from "./commands/open.js"; const program = createCli(); +const knownCommands = new Set(program.commands.map((command) => command.name())); const firstArg = process.argv[2]; -if (firstArg && isPathLikeArg(firstArg)) { +if (firstArg && shouldOpenProjectArg({ arg: firstArg, knownCommands })) { await openDesktopWithProject(firstArg); } else { if (process.argv.length <= 2) { diff --git a/packages/cli/tests/32-open-project.test.ts b/packages/cli/tests/32-open-project.test.ts index fa89dfd6f..14d436d01 100644 --- a/packages/cli/tests/32-open-project.test.ts +++ b/packages/cli/tests/32-open-project.test.ts @@ -1,9 +1,15 @@ #!/usr/bin/env npx zx import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { isPathLikeArg, openDesktopWithProject } from "../src/commands/open.ts"; +import { + isExistingDirectoryArg, + isPathLikeArg, + openDesktopWithProject, + shouldOpenProjectArg, +} from "../src/commands/open.ts"; console.log("📋 Phase 32: Open Project CLI Tests\n"); @@ -16,6 +22,25 @@ assert.equal(isPathLikeArg("run"), false); assert.equal(isPathLikeArg("foo"), false); console.log(" ✅ path-like detection matches the expected prefixes"); +console.log(" Testing existing directory detection and command precedence..."); +const existingProject = join(await mkdtemp(join(tmpdir(), "paseo-open-project-")), "project"); +await mkdir(existingProject); +const originalCwd = process.cwd(); +process.chdir(join(existingProject, "..")); + +assert.equal(isExistingDirectoryArg("project"), true); +assert.equal( + shouldOpenProjectArg({ arg: "project", knownCommands: new Set(["run", "status"]) }), + true, +); +assert.equal( + shouldOpenProjectArg({ arg: "run", knownCommands: new Set(["run", "status"]) }), + false, +); + +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"); const originalWrite = process.stderr.write.bind(process.stderr); diff --git a/packages/desktop/src/integrations/cli-install-path.test.ts b/packages/desktop/src/integrations/cli-install-path.test.ts new file mode 100644 index 000000000..f07598b25 --- /dev/null +++ b/packages/desktop/src/integrations/cli-install-path.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { resolveCliInstallSourcePath } from "./cli-install-path"; + +describe("cli-install-path", () => { + it("uses the packaged executable on supported unix platforms", () => { + expect( + resolveCliInstallSourcePath({ + platform: "darwin", + isPackaged: true, + executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo", + shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo", + }), + ).toBe("/Applications/Paseo.app/Contents/MacOS/Paseo"); + }); + + it("prefers the original AppImage path on linux", () => { + expect( + resolveCliInstallSourcePath({ + platform: "linux", + isPackaged: true, + executablePath: "/tmp/.mount_paseo123/paseo", + shimPath: "/tmp/.mount_paseo123/resources/bin/paseo", + appImagePath: "/home/user/Applications/Paseo.AppImage", + }), + ).toBe("/home/user/Applications/Paseo.AppImage"); + }); + + it("falls back to the shim on windows and in development", () => { + expect( + resolveCliInstallSourcePath({ + platform: "win32", + isPackaged: true, + executablePath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\Paseo.exe", + shimPath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd", + }), + ).toBe("C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd"); + + expect( + resolveCliInstallSourcePath({ + platform: "linux", + isPackaged: false, + executablePath: "/opt/Paseo/paseo", + shimPath: "/opt/Paseo/resources/bin/paseo", + }), + ).toBe("/opt/Paseo/resources/bin/paseo"); + }); +}); diff --git a/packages/desktop/src/integrations/cli-install-path.ts b/packages/desktop/src/integrations/cli-install-path.ts new file mode 100644 index 000000000..245e5d17f --- /dev/null +++ b/packages/desktop/src/integrations/cli-install-path.ts @@ -0,0 +1,24 @@ +export function resolveCliInstallSourcePath(input: { + platform: NodeJS.Platform; + isPackaged: boolean; + executablePath: string; + shimPath: string; + appImagePath?: string | null; +}): string { + if (input.platform === "win32") { + return input.shimPath; + } + + if (!input.isPackaged) { + return input.shimPath; + } + + if (input.platform === "linux") { + const appImagePath = input.appImagePath?.trim(); + if (appImagePath) { + return appImagePath; + } + } + + return input.executablePath; +} diff --git a/packages/desktop/src/integrations/integrations-manager.ts b/packages/desktop/src/integrations/integrations-manager.ts index 4bca22ad2..b70894b71 100644 --- a/packages/desktop/src/integrations/integrations-manager.ts +++ b/packages/desktop/src/integrations/integrations-manager.ts @@ -3,6 +3,7 @@ import path from "node:path"; import os from "node:os"; import { app } from "electron"; import log from "electron-log/main"; +import { resolveCliInstallSourcePath } from "./cli-install-path.js"; // --------------------------------------------------------------------------- // Types @@ -184,6 +185,13 @@ async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> { export async function installCli(): Promise { const targetPath = getCliTargetPath(); const shimPath = getBundledCliShimPath(); + const installSourcePath = resolveCliInstallSourcePath({ + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: app.getPath("exe"), + shimPath, + appImagePath: process.env.APPIMAGE, + }); const binDir = getLocalBinDir(); await fs.mkdir(binDir, { recursive: true }); @@ -192,12 +200,12 @@ export async function installCli(): Promise { if (await pathOrSymlinkExists(targetPath)) { await fs.unlink(targetPath); } - await fs.copyFile(shimPath, targetPath); + await fs.copyFile(installSourcePath, targetPath); } else { if (await pathOrSymlinkExists(targetPath)) { await fs.unlink(targetPath); } - await fs.symlink(shimPath, targetPath); + await fs.symlink(installSourcePath, targetPath); } const { shellUpdated } = await ensurePathInShellRc(); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index c466c4581..c1417c7de 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -27,35 +27,18 @@ import { } from "./features/notifications.js"; import { registerOpenerHandlers } from "./features/opener.js"; import { setupApplicationMenu } from "./features/menu.js"; +import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; const OPEN_PROJECT_EVENT = "paseo:event:open-project"; -const OPEN_PROJECT_FLAG = "--open-project"; -const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"]; app.setName("Paseo"); -function parseOpenProjectPath(argv: string[]): string | null { - const startIndex = process.defaultApp ? 2 : 1; - - for (let index = startIndex; index < argv.length; index += 1) { - const arg = argv[index]; - if (OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) { - continue; - } - - if (arg !== OPEN_PROJECT_FLAG) { - return null; - } - - const pathArg = argv[index + 1]; - return pathArg ? pathArg : null; - } - - return null; -} - -const pendingOpenProjectPath = parseOpenProjectPath(process.argv); +const pendingOpenProjectPath = parseOpenProjectPathFromArgv({ + argv: process.argv, + isDefaultApp: process.defaultApp, + cwd: process.cwd(), +}); protocol.registerSchemesAsPrivileged([ { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, @@ -175,8 +158,12 @@ function setupSingleInstanceLock(): boolean { return false; } - app.on("second-instance", (_event, commandLine) => { - const openProjectPath = parseOpenProjectPath(commandLine); + app.on("second-instance", (_event, commandLine, workingDirectory) => { + const openProjectPath = parseOpenProjectPathFromArgv({ + argv: commandLine, + isDefaultApp: false, + cwd: workingDirectory || process.cwd(), + }); const win = BrowserWindow.getAllWindows()[0]; if (win) { win.show(); diff --git a/packages/desktop/src/open-project-routing.test.ts b/packages/desktop/src/open-project-routing.test.ts new file mode 100644 index 000000000..1a5390f41 --- /dev/null +++ b/packages/desktop/src/open-project-routing.test.ts @@ -0,0 +1,44 @@ +import os 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", () => { + 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); + + expect( + parseOpenProjectPathFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "project"], + 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(); + }); +}); diff --git a/packages/desktop/src/open-project-routing.ts b/packages/desktop/src/open-project-routing.ts new file mode 100644 index 000000000..51c595f52 --- /dev/null +++ b/packages/desktop/src/open-project-routing.ts @@ -0,0 +1,126 @@ +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)) { + 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); + } + + return effective; +} + +export function parseOpenProjectPathFromArgv(input: { + argv: string[]; + isDefaultApp: boolean; + cwd: string; +}): string | null { + const effectiveArgs = getEffectiveArgs({ + argv: input.argv, + isDefaultApp: input.isDefaultApp, + }); + + 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; + } + + if (effectiveArgs.length !== 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 }); +}