Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,9 +61,24 @@ async function discoverServer(): Promise<ServerDiscovery> {
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;
}
})();
194 changes: 194 additions & 0 deletions src/cli/argv.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
102 changes: 102 additions & 0 deletions src/cli/argv.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = 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;
}
Loading