Skip to content

Commit e389455

Browse files
🤖 fix: AppImage CLI argument handling (#1161)
## Summary Fix CLI subcommands (`server`, `api`, `run`) not working when invoked from packaged AppImage. ## Problem When running `./mux.AppImage server` or `./mux.AppImage --help`, the commands weren't recognized and the app would either launch the desktop GUI or fail. **Root cause**: Incorrect argv offset detection across different execution contexts: | Environment | `isElectron` | `process.defaultApp` | First arg index | |-------------|--------------|----------------------|-----------------| | `bun mux` | false | undefined | 2 | | `electron .` | true | true | 2 | | `./mux.AppImage` | true | undefined | **1** | The original code used `process.argv[2]` unconditionally, which broke packaged apps. ## Solution Created `src/cli/argv.ts` with testable pure functions: - `detectCliEnvironment()` - Returns `{ isElectron, isPackagedElectron, firstArgIndex }` - `getParseOptions()` - Returns Commander parse options (`{ from: "electron" | "node" }`) - `getSubcommand()` - Gets subcommand at correct argv index - `getArgsAfterSplice()` - Gets remaining args for subcommand handlers - `isCommandAvailable()` - Checks if a command is available in current environment Key insight: Use `isPackagedElectron = isElectron && !process.defaultApp` to correctly identify packaged apps. ## Manual Test Results ### Bun/Node CLI | Command | Result | |---------|--------| | `bun src/cli/index.ts --help` | ✅ Shows help WITH "run" command | | `bun src/cli/index.ts server --help` | ✅ Shows server options | | `bun src/cli/index.ts run --help` | ✅ Shows run options | | `bun dist/cli/index.js api --help` | ✅ Shows api commands | ### Electron Dev Mode | Command | Result | |---------|--------| | `bunx electron . server --help` | ✅ Shows server options | | `bunx electron . api --help` | ✅ Shows api commands | | `bunx electron . run` | ✅ Friendly error (not crash) | | `bunx electron .` | ✅ Launches desktop | ### Packaged AppImage | Command | Result | |---------|--------| | `./release/mux-*-x86_64.AppImage --help` | ✅ Shows help WITHOUT "run" | | `./release/mux-*-x86_64.AppImage server --help` | ✅ Shows server options | | `./release/mux-*-x86_64.AppImage api --help` | ✅ Shows api commands | | `./release/mux-*-x86_64.AppImage run` | ✅ Friendly error | | `./release/mux-*-x86_64.AppImage` | ✅ Launches desktop | --- _Generated with `mux` • Model: `anthropic:claude-sonnet-4-20250514` • Thinking: `low`_
1 parent b220762 commit e389455

File tree

6 files changed

+358
-19
lines changed

6 files changed

+358
-19
lines changed

src/cli/api.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { router } from "@/node/orpc/router";
1515
import { proxifyOrpc } from "./proxifyOrpc";
1616
import { ServerLockfile } from "@/node/services/serverLockfile";
1717
import { getMuxHome } from "@/common/constants/paths";
18-
import type { Command } from "commander";
18+
import { getArgsAfterSplice } from "./argv";
19+
20+
// index.ts already splices "api" from argv before importing this module,
21+
// so we just need to get the remaining args after the splice point.
22+
const args = getArgsAfterSplice();
1923

2024
interface ServerDiscovery {
2125
baseUrl: string;
@@ -57,9 +61,24 @@ async function discoverServer(): Promise<ServerDiscovery> {
5761
const { baseUrl, authToken } = await discoverServer();
5862

5963
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
60-
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
6164

62-
cli.name("mux api");
63-
cli.description("Interact with the mux API via a running server");
64-
cli.parse();
65+
// Use trpc-cli's run() method instead of buildProgram().parse()
66+
// run() sets exitOverride on root, uses parseAsync, and handles process exit properly
67+
const { run } = createCli({
68+
router: proxiedRouter,
69+
name: "mux api",
70+
description: "Interact with the mux API via a running server",
71+
});
72+
73+
try {
74+
await run({ argv: args });
75+
} catch (error) {
76+
// trpc-cli throws FailedToExitError after calling process.exit()
77+
// In Electron, process.exit() doesn't immediately terminate, so the error surfaces.
78+
// This is expected and safe to ignore since exit was already requested.
79+
if (error instanceof Error && error.constructor.name === "FailedToExitError") {
80+
return;
81+
}
82+
throw error;
83+
}
6584
})();

src/cli/argv.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
detectCliEnvironment,
4+
getParseOptions,
5+
getSubcommand,
6+
getArgsAfterSplice,
7+
isCommandAvailable,
8+
isElectronLaunchArg,
9+
} from "./argv";
10+
11+
describe("detectCliEnvironment", () => {
12+
test("bun/node: firstArgIndex=2", () => {
13+
const env = detectCliEnvironment({}, undefined);
14+
expect(env).toEqual({
15+
isElectron: false,
16+
isPackagedElectron: false,
17+
firstArgIndex: 2,
18+
});
19+
});
20+
21+
test("electron dev: firstArgIndex=2", () => {
22+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
23+
expect(env).toEqual({
24+
isElectron: true,
25+
isPackagedElectron: false,
26+
firstArgIndex: 2,
27+
});
28+
});
29+
30+
test("packaged electron: firstArgIndex=1", () => {
31+
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
32+
expect(env).toEqual({
33+
isElectron: true,
34+
isPackagedElectron: true,
35+
firstArgIndex: 1,
36+
});
37+
});
38+
});
39+
40+
describe("getParseOptions", () => {
41+
test("returns node for bun/node", () => {
42+
const env = detectCliEnvironment({}, undefined);
43+
expect(getParseOptions(env)).toEqual({ from: "node" });
44+
});
45+
46+
test("returns node for electron dev", () => {
47+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
48+
expect(getParseOptions(env)).toEqual({ from: "node" });
49+
});
50+
51+
test("returns electron for packaged", () => {
52+
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
53+
expect(getParseOptions(env)).toEqual({ from: "electron" });
54+
});
55+
});
56+
57+
describe("getSubcommand", () => {
58+
test("bun: gets arg at index 2", () => {
59+
const env = detectCliEnvironment({}, undefined);
60+
expect(getSubcommand(["bun", "script.ts", "server", "--help"], env)).toBe("server");
61+
});
62+
63+
test("electron dev: gets arg at index 2", () => {
64+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
65+
expect(getSubcommand(["electron", ".", "api", "--help"], env)).toBe("api");
66+
});
67+
68+
test("packaged: gets arg at index 1", () => {
69+
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
70+
expect(getSubcommand(["mux", "server", "-p", "3001"], env)).toBe("server");
71+
});
72+
73+
test("returns undefined when no subcommand", () => {
74+
const env = detectCliEnvironment({}, undefined);
75+
expect(getSubcommand(["bun", "script.ts"], env)).toBeUndefined();
76+
});
77+
});
78+
79+
describe("getArgsAfterSplice", () => {
80+
// These tests simulate what happens AFTER index.ts splices out the subcommand name
81+
// Original argv: ["electron", ".", "api", "--help"]
82+
// After splice: ["electron", ".", "--help"]
83+
84+
test("bun: returns args after firstArgIndex", () => {
85+
const env = detectCliEnvironment({}, undefined);
86+
// Simulates: bun script.ts api --help -> after splice -> bun script.ts --help
87+
const argvAfterSplice = ["bun", "script.ts", "--help"];
88+
expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["--help"]);
89+
});
90+
91+
test("electron dev: returns args after firstArgIndex", () => {
92+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
93+
// Simulates: electron . api --help -> after splice -> electron . --help
94+
const argvAfterSplice = ["electron", ".", "--help"];
95+
expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["--help"]);
96+
});
97+
98+
test("packaged electron: returns args after firstArgIndex", () => {
99+
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
100+
// Simulates: ./mux api --help -> after splice -> ./mux --help
101+
const argvAfterSplice = ["./mux", "--help"];
102+
expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["--help"]);
103+
});
104+
105+
test("handles multiple args", () => {
106+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
107+
// Simulates: electron . server -p 3001 --host 0.0.0.0
108+
// After splice: electron . -p 3001 --host 0.0.0.0
109+
const argvAfterSplice = ["electron", ".", "-p", "3001", "--host", "0.0.0.0"];
110+
expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual(["-p", "3001", "--host", "0.0.0.0"]);
111+
});
112+
113+
test("returns empty array when no args after splice", () => {
114+
const env = detectCliEnvironment({}, undefined);
115+
// Simulates: bun script.ts server -> after splice -> bun script.ts
116+
const argvAfterSplice = ["bun", "script.ts"];
117+
expect(getArgsAfterSplice(argvAfterSplice, env)).toEqual([]);
118+
});
119+
});
120+
121+
describe("isElectronLaunchArg", () => {
122+
test("returns false for bun/node (not Electron)", () => {
123+
const env = detectCliEnvironment({}, undefined);
124+
expect(isElectronLaunchArg(".", env)).toBe(false);
125+
expect(isElectronLaunchArg("--help", env)).toBe(false);
126+
});
127+
128+
test("returns false for packaged Electron (flags are real CLI args)", () => {
129+
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
130+
expect(isElectronLaunchArg("--help", env)).toBe(false);
131+
expect(isElectronLaunchArg(".", env)).toBe(false);
132+
});
133+
134+
test("returns true for '.' in electron dev mode", () => {
135+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
136+
expect(isElectronLaunchArg(".", env)).toBe(true);
137+
});
138+
139+
test("returns true for flags in electron dev mode", () => {
140+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
141+
expect(isElectronLaunchArg("--help", env)).toBe(true);
142+
expect(isElectronLaunchArg("--inspect", env)).toBe(true);
143+
expect(isElectronLaunchArg("-v", env)).toBe(true);
144+
});
145+
146+
test("returns false for real subcommands in electron dev mode", () => {
147+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
148+
expect(isElectronLaunchArg("server", env)).toBe(false);
149+
expect(isElectronLaunchArg("api", env)).toBe(false);
150+
expect(isElectronLaunchArg("desktop", env)).toBe(false);
151+
});
152+
153+
test("returns false for undefined subcommand", () => {
154+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
155+
expect(isElectronLaunchArg(undefined, env)).toBe(false);
156+
});
157+
});
158+
159+
describe("isCommandAvailable", () => {
160+
test("run is available in bun/node", () => {
161+
const env = detectCliEnvironment({}, undefined);
162+
expect(isCommandAvailable("run", env)).toBe(true);
163+
});
164+
165+
test("run is NOT available in electron dev", () => {
166+
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
167+
expect(isCommandAvailable("run", env)).toBe(false);
168+
});
169+
170+
test("run is NOT available in packaged electron", () => {
171+
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
172+
expect(isCommandAvailable("run", env)).toBe(false);
173+
});
174+
175+
test("server is available everywhere", () => {
176+
expect(isCommandAvailable("server", detectCliEnvironment({}, undefined))).toBe(true);
177+
expect(isCommandAvailable("server", detectCliEnvironment({ electron: "33.0.0" }, true))).toBe(
178+
true
179+
);
180+
expect(
181+
isCommandAvailable("server", detectCliEnvironment({ electron: "33.0.0" }, undefined))
182+
).toBe(true);
183+
});
184+
185+
test("api is available everywhere", () => {
186+
expect(isCommandAvailable("api", detectCliEnvironment({}, undefined))).toBe(true);
187+
expect(isCommandAvailable("api", detectCliEnvironment({ electron: "33.0.0" }, true))).toBe(
188+
true
189+
);
190+
expect(isCommandAvailable("api", detectCliEnvironment({ electron: "33.0.0" }, undefined))).toBe(
191+
true
192+
);
193+
});
194+
});

