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
+19 -71
View File
@@ -1,73 +1,8 @@
import { existsSync, statSync } from "node:fs";
import { existsSync } 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)
);
}
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();
}
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 = [
@@ -113,17 +48,25 @@ function findDesktopApp(): string | null {
return null;
}
function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv {
const env = { ...process.env };
// The CLI runs via ELECTRON_RUN_AS_NODE=1. On Linux/Windows the spawned
// desktop process inherits the env directly, so we must strip it or the
// desktop app would start as a bare Node process instead of Electron.
delete env.ELECTRON_RUN_AS_NODE;
return env;
}
function spawnDetached(command: string, args: string[]): void {
spawn(command, args, {
detached: true,
stdio: "ignore",
env: cleanEnvForDesktopLaunch(),
}).unref();
}
export async function openDesktopWithProject(pathArg: string): Promise<void> {
export async function openDesktopWithProject(projectPath: 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.",
@@ -138,11 +81,16 @@ export async function openDesktopWithProject(pathArg: string): Promise<void> {
}
if (process.platform === "darwin") {
spawnDetached("open", ["-a", desktopApp, "--args", DESKTOP_GUI_FLAG, projectPath]);
// -n forces a new instance even if the app is already running.
// The new instance hits requestSingleInstanceLock(), fails, and relays
// the argv to the first instance via the second-instance event.
// -g keeps the terminal in the foreground (better CLI UX).
// Without -n, macOS just activates the existing window and drops --args.
spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]);
return;
}
spawnDetached(desktopApp, [DESKTOP_GUI_FLAG, projectPath]);
spawnDetached(desktopApp, [projectPath]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);