From 3d3e327378059cd2e53f5668196dbf140252a2e1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 14:30:25 +0700 Subject: [PATCH] feat(cli): support `paseo .` to open desktop app with project (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): reorder settings sections for better grouping * feat(app): add setup hint and paseo.sh link on mobile welcome screen New app store users land on the welcome screen with no context. Show a brief explanation that the desktop app or server is needed, plus a link to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop. * docs(release): add pre-release sanity check and clarify changelog scope Add a Codex 5.4 review step before cutting releases to catch breaking changes and backward-compatibility issues (mobile apps lag behind desktop/daemon updates). Clarify that the changelog always covers the delta from the previous stable release, not from the last RC. * feat(cli): support `paseo .` to open desktop app with project directory Similar to VS Code's `code .`, users can now type `paseo .` or `paseo ` to open the Paseo desktop app with that directory as the active project. - Desktop shims detect path-like first args and launch Electron in GUI mode with --open-project instead of CLI passthrough mode - Electron main process parses --open-project, sends IPC event to renderer, and forwards via second-instance for the already-running case - Renderer OpenProjectListener reuses existing openProject() RPC flow - Standalone CLI discovers the desktop app per platform and spawns it --- packages/app/src/app/_layout.tsx | 65 +++++++++ packages/cli/src/commands/open.ts | 126 ++++++++++++++++++ packages/cli/src/index.ts | 13 +- packages/cli/tests/32-open-project.test.ts | 39 ++++++ packages/desktop/bin/paseo | 26 ++++ packages/desktop/bin/paseo.cmd | 27 ++++ .../daemon/node-entrypoint-launcher.test.ts | 14 ++ .../src/daemon/node-entrypoint-launcher.ts | 11 +- packages/desktop/src/main.ts | 50 ++++++- 9 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/open.ts create mode 100644 packages/cli/tests/32-open-project.test.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b517c1072..8cd7071a8 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -27,6 +27,7 @@ import { import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { loadSettingsFromStorage } from "@/hooks/use-settings"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useOpenProject } from "@/hooks/use-open-project"; import { SessionProvider } from "@/contexts/session-context"; import type { HostProfile } from "@/types/host-connection"; import { @@ -67,6 +68,7 @@ import { type WebNotificationClickDetail, ensureOsNotificationPermission, } from "@/utils/os-notifications"; +import { listenToDesktopEvent } from "@/desktop/electron/events"; import { getDesktopHost } from "@/desktop/host"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { buildNotificationRoute } from "@/utils/notification-routing"; @@ -548,6 +550,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return ( + {children} @@ -597,6 +600,68 @@ function OfferLinkListener({ return null; } +interface OpenProjectEventPayload { + path?: unknown; +} + +function OpenProjectListener() { + const hosts = useHosts(); + const serverId = hosts[0]?.serverId ?? null; + const client = useHostRuntimeClient(serverId ?? ""); + const openProject = useOpenProject(serverId); + const pendingPathRef = useRef(null); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | null = null; + const maybeOpenProject = (inputPath: string) => { + const nextPath = inputPath.trim(); + if (!nextPath) { + return; + } + + pendingPathRef.current = nextPath; + + if (!serverId || !client) { + return; + } + + const pathToOpen = pendingPathRef.current; + pendingPathRef.current = null; + if (!pathToOpen) { + return; + } + + void openProject(pathToOpen).catch(() => undefined); + }; + + void listenToDesktopEvent("open-project", (payload) => { + if (disposed) { + return; + } + const nextPath = typeof payload?.path === "string" ? payload.path.trim() : ""; + maybeOpenProject(nextPath); + }) + .then((dispose) => { + if (disposed) { + dispose(); + return; + } + unlisten = dispose; + }) + .catch(() => undefined); + + maybeOpenProject(pendingPathRef.current ?? ""); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [client, openProject, serverId]); + + return null; +} + function AppWithSidebar({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts new file mode 100644 index 000000000..a023072bd --- /dev/null +++ b/packages/cli/src/commands/open.ts @@ -0,0 +1,126 @@ +import { existsSync, statSync } 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) + ); +} + +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 = [ + "/Applications/Paseo.app", + path.join(homedir(), "Applications", "Paseo.app"), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; + } + + if (process.platform === "linux") { + const candidates = [ + "/usr/bin/Paseo", + "/opt/Paseo/Paseo", + path.join(homedir(), "Applications", "Paseo.AppImage"), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; + } + + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return null; + } + + const candidate = path.join(localAppData, "Programs", "Paseo", "Paseo.exe"); + return existsSync(candidate) ? candidate : null; + } + + return null; +} + +function spawnDetached(command: string, args: string[]): void { + spawn(command, args, { + detached: true, + stdio: "ignore", + }).unref(); +} + +export async function openDesktopWithProject(pathArg: 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.", + ); + } + + const desktopApp = findDesktopApp(); + if (!desktopApp) { + throw new Error( + "Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases", + ); + } + + if (process.platform === "darwin") { + spawnDetached("open", ["-a", desktopApp, "--args", DESKTOP_GUI_FLAG, projectPath]); + return; + } + + spawnDetached(desktopApp, [DESKTOP_GUI_FLAG, projectPath]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c50882984..1d848284f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,14 @@ import { createCli } from "./cli.js"; +import { isPathLikeArg, openDesktopWithProject } from "./commands/open.js"; const program = createCli(); -if (process.argv.length <= 2) { - process.argv.push("onboard"); + +const firstArg = process.argv[2]; +if (firstArg && isPathLikeArg(firstArg)) { + await openDesktopWithProject(firstArg); +} else { + if (process.argv.length <= 2) { + process.argv.push("onboard"); + } + program.parse(process.argv, { from: "node" }); } -program.parse(process.argv, { from: "node" }); diff --git a/packages/cli/tests/32-open-project.test.ts b/packages/cli/tests/32-open-project.test.ts new file mode 100644 index 000000000..fa89dfd6f --- /dev/null +++ b/packages/cli/tests/32-open-project.test.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env npx zx + +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { isPathLikeArg, openDesktopWithProject } from "../src/commands/open.ts"; + +console.log("šŸ“‹ Phase 32: Open Project CLI Tests\n"); + +console.log(" Testing path-like detection exports..."); +assert.equal(isPathLikeArg("."), true); +assert.equal(isPathLikeArg("./app"), true); +assert.equal(isPathLikeArg("/tmp/app"), true); +assert.equal(isPathLikeArg("~/app"), true); +assert.equal(isPathLikeArg("run"), false); +assert.equal(isPathLikeArg("foo"), false); +console.log(" āœ… path-like detection matches the expected prefixes"); + +console.log(" Testing nonexistent project path errors..."); +const missingProject = join(tmpdir(), "paseo-open-project-missing"); +const originalWrite = process.stderr.write.bind(process.stderr); +const stderrChunks: string[] = []; +process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; +}) as typeof process.stderr.write; + +const previousExitCode = process.exitCode; +process.exitCode = undefined; + +await openDesktopWithProject(missingProject); + +process.stderr.write = originalWrite; +assert.equal(process.exitCode, 1); +assert.match(stderrChunks.join(""), /Path does not exist:/); +process.exitCode = previousExitCode; +console.log(" āœ… nonexistent paths fail with a helpful error"); + +console.log("\nāœ… Phase 32: Open Project CLI Tests PASSED"); diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index 5a4fa65de..b83994b0f 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -31,4 +31,30 @@ 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 + exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@" diff --git a/packages/desktop/bin/paseo.cmd b/packages/desktop/bin/paseo.cmd index 1e8321126..24984e38f 100644 --- a/packages/desktop/bin/paseo.cmd +++ b/packages/desktop/bin/paseo.cmd @@ -9,6 +9,33 @@ 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" %* exit /b %errorlevel% diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts index a85511cc2..b71e76bec 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts @@ -62,6 +62,20 @@ describe("node-entrypoint-launcher", () => { ).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({ diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.ts index 4b54cf7d8..8831ca2bd 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.ts @@ -42,9 +42,14 @@ export function parseCliPassthroughArgsFromArgv( input: ParseCliPassthroughArgsFromArgvInput, ): string[] | null { const startIndex = input.isDefaultApp ? 2 : 1; - const effective = input.argv - .slice(startIndex) - .filter((arg) => !IGNORED_ARG_PREFIXES.some((p) => arg.startsWith(p))); + 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; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 6a38ad1e1..c466c4581 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -30,8 +30,33 @@ import { setupApplicationMenu } from "./features/menu.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); + protocol.registerSchemesAsPrivileged([ { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, ]); @@ -126,6 +151,19 @@ async function createMainWindow(): Promise { await mainWindow.loadURL(`${APP_SCHEME}://app/`); } +function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void { + const send = () => { + win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath }); + }; + + if (win.webContents.isLoadingMainFrame()) { + win.webContents.once("did-finish-load", send); + return; + } + + send(); +} + // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- @@ -137,12 +175,16 @@ function setupSingleInstanceLock(): boolean { return false; } - app.on("second-instance", () => { + app.on("second-instance", (_event, commandLine) => { + const openProjectPath = parseOpenProjectPath(commandLine); const win = BrowserWindow.getAllWindows()[0]; if (win) { win.show(); if (win.isMinimized()) win.restore(); win.focus(); + if (openProjectPath) { + sendOpenProjectEvent(win, openProjectPath); + } } }); @@ -168,7 +210,7 @@ async function runCliPassthroughIfRequested(): Promise { } async function bootstrap(): Promise { - if (await runCliPassthroughIfRequested()) { + if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) { return; } @@ -214,6 +256,10 @@ 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) {