Fix CLI arg routing and desktop install links

This commit is contained in:
Mohamed Boudra
2026-04-04 16:35:31 +07:00
parent c3313ff82a
commit d69fcb861d
9 changed files with 317 additions and 30 deletions
+25
View File
@@ -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<string>;
}): 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();
+3 -2
View File
@@ -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) {
+26 -1
View File
@@ -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);
@@ -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");
});
});
@@ -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;
}
@@ -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<InstallStatus> {
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<InstallStatus> {
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();
+12 -25
View File
@@ -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();
@@ -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();
});
});
@@ -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 });
}