diff --git a/src/cli/api.ts b/src/cli/api.ts index 3618d83c87..5cc25aec8a 100644 --- a/src/cli/api.ts +++ b/src/cli/api.ts @@ -15,7 +15,11 @@ import { router } from "@/node/orpc/router"; import { proxifyOrpc } from "./proxifyOrpc"; import { ServerLockfile } from "@/node/services/serverLockfile"; import { getMuxHome } from "@/common/constants/paths"; -import type { Command } from "commander"; +import { getArgsAfterSplice } from "./argv"; + +// index.ts already splices "api" from argv before importing this module, +// so we just need to get the remaining args after the splice point. +const args = getArgsAfterSplice(); interface ServerDiscovery { baseUrl: string; @@ -57,9 +61,24 @@ async function discoverServer(): Promise { const { baseUrl, authToken } = await discoverServer(); const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken }); - const cli = createCli({ router: proxiedRouter }).buildProgram() as Command; - cli.name("mux api"); - cli.description("Interact with the mux API via a running server"); - cli.parse(); + // Use trpc-cli's run() method instead of buildProgram().parse() + // run() sets exitOverride on root, uses parseAsync, and handles process exit properly + const { run } = createCli({ + router: proxiedRouter, + name: "mux api", + description: "Interact with the mux API via a running server", + }); + + try { + await run({ argv: args }); + } catch (error) { + // trpc-cli throws FailedToExitError after calling process.exit() + // In Electron, process.exit() doesn't immediately terminate, so the error surfaces. + // This is expected and safe to ignore since exit was already requested. + if (error instanceof Error && error.constructor.name === "FailedToExitError") { + return; + } + throw error; + } })(); diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts new file mode 100644 index 0000000000..8a687fb23e --- /dev/null +++ b/src/cli/argv.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test"; +import { + detectCliEnvironment, + getParseOptions, + getSubcommand, + getArgsAfterSplice, + isCommandAvailable, + isElectronLaunchArg, +} from "./argv"; + +describe("detectCliEnvironment", () => { + test("bun/node: firstArgIndex=2", () => { + const env = detectCliEnvironment({}, undefined); + expect(env).toEqual({ + isElectron: false, + isPackagedElectron: false, + firstArgIndex: 2, + }); + }); + + test("electron dev: firstArgIndex=2", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(env).toEqual({ + isElectron: true, + isPackagedElectron: false, + firstArgIndex: 2, + }); + }); + + test("packaged electron: firstArgIndex=1", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + expect(env).toEqual({ + isElectron: true, + isPackagedElectron: true, + firstArgIndex: 1, + }); + }); +}); + +describe("getParseOptions", () => { + test("returns node for bun/node", () => { + const env = detectCliEnvironment({}, undefined); + expect(getParseOptions(env)).toEqual({ from: "node" }); + }); + + test("returns node for electron dev", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(getParseOptions(env)).toEqual({ from: "node" }); + }); + + test("returns electron for packaged", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + expect(getParseOptions(env)).toEqual({ from: "electron" }); + }); +}); + +describe("getSubcommand", () => { + test("bun: gets arg at index 2", () => { + const env = detectCliEnvironment({}, undefined); + expect(getSubcommand(["bun", "script.ts", "server", "--help"], env)).toBe("server"); + }); + + test("electron dev: gets arg at index 2", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(getSubcommand(["electron", ".", "api", "--help"], env)).toBe("api"); + }); + + test("packaged: gets arg at index 1", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + expect(getSubcommand(["mux", "server", "-p", "3001"], env)).toBe("server"); + }); + + test("returns undefined when no subcommand", () => { + const env = detectCliEnvironment({}, undefined); + expect(getSubcommand(["bun", "script.ts"], env)).toBeUndefined(); + }); +}); + +describe("getArgsAfterSplice", () => { + // These tests simulate what happens AFTER index.ts splices out the subcommand name + // Original argv: ["electron", ".", "api", "--help"] + // After splice: ["electron", ".", "--help"] + + test("bun: returns args after firstArgIndex", () => { + const env = detectCliEnvironment({}, undefined); + // Simulates: bun script.ts api --help -> after splice -> bun script.ts --help + const argvAfterSplice = ["bun", "script.ts", "--help"]; + expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["--help"]); + }); + + test("electron dev: returns args after firstArgIndex", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + // Simulates: electron . api --help -> after splice -> electron . --help + const argvAfterSplice = ["electron", ".", "--help"]; + expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["--help"]); + }); + + test("packaged electron: returns args after firstArgIndex", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + // Simulates: ./mux api --help -> after splice -> ./mux --help + const argvAfterSplice = ["./mux", "--help"]; + expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["--help"]); + }); + + test("handles multiple args", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + // Simulates: electron . server -p 3001 --host 0.0.0.0 + // After splice: electron . -p 3001 --host 0.0.0.0 + const argvAfterSplice = ["electron", ".", "-p", "3001", "--host", "0.0.0.0"]; + expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["-p", "3001", "--host", "0.0.0.0"]); + }); + + test("returns empty array when no args after splice", () => { + const env = detectCliEnvironment({}, undefined); + // Simulates: bun script.ts server -> after splice -> bun script.ts + const argvAfterSplice = ["bun", "script.ts"]; + expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual([]); + }); +}); + +describe("isElectronLaunchArg", () => { + test("returns false for bun/node (not Electron)", () => { + const env = detectCliEnvironment({}, undefined); + expect(isElectronLaunchArg(".", env)).toBe(false); + expect(isElectronLaunchArg("--help", env)).toBe(false); + }); + + test("returns false for packaged Electron (flags are real CLI args)", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + expect(isElectronLaunchArg("--help", env)).toBe(false); + expect(isElectronLaunchArg(".", env)).toBe(false); + }); + + test("returns true for '.' in electron dev mode", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(isElectronLaunchArg(".", env)).toBe(true); + }); + + test("returns true for flags in electron dev mode", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(isElectronLaunchArg("--help", env)).toBe(true); + expect(isElectronLaunchArg("--inspect", env)).toBe(true); + expect(isElectronLaunchArg("-v", env)).toBe(true); + }); + + test("returns false for real subcommands in electron dev mode", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(isElectronLaunchArg("server", env)).toBe(false); + expect(isElectronLaunchArg("api", env)).toBe(false); + expect(isElectronLaunchArg("desktop", env)).toBe(false); + }); + + test("returns false for undefined subcommand", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(isElectronLaunchArg(undefined, env)).toBe(false); + }); +}); + +describe("isCommandAvailable", () => { + test("run is available in bun/node", () => { + const env = detectCliEnvironment({}, undefined); + expect(isCommandAvailable("run", env)).toBe(true); + }); + + test("run is NOT available in electron dev", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + expect(isCommandAvailable("run", env)).toBe(false); + }); + + test("run is NOT available in packaged electron", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + expect(isCommandAvailable("run", env)).toBe(false); + }); + + test("server is available everywhere", () => { + expect(isCommandAvailable("server", detectCliEnvironment({}, undefined))).toBe(true); + expect(isCommandAvailable("server", detectCliEnvironment({ electron: "33.0.0" }, true))).toBe( + true + ); + expect( + isCommandAvailable("server", detectCliEnvironment({ electron: "33.0.0" }, undefined)) + ).toBe(true); + }); + + test("api is available everywhere", () => { + expect(isCommandAvailable("api", detectCliEnvironment({}, undefined))).toBe(true); + expect(isCommandAvailable("api", detectCliEnvironment({ electron: "33.0.0" }, true))).toBe( + true + ); + expect(isCommandAvailable("api", detectCliEnvironment({ electron: "33.0.0" }, undefined))).toBe( + true + ); + }); +}); diff --git a/src/cli/argv.ts b/src/cli/argv.ts new file mode 100644 index 0000000000..6d6963465d --- /dev/null +++ b/src/cli/argv.ts @@ -0,0 +1,102 @@ +/** + * CLI environment detection for correct argv parsing across: + * - bun/node direct invocation + * - Electron dev mode (electron .) + * - Packaged Electron app (./mux.AppImage) + */ + +export interface CliEnvironment { + /** Running under Electron runtime */ + isElectron: boolean; + /** Running as packaged Electron app (not dev mode) */ + isPackagedElectron: boolean; + /** Index of first user argument in process.argv */ + firstArgIndex: number; +} + +/** + * Detect CLI environment from process state. + * + * | Environment | isElectron | defaultApp | firstArgIndex | + * |-------------------|------------|------------|---------------| + * | bun/node | false | undefined | 2 | + * | electron dev | true | true | 2 | + * | packaged electron | true | undefined | 1 | + */ +export function detectCliEnvironment( + versions: Record = process.versions, + defaultApp: boolean | undefined = process.defaultApp +): CliEnvironment { + const isElectron = "electron" in versions; + const isPackagedElectron = isElectron && !defaultApp; + const firstArgIndex = isPackagedElectron ? 1 : 2; + return { isElectron, isPackagedElectron, firstArgIndex }; +} + +/** + * Get Commander parse options for current environment. + * Use with: program.parse(process.argv, getParseOptions()) + */ +export function getParseOptions(env: CliEnvironment = detectCliEnvironment()): { + from: "electron" | "node"; +} { + return { from: env.isPackagedElectron ? "electron" : "node" }; +} + +/** + * Get the subcommand from argv (e.g., "server", "api", "run"). + */ +export function getSubcommand( + argv: string[] = process.argv, + env: CliEnvironment = detectCliEnvironment() +): string | undefined { + return argv[env.firstArgIndex]; +} + +/** + * Get args for a subcommand after the subcommand name has been spliced out. + * This is what subcommand handlers (server.ts, api.ts, run.ts) use after + * index.ts removes the subcommand name from process.argv. + * + * @example + * // Original: ["electron", ".", "api", "--help"] + * // After index.ts splices: ["electron", ".", "--help"] + * // getArgsAfterSplice returns: ["--help"] + */ +export function getArgsAfterSplice( + argv: string[] = process.argv, + env: CliEnvironment = detectCliEnvironment() +): string[] { + return argv.slice(env.firstArgIndex); +} + +/** + * Check if the subcommand is an Electron launch arg (not a real CLI command). + * In dev mode (electron --inspect .), argv may contain flags or "." before the subcommand. + * These should trigger desktop launch, not CLI processing. + */ +export function isElectronLaunchArg( + subcommand: string | undefined, + env: CliEnvironment = detectCliEnvironment() +): boolean { + if (env.isPackagedElectron || !env.isElectron) { + return false; + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional: false from startsWith should still check "." + return subcommand?.startsWith("-") || subcommand === "."; +} + +/** + * Check if a command is available in the current environment. + * The "run" command requires bun/node - it's not bundled in Electron. + */ +export function isCommandAvailable( + command: string, + env: CliEnvironment = detectCliEnvironment() +): boolean { + if (command === "run") { + // run.ts is only available in bun/node, not bundled in Electron (dev or packaged) + return !env.isElectron; + } + return true; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 201b280681..a321cd852e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,18 +8,30 @@ * fails when running CLI commands in non-GUI environments. Subcommands like * `run` and `server` import the AI SDK which has significant startup cost. * - * By checking argv[2] first, we only load the code path actually needed. + * By checking argv first, we only load the code path actually needed. * * ELECTRON DETECTION: * When run via `electron .` or as a packaged app, Electron sets process.versions.electron. * In that case, we launch the desktop app automatically. When run via `bun` or `node`, * we show CLI help instead. + * + * ARGV OFFSET: + * In development (`electron .`), argv = [electron, ".", ...args] so first arg is at index 2. + * In packaged apps (`./mux.AppImage`), argv = [app, ...args] so first arg is at index 1. + * process.defaultApp is true in dev mode and undefined in packaged apps. */ import { Command } from "commander"; import { VERSION } from "../version"; +import { + detectCliEnvironment, + getParseOptions, + getSubcommand, + isCommandAvailable, + isElectronLaunchArg, +} from "./argv"; -const subcommand = process.argv[2]; -const isElectron = "electron" in process.versions; +const env = detectCliEnvironment(); +const subcommand = getSubcommand(process.argv, env); function launchDesktop(): void { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -27,19 +39,22 @@ function launchDesktop(): void { } // Route known subcommands to their dedicated entry points (each has its own Commander instance) -// When Electron launches us (e.g., `bunx electron --flags .`), argv[2] may be a flag or "." - not a subcommand -const isElectronLaunchArg = subcommand?.startsWith("-") || subcommand === "."; if (subcommand === "run") { - process.argv.splice(2, 1); // Remove "run" since run.ts defines .name("mux run") + if (!isCommandAvailable("run", env)) { + console.error("The 'run' command is only available via the CLI (bun mux run)."); + console.error("It is not bundled in Electron."); + process.exit(1); + } + process.argv.splice(env.firstArgIndex, 1); // Remove "run" since run.ts defines .name("mux run") // eslint-disable-next-line @typescript-eslint/no-require-imports require("./run"); } else if (subcommand === "server") { - process.argv.splice(2, 1); + process.argv.splice(env.firstArgIndex, 1); // eslint-disable-next-line @typescript-eslint/no-require-imports require("./server"); } else if (subcommand === "api") { - process.argv.splice(2, 1); + process.argv.splice(env.firstArgIndex, 1); // Must use native import() to load ESM module - trpc-cli requires ESM with top-level await. // Using Function constructor prevents TypeScript from converting this to require(). // The .mjs extension is critical for Node.js to treat it as ESM. @@ -47,7 +62,7 @@ if (subcommand === "run") { void new Function("return import('./api.mjs')")(); } else if ( subcommand === "desktop" || - (isElectron && (subcommand === undefined || isElectronLaunchArg)) + (env.isElectron && (subcommand === undefined || isElectronLaunchArg(subcommand, env))) ) { // Explicit `mux desktop`, or Electron runtime with no subcommand / Electron launch args launchDesktop(); @@ -70,10 +85,17 @@ if (subcommand === "run") { .version(`${gitDescribe} (${gitCommit})`, "-v, --version"); // Register subcommand stubs for help display (actual implementations are above) - program.command("run").description("Run a one-off agent task"); + // `run` is only available via bun/node CLI, not bundled in Electron + if (isCommandAvailable("run", env)) { + program.command("run").description("Run a one-off agent task"); + } program.command("server").description("Start the HTTP/WebSocket ORPC server"); program.command("api").description("Interact with the mux API via a running server"); - program.command("desktop").description("Launch the desktop app (requires Electron)"); + program + .command("desktop") + .description( + env.isElectron ? "Launch the desktop app" : "Launch the desktop app (requires Electron)" + ); - program.parse(); + program.parse(process.argv, getParseOptions(env)); } diff --git a/src/cli/run.ts b/src/cli/run.ts index f5ef06c943..b31ca01bf9 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -43,6 +43,7 @@ import { parseRuntimeModeAndHost, RUNTIME_MODE } from "@/common/types/runtime"; import assert from "@/common/utils/assert"; import parseDuration from "parse-duration"; import { log, type LogLevel } from "@/node/services/log"; +import { getParseOptions } from "./argv"; type CLIMode = "plan" | "exec"; @@ -212,7 +213,7 @@ Examples: ` ); -program.parse(process.argv); +program.parse(process.argv, getParseOptions()); interface CLIOptions { dir: string; diff --git a/src/cli/server.ts b/src/cli/server.ts index 3651520cde..3040a37aba 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -11,6 +11,7 @@ import { Command } from "commander"; import { validateProjectPath } from "@/node/utils/pathUtils"; import { createOrpcServer } from "@/node/orpc/server"; import type { ORPCContext } from "@/node/orpc/context"; +import { getParseOptions } from "./argv"; const program = new Command(); program @@ -21,7 +22,7 @@ program .option("--auth-token ", "optional bearer token for HTTP/WS auth") .option("--ssh-host ", "SSH hostname/alias for editor deep links (e.g., devbox)") .option("--add-project ", "add and open project at the specified path (idempotent)") - .parse(process.argv); + .parse(process.argv, getParseOptions()); const options = program.opts(); const HOST = options.host as string;