Fix CLI arg routing and desktop install links
This commit is contained in:
@@ -18,6 +18,31 @@ export function isPathLikeArg(arg: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function expandUserPath(inputPath: string): string {
|
||||||
if (inputPath === "~") {
|
if (inputPath === "~") {
|
||||||
return homedir();
|
return homedir();
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createCli } from "./cli.js";
|
import { createCli } from "./cli.js";
|
||||||
import { isPathLikeArg, openDesktopWithProject } from "./commands/open.js";
|
import { openDesktopWithProject, shouldOpenProjectArg } from "./commands/open.js";
|
||||||
|
|
||||||
const program = createCli();
|
const program = createCli();
|
||||||
|
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
||||||
|
|
||||||
const firstArg = process.argv[2];
|
const firstArg = process.argv[2];
|
||||||
if (firstArg && isPathLikeArg(firstArg)) {
|
if (firstArg && shouldOpenProjectArg({ arg: firstArg, knownCommands })) {
|
||||||
await openDesktopWithProject(firstArg);
|
await openDesktopWithProject(firstArg);
|
||||||
} else {
|
} else {
|
||||||
if (process.argv.length <= 2) {
|
if (process.argv.length <= 2) {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
#!/usr/bin/env npx zx
|
#!/usr/bin/env npx zx
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
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 { isPathLikeArg, openDesktopWithProject } from "../src/commands/open.ts";
|
import {
|
||||||
|
isExistingDirectoryArg,
|
||||||
|
isPathLikeArg,
|
||||||
|
openDesktopWithProject,
|
||||||
|
shouldOpenProjectArg,
|
||||||
|
} from "../src/commands/open.ts";
|
||||||
|
|
||||||
console.log("📋 Phase 32: Open Project CLI Tests\n");
|
console.log("📋 Phase 32: Open Project CLI Tests\n");
|
||||||
|
|
||||||
@@ -16,6 +22,25 @@ assert.equal(isPathLikeArg("run"), false);
|
|||||||
assert.equal(isPathLikeArg("foo"), false);
|
assert.equal(isPathLikeArg("foo"), false);
|
||||||
console.log(" ✅ path-like detection matches the expected prefixes");
|
console.log(" ✅ path-like detection matches the expected prefixes");
|
||||||
|
|
||||||
|
console.log(" Testing existing directory detection and command precedence...");
|
||||||
|
const existingProject = join(await mkdtemp(join(tmpdir(), "paseo-open-project-")), "project");
|
||||||
|
await mkdir(existingProject);
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(join(existingProject, ".."));
|
||||||
|
|
||||||
|
assert.equal(isExistingDirectoryArg("project"), true);
|
||||||
|
assert.equal(
|
||||||
|
shouldOpenProjectArg({ arg: "project", knownCommands: new Set(["run", "status"]) }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldOpenProjectArg({ arg: "run", knownCommands: new Set(["run", "status"]) }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
console.log(" ✅ existing directories open as projects, but known commands still win");
|
||||||
|
|
||||||
console.log(" Testing nonexistent project path errors...");
|
console.log(" Testing nonexistent project path errors...");
|
||||||
const missingProject = join(tmpdir(), "paseo-open-project-missing");
|
const missingProject = join(tmpdir(), "paseo-open-project-missing");
|
||||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||||
|
|
||||||
|
describe("cli-install-path", () => {
|
||||||
|
it("uses the packaged executable on supported unix platforms", () => {
|
||||||
|
expect(
|
||||||
|
resolveCliInstallSourcePath({
|
||||||
|
platform: "darwin",
|
||||||
|
isPackaged: true,
|
||||||
|
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||||
|
shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo",
|
||||||
|
}),
|
||||||
|
).toBe("/Applications/Paseo.app/Contents/MacOS/Paseo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the original AppImage path on linux", () => {
|
||||||
|
expect(
|
||||||
|
resolveCliInstallSourcePath({
|
||||||
|
platform: "linux",
|
||||||
|
isPackaged: true,
|
||||||
|
executablePath: "/tmp/.mount_paseo123/paseo",
|
||||||
|
shimPath: "/tmp/.mount_paseo123/resources/bin/paseo",
|
||||||
|
appImagePath: "/home/user/Applications/Paseo.AppImage",
|
||||||
|
}),
|
||||||
|
).toBe("/home/user/Applications/Paseo.AppImage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the shim on windows and in development", () => {
|
||||||
|
expect(
|
||||||
|
resolveCliInstallSourcePath({
|
||||||
|
platform: "win32",
|
||||||
|
isPackaged: true,
|
||||||
|
executablePath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||||
|
shimPath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd",
|
||||||
|
}),
|
||||||
|
).toBe("C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveCliInstallSourcePath({
|
||||||
|
platform: "linux",
|
||||||
|
isPackaged: false,
|
||||||
|
executablePath: "/opt/Paseo/paseo",
|
||||||
|
shimPath: "/opt/Paseo/resources/bin/paseo",
|
||||||
|
}),
|
||||||
|
).toBe("/opt/Paseo/resources/bin/paseo");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function resolveCliInstallSourcePath(input: {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
isPackaged: boolean;
|
||||||
|
executablePath: string;
|
||||||
|
shimPath: string;
|
||||||
|
appImagePath?: string | null;
|
||||||
|
}): string {
|
||||||
|
if (input.platform === "win32") {
|
||||||
|
return input.shimPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.isPackaged) {
|
||||||
|
return input.shimPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.platform === "linux") {
|
||||||
|
const appImagePath = input.appImagePath?.trim();
|
||||||
|
if (appImagePath) {
|
||||||
|
return appImagePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.executablePath;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import log from "electron-log/main";
|
import log from "electron-log/main";
|
||||||
|
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -184,6 +185,13 @@ async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
|||||||
export async function installCli(): Promise<InstallStatus> {
|
export async function installCli(): Promise<InstallStatus> {
|
||||||
const targetPath = getCliTargetPath();
|
const targetPath = getCliTargetPath();
|
||||||
const shimPath = getBundledCliShimPath();
|
const shimPath = getBundledCliShimPath();
|
||||||
|
const installSourcePath = resolveCliInstallSourcePath({
|
||||||
|
platform: process.platform,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
executablePath: app.getPath("exe"),
|
||||||
|
shimPath,
|
||||||
|
appImagePath: process.env.APPIMAGE,
|
||||||
|
});
|
||||||
const binDir = getLocalBinDir();
|
const binDir = getLocalBinDir();
|
||||||
|
|
||||||
await fs.mkdir(binDir, { recursive: true });
|
await fs.mkdir(binDir, { recursive: true });
|
||||||
@@ -192,12 +200,12 @@ export async function installCli(): Promise<InstallStatus> {
|
|||||||
if (await pathOrSymlinkExists(targetPath)) {
|
if (await pathOrSymlinkExists(targetPath)) {
|
||||||
await fs.unlink(targetPath);
|
await fs.unlink(targetPath);
|
||||||
}
|
}
|
||||||
await fs.copyFile(shimPath, targetPath);
|
await fs.copyFile(installSourcePath, targetPath);
|
||||||
} else {
|
} else {
|
||||||
if (await pathOrSymlinkExists(targetPath)) {
|
if (await pathOrSymlinkExists(targetPath)) {
|
||||||
await fs.unlink(targetPath);
|
await fs.unlink(targetPath);
|
||||||
}
|
}
|
||||||
await fs.symlink(shimPath, targetPath);
|
await fs.symlink(installSourcePath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { shellUpdated } = await ensurePathInShellRc();
|
const { shellUpdated } = await ensurePathInShellRc();
|
||||||
|
|||||||
@@ -27,35 +27,18 @@ import {
|
|||||||
} from "./features/notifications.js";
|
} from "./features/notifications.js";
|
||||||
import { registerOpenerHandlers } from "./features/opener.js";
|
import { registerOpenerHandlers } from "./features/opener.js";
|
||||||
import { setupApplicationMenu } from "./features/menu.js";
|
import { setupApplicationMenu } from "./features/menu.js";
|
||||||
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||||
|
|
||||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||||
const APP_SCHEME = "paseo";
|
const APP_SCHEME = "paseo";
|
||||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||||
const OPEN_PROJECT_FLAG = "--open-project";
|
|
||||||
const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
|
|
||||||
app.setName("Paseo");
|
app.setName("Paseo");
|
||||||
|
|
||||||
function parseOpenProjectPath(argv: string[]): string | null {
|
const pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||||
const startIndex = process.defaultApp ? 2 : 1;
|
argv: process.argv,
|
||||||
|
isDefaultApp: process.defaultApp,
|
||||||
for (let index = startIndex; index < argv.length; index += 1) {
|
cwd: process.cwd(),
|
||||||
const arg = argv[index];
|
});
|
||||||
if (OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arg !== OPEN_PROJECT_FLAG) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathArg = argv[index + 1];
|
|
||||||
return pathArg ? pathArg : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingOpenProjectPath = parseOpenProjectPath(process.argv);
|
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||||
@@ -175,8 +158,12 @@ function setupSingleInstanceLock(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on("second-instance", (_event, commandLine) => {
|
app.on("second-instance", (_event, commandLine, workingDirectory) => {
|
||||||
const openProjectPath = parseOpenProjectPath(commandLine);
|
const openProjectPath = parseOpenProjectPathFromArgv({
|
||||||
|
argv: commandLine,
|
||||||
|
isDefaultApp: false,
|
||||||
|
cwd: workingDirectory || process.cwd(),
|
||||||
|
});
|
||||||
const win = BrowserWindow.getAllWindows()[0];
|
const win = BrowserWindow.getAllWindows()[0];
|
||||||
if (win) {
|
if (win) {
|
||||||
win.show();
|
win.show();
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import os 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", () => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseOpenProjectPathFromArgv({
|
||||||
|
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "project"],
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpenProjectPathFromArgv(input: {
|
||||||
|
argv: string[];
|
||||||
|
isDefaultApp: boolean;
|
||||||
|
cwd: string;
|
||||||
|
}): string | null {
|
||||||
|
const effectiveArgs = getEffectiveArgs({
|
||||||
|
argv: input.argv,
|
||||||
|
isDefaultApp: input.isDefaultApp,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveArgs.length !== 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user