src/cli/argv.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* CLI environment detection for correct argv parsing across:
3+
* - bun/node direct invocation
4+
* - Electron dev mode (electron .)
5+
* - Packaged Electron app (./mux.AppImage)
6+
*/
7+
8+
export interface CliEnvironment {
9+
/** Running under Electron runtime */
10+
isElectron: boolean;
11+
/** Running as packaged Electron app (not dev mode) */
12+
isPackagedElectron: boolean;
13+
/** Index of first user argument in process.argv */
14+
firstArgIndex: number;
15+
}
16+
17+
/**
18+
* Detect CLI environment from process state.
19+
*
20+
* | Environment | isElectron | defaultApp | firstArgIndex |
21+
* |-------------------|------------|------------|---------------|
22+
* | bun/node | false | undefined | 2 |
23+
* | electron dev | true | true | 2 |
24+
* | packaged electron | true | undefined | 1 |
25+
*/
26+
export function detectCliEnvironment(
27+
versions: Record<string, string | undefined> = process.versions,
28+
defaultApp: boolean | undefined = process.defaultApp
29+
): CliEnvironment {
30+
const isElectron = "electron" in versions;
31+
const isPackagedElectron = isElectron && !defaultApp;
32+
const firstArgIndex = isPackagedElectron ? 1 : 2;
33+
return { isElectron, isPackagedElectron, firstArgIndex };
34+
}
35+
36+
/**
37+
* Get Commander parse options for current environment.
38+
* Use with: program.parse(process.argv, getParseOptions())
39+
*/
40+
export function getParseOptions(env: CliEnvironment = detectCliEnvironment()): {
41+
from: "electron" | "node";
42+
} {
43+
return { from: env.isPackagedElectron ? "electron" : "node" };
44+
}
45+
46+
/**
47+
* Get the subcommand from argv (e.g., "server", "api", "run").
48+
*/
49+
export function getSubcommand(
50+
argv: string[] = process.argv,
51+
env: CliEnvironment = detectCliEnvironment()
52+
): string | undefined {
53+
return argv[env.firstArgIndex];
54+
}
55+
56+
/**
57+
* Get args for a subcommand after the subcommand name has been spliced out.
58+
* This is what subcommand handlers (server.ts, api.ts, run.ts) use after
59+
* index.ts removes the subcommand name from process.argv.
60+
*
61+
* @example
62+
* // Original: ["electron", ".", "api", "--help"]
63+
* // After index.ts splices: ["electron", ".", "--help"]
64+
* // getArgsAfterSplice returns: ["--help"]
65+
*/
66+
export function getArgsAfterSplice(
67+
argv: string[] = process.argv,
68+
env: CliEnvironment = detectCliEnvironment()
69+
): string[] {
70+
return argv.slice(env.firstArgIndex);
71+
}
72+
73+
/**
74+
* Check if the subcommand is an Electron launch arg (not a real CLI command).
75+
* In dev mode (electron --inspect .), argv may contain flags or "." before the subcommand.
76+
* These should trigger desktop launch, not CLI processing.
77+
*/
78+
export function isElectronLaunchArg(
79+
subcommand: string | undefined,
80+
env: CliEnvironment = detectCliEnvironment()
81+
): boolean {
82+
if (env.isPackagedElectron || !env.isElectron) {
83+
return false;
84+
}
85+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional: false from startsWith should still check "."
86+
return subcommand?.startsWith("-") || subcommand === ".";
87+
}
88+
89+
/**
90+
* Check if a command is available in the current environment.
91+
* The "run" command requires bun/node - it's not bundled in Electron.
92+
*/
93+
export function isCommandAvailable(
94+
command: string,
95+
env: CliEnvironment = detectCliEnvironment()
96+
): boolean {
97+
if (command === "run") {
98+
// run.ts is only available in bun/node, not bundled in Electron (dev or packaged)
99+
return !env.isElectron;
100+
}
101+
return true;
102+
}

0 commit comments

Comments
 (0)