Skip to content

Commit 97f490b

Browse files
authored
🤖 feat: add --add-project flag to cmux server (#551)
Adds idempotent `--add-project <path>` flag to server mode. If project exists at path, opens it. If not, creates it then opens it. Frontend auto-navigates to first workspace in the project if available. _Generated with `cmux`_
1 parent 9940c26 commit 97f490b

File tree

4 files changed

+148
-0
lines changed

4 files changed

+148
-0
lines changed

src/browser/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ const webApi: IPCApi = {
260260
return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void);
261261
},
262262
},
263+
server: {
264+
getLaunchProject: () => invokeIPC("server:getLaunchProject"),
265+
},
263266
};
264267

265268
if (typeof window.api === "undefined") {

src/components/AppLoader.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,51 @@ export function AppLoader() {
9696
setSelectedWorkspace,
9797
]);
9898

99+
// Check for launch project from server (for --add-project flag)
100+
// This only applies in server mode
101+
useEffect(() => {
102+
// Wait until stores are synced and hash restoration is complete
103+
if (!storesSynced || !hasRestoredFromHash) return;
104+
105+
// Skip if we already have a selected workspace (from localStorage or URL hash)
106+
if (selectedWorkspace) return;
107+
108+
// Only check once
109+
const checkLaunchProject = async () => {
110+
// Only available in server mode
111+
if (!window.api.server?.getLaunchProject) return;
112+
113+
const launchProjectPath = await window.api.server.getLaunchProject();
114+
if (!launchProjectPath) return;
115+
116+
// Find first workspace in this project
117+
const projectWorkspaces = Array.from(workspaceManagement.workspaceMetadata.values()).filter(
118+
(meta) => meta.projectPath === launchProjectPath
119+
);
120+
121+
if (projectWorkspaces.length > 0) {
122+
// Select the first workspace in the project
123+
const metadata = projectWorkspaces[0];
124+
setSelectedWorkspace({
125+
workspaceId: metadata.id,
126+
projectPath: metadata.projectPath,
127+
projectName: metadata.projectName,
128+
namedWorkspacePath: metadata.namedWorkspacePath,
129+
});
130+
}
131+
// If no workspaces exist yet, just leave the project in the sidebar
132+
// The user will need to create a workspace
133+
};
134+
135+
void checkLaunchProject();
136+
}, [
137+
storesSynced,
138+
hasRestoredFromHash,
139+
selectedWorkspace,
140+
workspaceManagement.workspaceMetadata,
141+
setSelectedWorkspace,
142+
]);
143+
99144
// Show loading screen until stores are synced
100145
if (workspaceManagement.loading || !storesSynced) {
101146
return <LoadingScreen />;

src/main-server.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as path from "path";
1313
import type { RawData } from "ws";
1414
import { WebSocket, WebSocketServer } from "ws";
1515
import { Command } from "commander";
16+
import { validateProjectPath } from "./utils/pathUtils";
1617

1718
// Parse command line arguments
1819
const program = new Command();
@@ -22,11 +23,16 @@ program
2223
.description("HTTP/WebSocket server for cmux - allows accessing cmux backend from mobile devices")
2324
.option("-h, --host <host>", "bind to specific host", "localhost")
2425
.option("-p, --port <port>", "bind to specific port", "3000")
26+
.option("--add-project <path>", "add and open project at the specified path (idempotent)")
2527
.parse(process.argv);
2628

2729
const options = program.opts();
2830
const HOST = options.host as string;
2931
const PORT = parseInt(options.port as string, 10);
32+
const ADD_PROJECT_PATH = options.addProject as string | undefined;
33+
34+
// Track the launch project path for initial navigation
35+
let launchProjectPath: string | null = null;
3036

3137
// Mock Electron's ipcMain for HTTP
3238
class HttpIpcMainAdapter {
@@ -35,6 +41,13 @@ class HttpIpcMainAdapter {
3541

3642
constructor(private readonly app: express.Application) {}
3743

44+
// Public method to get a handler (for internal use)
45+
getHandler(
46+
channel: string
47+
): ((event: unknown, ...args: unknown[]) => Promise<unknown>) | undefined {
48+
return this.handlers.get(channel);
49+
}
50+
3851
handle(channel: string, handler: (event: unknown, ...args: unknown[]) => Promise<unknown>): void {
3952
this.handlers.set(channel, handler);
4053

@@ -138,6 +151,11 @@ ipcMainService.register(
138151
mockWindow as unknown as BrowserWindow
139152
);
140153

154+
// Add custom endpoint for launch project (only for server mode)
155+
httpIpcMain.handle("server:getLaunchProject", () => {
156+
return Promise.resolve(launchProjectPath);
157+
});
158+
141159
// Serve static files from dist directory (built renderer)
142160
app.use(express.static(path.join(__dirname, ".")));
143161

@@ -247,6 +265,85 @@ wss.on("connection", (ws) => {
247265
});
248266
});
249267

268+
/**
269+
* Initialize a project from the --add-project flag
270+
* This checks if a project exists at the given path, creates it if not, and opens it
271+
*/
272+
async function initializeProject(
273+
projectPath: string,
274+
ipcAdapter: HttpIpcMainAdapter
275+
): Promise<void> {
276+
try {
277+
// Trim trailing slashes to ensure proper project name extraction
278+
projectPath = projectPath.replace(/\/+$/, "");
279+
280+
// Normalize path (expand tilde, make absolute) to match how PROJECT_CREATE normalizes paths
281+
const validation = await validateProjectPath(projectPath);
282+
if (!validation.valid) {
283+
const errorMsg = validation.error ?? "Unknown validation error";
284+
console.error(`Invalid project path: ${errorMsg}`);
285+
return;
286+
}
287+
projectPath = validation.expandedPath!;
288+
289+
// First, check if project already exists by listing all projects
290+
const handler = ipcAdapter.getHandler(IPC_CHANNELS.PROJECT_LIST);
291+
if (!handler) {
292+
console.error("PROJECT_LIST handler not found");
293+
return;
294+
}
295+
296+
const projectsList = await handler(null);
297+
if (!Array.isArray(projectsList)) {
298+
console.error("Unexpected PROJECT_LIST response format");
299+
return;
300+
}
301+
302+
// Check if the project already exists (projectsList is Array<[string, ProjectConfig]>)
303+
const existingProject = (projectsList as Array<[string, unknown]>).find(
304+
([path]) => path === projectPath
305+
);
306+
307+
if (existingProject) {
308+
console.log(`Project already exists at: ${projectPath}`);
309+
launchProjectPath = projectPath;
310+
return;
311+
}
312+
313+
// Project doesn't exist, create it
314+
console.log(`Creating new project at: ${projectPath}`);
315+
const createHandler = ipcAdapter.getHandler(IPC_CHANNELS.PROJECT_CREATE);
316+
if (!createHandler) {
317+
console.error("PROJECT_CREATE handler not found");
318+
return;
319+
}
320+
321+
const createResult = await createHandler(null, projectPath);
322+
323+
// Check if creation was successful using the Result type
324+
if (createResult && typeof createResult === "object" && "success" in createResult) {
325+
if (createResult.success) {
326+
console.log(`Successfully created project at: ${projectPath}`);
327+
launchProjectPath = projectPath;
328+
} else if ("error" in createResult) {
329+
const err = createResult as { error: unknown };
330+
const errorMsg = err.error instanceof Error ? err.error.message : String(err.error);
331+
console.error(`Failed to create project: ${errorMsg}`);
332+
}
333+
} else {
334+
console.error("Unexpected PROJECT_CREATE response format");
335+
}
336+
} catch (error) {
337+
console.error(`Error initializing project:`, error);
338+
}
339+
}
340+
250341
server.listen(PORT, HOST, () => {
251342
console.log(`Server is running on http://${HOST}:${PORT}`);
343+
344+
// Handle --add-project flag if present
345+
if (ADD_PROJECT_PATH) {
346+
console.log(`Initializing project at: ${ADD_PROJECT_PATH}`);
347+
void initializeProject(ADD_PROJECT_PATH, httpIpcMain);
348+
}
252349
});

src/types/ipc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ export interface IPCApi {
299299
install(): void;
300300
onStatus(callback: (status: UpdateStatus) => void): () => void;
301301
};
302+
server?: {
303+
getLaunchProject(): Promise<string | null>;
304+
};
302305
}
303306

304307
// Update status type (matches updater service)

0 commit comments

Comments
 (0)