feat(cli): support paseo . to open desktop app with project (#189)

* 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 <path>` 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
This commit is contained in:
Mohamed Boudra
2026-04-04 14:30:25 +07:00
committed by GitHub
parent 42cfed514c
commit 3d3e327378
9 changed files with 363 additions and 8 deletions

View File

@@ -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<void> {
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;
}
}

View File

@@ -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" });

View File

@@ -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");