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
+188
View File
@@ -0,0 +1,188 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "./classify.js";
const knownCommands = new Set(["ls", "run", "status"]);
describe("classifyInvocation", () => {
it("classifies no args as CLI mode", () => {
expect(
classifyInvocation({
argv: [],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: [] });
});
it("classifies flags as CLI mode", () => {
expect(
classifyInvocation({
argv: ["--version"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: ["--version"] });
});
it("classifies known commands as CLI mode", () => {
expect(
classifyInvocation({
argv: ["ls", "--json"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: ["ls", "--json"] });
});
it("classifies '.' as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-dot-"));
expect(
classifyInvocation({
argv: ["."],
knownCommands,
cwd: projectDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies '..' as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-parent-"));
const childDir = path.join(parentDir, "child");
mkdirSync(childDir);
expect(
classifyInvocation({
argv: [".."],
knownCommands,
cwd: childDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: parentDir,
});
});
it("classifies './myproject' as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-relative-"));
const projectDir = path.join(parentDir, "myproject");
mkdirSync(projectDir);
expect(
classifyInvocation({
argv: ["./myproject"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies an absolute path as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-absolute-"));
expect(
classifyInvocation({
argv: [projectDir],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies a home-relative path as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(homedir(), "paseo-classify-home-"));
const relativeToHome = `~/${path.basename(projectDir)}`;
expect(
classifyInvocation({
argv: [relativeToHome],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies an existing directory name as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-existing-"));
const projectDir = path.join(parentDir, "myproject");
mkdirSync(projectDir);
expect(
classifyInvocation({
argv: ["myproject"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("keeps known commands in CLI mode even when a matching directory exists", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-command-"));
mkdirSync(path.join(parentDir, "status"));
expect(
classifyInvocation({
argv: ["status"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "cli",
argv: ["status"],
});
});
it("classifies nonexistent args as CLI mode", () => {
expect(
classifyInvocation({
argv: ["nonexistent"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "cli",
argv: ["nonexistent"],
});
});
});
describe("path helpers", () => {
it("detects path-like prefixes", () => {
expect(isPathLikeArg(".")).toBe(true);
expect(isPathLikeArg("..")).toBe(true);
expect(isPathLikeArg("./project")).toBe(true);
expect(isPathLikeArg("../project")).toBe(true);
expect(isPathLikeArg("/tmp/project")).toBe(true);
expect(isPathLikeArg("~")).toBe(true);
expect(isPathLikeArg("~/project")).toBe(true);
expect(isPathLikeArg("C:\\project")).toBe(true);
expect(isPathLikeArg("status")).toBe(false);
});
it("detects existing directories relative to cwd", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-helper-"));
const projectDir = path.join(parentDir, "project");
mkdirSync(projectDir);
expect(isExistingDirectory({ pathArg: "project", cwd: parentDir })).toBe(true);
expect(isExistingDirectory({ pathArg: "missing", cwd: parentDir })).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
export type CliInvocation =
| { kind: "cli"; argv: string[] }
| { kind: "open-project"; resolvedPath: string };
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 expandUserPath(inputPath: string): string {
if (inputPath === "~") {
return homedir();
}
if (inputPath.startsWith("~/")) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}
export function isExistingDirectory(input: { pathArg: string; cwd: string }): boolean {
const resolvedPath = path.resolve(input.cwd, expandUserPath(input.pathArg));
if (!existsSync(resolvedPath)) {
return false;
}
return statSync(resolvedPath).isDirectory();
}
export function classifyInvocation(input: {
argv: string[];
knownCommands: ReadonlySet<string>;
cwd: string;
}): CliInvocation {
const [firstArg] = input.argv;
if (!firstArg) {
return { kind: "cli", argv: input.argv };
}
if (firstArg.startsWith("-")) {
return { kind: "cli", argv: input.argv };
}
if (input.knownCommands.has(firstArg)) {
return { kind: "cli", argv: input.argv };
}
if (isExistingDirectory({ pathArg: firstArg, cwd: input.cwd })) {
return {
kind: "open-project",
resolvedPath: path.resolve(input.cwd, expandUserPath(firstArg)),
};
}
return { kind: "cli", argv: input.argv };
}
+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`);
+19 -8
View File
@@ -1,15 +1,26 @@
import { createCli } from "./cli.js";
import { openDesktopWithProject, shouldOpenProjectArg } from "./commands/open.js";
import { classifyInvocation } from "./classify.js";
import { openDesktopWithProject } from "./commands/open.js";
const program = createCli();
const knownCommands = new Set(program.commands.map((command) => command.name()));
const firstArg = process.argv[2];
if (firstArg && shouldOpenProjectArg({ arg: firstArg, knownCommands })) {
await openDesktopWithProject(firstArg);
} else {
if (process.argv.length <= 2) {
process.argv.push("onboard");
const invocation = classifyInvocation({
argv: process.argv.slice(2),
knownCommands,
cwd: process.cwd(),
});
switch (invocation.kind) {
case "cli": {
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
if (invocation.argv.length === 0) {
argv.push("onboard");
}
program.parse(argv, { from: "node" });
break;
}
program.parse(process.argv, { from: "node" });
case "open-project":
await openDesktopWithProject(invocation.resolvedPath);
break;
}