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:
@@ -31,30 +31,7 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
./*|../*|.|..|/*|~|~/*)
|
||||
case "$1" in
|
||||
~) ARG_PATH="$HOME" ;;
|
||||
~/*) ARG_PATH="$HOME/${1#\~/}" ;;
|
||||
*) ARG_PATH="$1" ;;
|
||||
esac
|
||||
if RESOLVED_PATH=$(CDPATH= cd -- "$ARG_PATH" 2>/dev/null && pwd); then
|
||||
:
|
||||
elif command -v realpath >/dev/null 2>&1; then
|
||||
RESOLVED_PATH=$(realpath "$ARG_PATH" 2>/dev/null || printf "%s" "$ARG_PATH")
|
||||
else
|
||||
RESOLVED_PATH=$ARG_PATH
|
||||
fi
|
||||
if [ ! -e "${RESOLVED_PATH}" ]; then
|
||||
echo "Path does not exist: ${RESOLVED_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "${RESOLVED_PATH}" ]; then
|
||||
echo "Not a directory: ${RESOLVED_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "${APP_EXECUTABLE}" --open-project "${RESOLVED_PATH}"
|
||||
;;
|
||||
esac
|
||||
RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js"
|
||||
CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@"
|
||||
|
||||
@@ -9,33 +9,6 @@ if not exist "%APP_EXECUTABLE%" (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "FIRST_ARG=%~1"
|
||||
if "%FIRST_ARG%"=="." goto :open_project
|
||||
if "%FIRST_ARG%"==".." goto :open_project
|
||||
if "%FIRST_ARG:~0,2%"==".\" goto :open_project
|
||||
if "%FIRST_ARG:~0,2%"=="./" goto :open_project
|
||||
if "%FIRST_ARG:~0,3%"=="..\" goto :open_project
|
||||
if "%FIRST_ARG:~0,3%"=="../" goto :open_project
|
||||
if "%FIRST_ARG:~0,1%"=="\" goto :open_project
|
||||
if "%FIRST_ARG:~0,1%"=="/" goto :open_project
|
||||
if "%FIRST_ARG:~1,2%"==":\" goto :open_project
|
||||
if "%FIRST_ARG:~1,2%"==":/" goto :open_project
|
||||
goto :cli_mode
|
||||
|
||||
:open_project
|
||||
for %%I in ("%~1") do set "RESOLVED_PATH=%%~fI"
|
||||
if not exist "%RESOLVED_PATH%" (
|
||||
echo Path does not exist: %RESOLVED_PATH% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%RESOLVED_PATH%\NUL" (
|
||||
echo Not a directory: %RESOLVED_PATH% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
"%APP_EXECUTABLE%" --open-project "%RESOLVED_PATH%"
|
||||
exit /b %errorlevel%
|
||||
|
||||
:cli_mode
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
@@ -7,6 +7,8 @@ directories:
|
||||
output: release
|
||||
files:
|
||||
- dist/**/*
|
||||
asarUnpack:
|
||||
- dist/daemon/node-entrypoint-runner.js
|
||||
extraResources:
|
||||
- from: ../app/dist
|
||||
to: app-dist
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user