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:
@@ -634,6 +634,22 @@ function OpenProjectListener() {
|
|||||||
void openProject(pathToOpen).catch(() => undefined);
|
void openProject(pathToOpen).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pull any path that was passed on cold start (before the listener existed).
|
||||||
|
// Store in the ref even if this effect instance is disposed — the next
|
||||||
|
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
||||||
|
void getDesktopHost()
|
||||||
|
?.getPendingOpenProject?.()
|
||||||
|
?.then((pending) => {
|
||||||
|
if (pending) {
|
||||||
|
pendingPathRef.current = pending;
|
||||||
|
}
|
||||||
|
if (!disposed && pending) {
|
||||||
|
maybeOpenProject(pending);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
// Listen for hot-start paths relayed via the second-instance event.
|
||||||
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface DesktopInvokeBridge {
|
|||||||
export interface DesktopHostBridge {
|
export interface DesktopHostBridge {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
invoke?: DesktopInvokeBridge["invoke"];
|
invoke?: DesktopInvokeBridge["invoke"];
|
||||||
|
getPendingOpenProject?: () => Promise<string | null>;
|
||||||
events?: DesktopEventsBridge;
|
events?: DesktopEventsBridge;
|
||||||
window?: DesktopWindowModuleBridge;
|
window?: DesktopWindowModuleBridge;
|
||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,73 +1,8 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import path from "node:path";
|
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 {
|
function findDesktopApp(): string | null {
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -113,17 +48,25 @@ function findDesktopApp(): string | null {
|
|||||||
return 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 {
|
function spawnDetached(command: string, args: string[]): void {
|
||||||
spawn(command, args, {
|
spawn(command, args, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
|
env: cleanEnvForDesktopLaunch(),
|
||||||
}).unref();
|
}).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openDesktopWithProject(pathArg: string): Promise<void> {
|
export async function openDesktopWithProject(projectPath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const projectPath = resolveProjectDirectory(pathArg);
|
|
||||||
|
|
||||||
if (process.env.PASEO_DESKTOP_CLI === "1") {
|
if (process.env.PASEO_DESKTOP_CLI === "1") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Cannot open a desktop project while running in desktop CLI passthrough mode.",
|
"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") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnDetached(desktopApp, [DESKTOP_GUI_FLAG, projectPath]);
|
spawnDetached(desktopApp, [projectPath]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { createCli } from "./cli.js";
|
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 program = createCli();
|
||||||
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
||||||
|
|
||||||
const firstArg = process.argv[2];
|
const invocation = classifyInvocation({
|
||||||
if (firstArg && shouldOpenProjectArg({ arg: firstArg, knownCommands })) {
|
argv: process.argv.slice(2),
|
||||||
await openDesktopWithProject(firstArg);
|
knownCommands,
|
||||||
} else {
|
cwd: process.cwd(),
|
||||||
if (process.argv.length <= 2) {
|
});
|
||||||
process.argv.push("onboard");
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { mkdir, mkdtemp } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import {
|
import {
|
||||||
isExistingDirectoryArg,
|
classifyInvocation,
|
||||||
|
isExistingDirectory,
|
||||||
isPathLikeArg,
|
isPathLikeArg,
|
||||||
openDesktopWithProject,
|
} from "../src/classify.ts";
|
||||||
shouldOpenProjectArg,
|
import { openDesktopWithProject } from "../src/commands/open.ts";
|
||||||
} from "../src/commands/open.ts";
|
|
||||||
|
|
||||||
console.log("📋 Phase 32: Open Project CLI Tests\n");
|
console.log("📋 Phase 32: Open Project CLI Tests\n");
|
||||||
|
|
||||||
console.log(" Testing path-like detection exports...");
|
console.log(" Testing path-like detection exports...");
|
||||||
assert.equal(isPathLikeArg("."), true);
|
assert.equal(isPathLikeArg("."), true);
|
||||||
assert.equal(isPathLikeArg("./app"), true);
|
assert.equal(isPathLikeArg("./app"), true);
|
||||||
assert.equal(isPathLikeArg("/tmp/app"), true);
|
assert.equal(isPathLikeArg("/tmp/app"), true);
|
||||||
@@ -28,21 +28,28 @@ await mkdir(existingProject);
|
|||||||
const originalCwd = process.cwd();
|
const originalCwd = process.cwd();
|
||||||
process.chdir(join(existingProject, ".."));
|
process.chdir(join(existingProject, ".."));
|
||||||
|
|
||||||
assert.equal(isExistingDirectoryArg("project"), true);
|
assert.equal(isExistingDirectory({ pathArg: "project", cwd: process.cwd() }), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldOpenProjectArg({ arg: "project", knownCommands: new Set(["run", "status"]) }),
|
classifyInvocation({
|
||||||
true,
|
argv: ["project"],
|
||||||
|
knownCommands: new Set(["run", "status"]),
|
||||||
|
cwd: process.cwd(),
|
||||||
|
}).kind,
|
||||||
|
"open-project",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldOpenProjectArg({ arg: "run", knownCommands: new Set(["run", "status"]) }),
|
classifyInvocation({
|
||||||
false,
|
argv: ["run"],
|
||||||
|
knownCommands: new Set(["run", "status"]),
|
||||||
|
cwd: process.cwd(),
|
||||||
|
}).kind,
|
||||||
|
"cli",
|
||||||
);
|
);
|
||||||
|
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
console.log(" ✅ existing directories open as projects, but known commands still win");
|
console.log(" ✅ existing directories open as projects, but known commands still win");
|
||||||
|
|
||||||
console.log(" Testing nonexistent project path errors...");
|
console.log(" Testing desktop CLI passthrough guard...");
|
||||||
const missingProject = join(tmpdir(), "paseo-open-project-missing");
|
|
||||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||||
const stderrChunks: string[] = [];
|
const stderrChunks: string[] = [];
|
||||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||||
@@ -52,13 +59,16 @@ process.stderr.write = ((chunk: string | Uint8Array) => {
|
|||||||
|
|
||||||
const previousExitCode = process.exitCode;
|
const previousExitCode = process.exitCode;
|
||||||
process.exitCode = undefined;
|
process.exitCode = undefined;
|
||||||
|
const previousDesktopCli = process.env.PASEO_DESKTOP_CLI;
|
||||||
|
process.env.PASEO_DESKTOP_CLI = "1";
|
||||||
|
|
||||||
await openDesktopWithProject(missingProject);
|
await openDesktopWithProject(existingProject);
|
||||||
|
|
||||||
process.stderr.write = originalWrite;
|
process.stderr.write = originalWrite;
|
||||||
assert.equal(process.exitCode, 1);
|
assert.equal(process.exitCode, 1);
|
||||||
assert.match(stderrChunks.join(""), /Path does not exist:/);
|
assert.match(stderrChunks.join(""), /desktop CLI passthrough mode/);
|
||||||
process.exitCode = previousExitCode;
|
process.exitCode = previousExitCode;
|
||||||
console.log(" ✅ nonexistent paths fail with a helpful error");
|
process.env.PASEO_DESKTOP_CLI = previousDesktopCli;
|
||||||
|
console.log(" ✅ desktop CLI passthrough mode is rejected");
|
||||||
|
|
||||||
console.log("\n✅ Phase 32: Open Project CLI Tests PASSED");
|
console.log("\n✅ Phase 32: Open Project CLI Tests PASSED");
|
||||||
|
|||||||
@@ -31,30 +31,7 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
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"
|
||||||
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
|
|
||||||
|
|
||||||
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
|
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"
|
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%
|
exit /b %errorlevel%
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ directories:
|
|||||||
output: release
|
output: release
|
||||||
files:
|
files:
|
||||||
- dist/**/*
|
- dist/**/*
|
||||||
|
asarUnpack:
|
||||||
|
- dist/daemon/node-entrypoint-runner.js
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: ../app/dist
|
- from: ../app/dist
|
||||||
to: app-dist
|
to: app-dist
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ describe("node-entrypoint-launcher", () => {
|
|||||||
isPackaged: true,
|
isPackaged: true,
|
||||||
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||||
entrypoint: CLI_ENTRYPOINT,
|
entrypoint: CLI_ENTRYPOINT,
|
||||||
argvMode: "bare",
|
argvMode: "node-script",
|
||||||
args: ["ls", "--json"],
|
args: ["ls", "--json"],
|
||||||
baseEnv: { PATH: "/usr/bin" },
|
baseEnv: { PATH: "/usr/bin" },
|
||||||
}),
|
}),
|
||||||
@@ -103,7 +103,7 @@ describe("node-entrypoint-launcher", () => {
|
|||||||
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||||
args: [
|
args: [
|
||||||
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||||
"bare",
|
"node-script",
|
||||||
"/tmp/paseo-cli.js",
|
"/tmp/paseo-cli.js",
|
||||||
"ls",
|
"ls",
|
||||||
"--json",
|
"--json",
|
||||||
@@ -122,7 +122,7 @@ describe("node-entrypoint-launcher", () => {
|
|||||||
isPackaged: false,
|
isPackaged: false,
|
||||||
packagedRunnerPath: null,
|
packagedRunnerPath: null,
|
||||||
entrypoint: CLI_ENTRYPOINT,
|
entrypoint: CLI_ENTRYPOINT,
|
||||||
argvMode: "bare",
|
argvMode: "node-script",
|
||||||
args: ["ls"],
|
args: ["ls"],
|
||||||
baseEnv: { PATH: "/usr/bin" },
|
baseEnv: { PATH: "/usr/bin" },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function resolvePackagedAsarPath(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePackagedNodeEntrypointRunnerPath(): 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 {
|
function assertPathExists(input: { label: string; filePath: string }): string {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true });
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
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 { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
parseCliPassthroughArgsFromArgv,
|
parseCliPassthroughArgsFromArgv,
|
||||||
@@ -34,10 +34,22 @@ const APP_SCHEME = "paseo";
|
|||||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||||
app.setName("Paseo");
|
app.setName("Paseo");
|
||||||
|
|
||||||
const pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||||
argv: process.argv,
|
argv: process.argv,
|
||||||
isDefaultApp: process.defaultApp,
|
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([
|
protocol.registerSchemesAsPrivileged([
|
||||||
@@ -136,10 +148,12 @@ async function createMainWindow(): Promise<void> {
|
|||||||
|
|
||||||
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
||||||
const send = () => {
|
const send = () => {
|
||||||
|
log.info("[open-project] sending event to renderer:", projectPath);
|
||||||
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (win.webContents.isLoadingMainFrame()) {
|
if (win.webContents.isLoadingMainFrame()) {
|
||||||
|
log.info("[open-project] waiting for did-finish-load before sending event");
|
||||||
win.webContents.once("did-finish-load", send);
|
win.webContents.once("did-finish-load", send);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,12 +172,13 @@ function setupSingleInstanceLock(): boolean {
|
|||||||
return false;
|
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({
|
const openProjectPath = parseOpenProjectPathFromArgv({
|
||||||
argv: commandLine,
|
argv: commandLine,
|
||||||
isDefaultApp: false,
|
isDefaultApp: false,
|
||||||
cwd: workingDirectory || process.cwd(),
|
|
||||||
});
|
});
|
||||||
|
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
||||||
const win = BrowserWindow.getAllWindows()[0];
|
const win = BrowserWindow.getAllWindows()[0];
|
||||||
if (win) {
|
if (win) {
|
||||||
win.show();
|
win.show();
|
||||||
@@ -243,10 +258,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
registerNotificationHandlers();
|
registerNotificationHandlers();
|
||||||
registerOpenerHandlers();
|
registerOpenerHandlers();
|
||||||
await createMainWindow();
|
await createMainWindow();
|
||||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
|
||||||
if (mainWindow && pendingOpenProjectPath) {
|
|
||||||
sendOpenProjectEvent(mainWindow, pendingOpenProjectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
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 path from "node:path";
|
||||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing";
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing";
|
||||||
|
|
||||||
describe("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(
|
expect(
|
||||||
parseOpenProjectPathFromArgv({
|
parseOpenProjectPathFromArgv({
|
||||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
|
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
|
||||||
isDefaultApp: false,
|
isDefaultApp: false,
|
||||||
cwd: process.cwd(),
|
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves a lone existing directory argument to an absolute project path", () => {
|
it("returns the path from an explicit --open-project flag for backward compatibility", () => {
|
||||||
const parentDir = mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-open-"));
|
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
|
||||||
const projectDir = path.join(parentDir, "project");
|
|
||||||
mkdirSync(projectDir);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
parseOpenProjectPathFromArgv({
|
parseOpenProjectPathFromArgv({
|
||||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "project"],
|
argv: [
|
||||||
|
"/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||||
|
"--open-project",
|
||||||
|
projectPath,
|
||||||
|
],
|
||||||
isDefaultApp: false,
|
isDefaultApp: false,
|
||||||
cwd: parentDir,
|
|
||||||
}),
|
}),
|
||||||
).toBe(projectDir);
|
).toBe(projectPath);
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,126 +1,45 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const OPEN_PROJECT_FLAG = "--open-project";
|
const OPEN_PROJECT_FLAG = "--open-project";
|
||||||
const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
|
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 {
|
function isExistingDirectoryAbsolutePath(candidate: string): boolean {
|
||||||
if (inputPath === "~") {
|
if (!path.isAbsolute(candidate) || !existsSync(candidate)) {
|
||||||
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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return statSync(absolutePath).isDirectory();
|
try {
|
||||||
}
|
return statSync(candidate).isDirectory();
|
||||||
|
} catch {
|
||||||
function resolveProjectPath(input: { arg: string; cwd: string }): string {
|
return false;
|
||||||
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: {
|
export function parseOpenProjectPathFromArgv(input: {
|
||||||
argv: string[];
|
argv: string[];
|
||||||
isDefaultApp: boolean;
|
isDefaultApp: boolean;
|
||||||
cwd: string;
|
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const effectiveArgs = getEffectiveArgs({
|
const effectiveArgs = input.argv
|
||||||
argv: input.argv,
|
.slice(input.isDefaultApp ? 2 : 1)
|
||||||
isDefaultApp: input.isDefaultApp,
|
.filter(
|
||||||
});
|
(arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)),
|
||||||
|
);
|
||||||
|
|
||||||
for (let index = 0; index < effectiveArgs.length; index += 1) {
|
const positionalProjectPath = effectiveArgs.find(
|
||||||
const arg = effectiveArgs[index];
|
(arg) => !arg.startsWith("-") && isExistingDirectoryAbsolutePath(arg),
|
||||||
if (arg !== OPEN_PROJECT_FLAG) {
|
);
|
||||||
break;
|
if (positionalProjectPath) {
|
||||||
}
|
return positionalProjectPath;
|
||||||
|
|
||||||
const pathArg = effectiveArgs[index + 1];
|
|
||||||
return pathArg ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveArgs.length !== 1) {
|
const openProjectIndex = effectiveArgs.indexOf(OPEN_PROJECT_FLAG);
|
||||||
|
if (openProjectIndex === -1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [arg] = effectiveArgs;
|
const flaggedProjectPath = effectiveArgs[openProjectIndex + 1];
|
||||||
if (arg.startsWith("-") || TOP_LEVEL_CLI_COMMANDS.has(arg)) {
|
return flaggedProjectPath && isExistingDirectoryAbsolutePath(flaggedProjectPath)
|
||||||
return null;
|
? flaggedProjectPath
|
||||||
}
|
: 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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
|||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
invoke: (command: string, args?: Record<string, unknown>) =>
|
||||||
ipcRenderer.invoke("paseo:invoke", command, args),
|
ipcRenderer.invoke("paseo:invoke", command, args),
|
||||||
|
getPendingOpenProject: () =>
|
||||||
|
ipcRenderer.invoke("paseo:get-pending-open-project") as Promise<string | null>,
|
||||||
events: {
|
events: {
|
||||||
on: (event: string, handler: EventHandler): Promise<() => void> => {
|
on: (event: string, handler: EventHandler): Promise<() => void> => {
|
||||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user