Fix CLI routing: single classifier, open-project for cold & hot start

- Add classify.ts as the single source of truth for CLI invocation
  routing (discriminated union, derives known commands from Commander)
- Remove all hardcoded command lists and duplicate path detection logic
- Simplify shell wrappers to dumb pipes (zero classification)
- Fix hot-start: use `open -n -g -a` (VS Code pattern) so second-instance
  event fires when app is already running
- Fix cold-start race: pull-based IPC (getPendingOpenProject) so renderer
  fetches the pending path after React mounts, instead of push event that
  arrived before the listener existed
- Fix asar read corruption: unpack node-entrypoint-runner.js from asar
  (Node.js v24 in Electron 41 can't parse package.json inside asar)
- Strip ELECTRON_RUN_AS_NODE from env when spawning desktop app from CLI
This commit is contained in:
Mohamed Boudra
2026-04-04 19:21:12 +07:00
parent fefe260f0a
commit 43542ac858
16 changed files with 441 additions and 288 deletions
@@ -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" },
}),
+1 -1
View File
@@ -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 {
+20 -9
View File
@@ -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<void> {
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<void> {
registerNotificationHandlers();
registerOpenerHandlers();
await createMainWindow();
const mainWindow = BrowserWindow.getAllWindows()[0];
if (mainWindow && pendingOpenProjectPath) {
sendOpenProjectEvent(mainWindow, pendingOpenProjectPath);
}
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -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);
});
});
+22 -103
View File
@@ -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;
}
+2
View File
@@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
invoke: (command: string, args?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:invoke", command, args),
getPendingOpenProject: () =>
ipcRenderer.invoke("paseo:get-pending-open-project") as Promise<string | null>,
events: {
on: (event: string, handler: EventHandler): Promise<() => void> => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {