diff --git a/src/browser/api.ts b/src/browser/api.ts index 9f1cc2c8a..d3460b3ca 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -260,6 +260,9 @@ const webApi: IPCApi = { return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); }, }, + server: { + getLaunchProject: () => invokeIPC("server:getLaunchProject"), + }, }; if (typeof window.api === "undefined") { diff --git a/src/components/AppLoader.tsx b/src/components/AppLoader.tsx index 739eee2c7..c6b4ed5ed 100644 --- a/src/components/AppLoader.tsx +++ b/src/components/AppLoader.tsx @@ -96,6 +96,51 @@ export function AppLoader() { setSelectedWorkspace, ]); + // Check for launch project from server (for --add-project flag) + // This only applies in server mode + useEffect(() => { + // Wait until stores are synced and hash restoration is complete + if (!storesSynced || !hasRestoredFromHash) return; + + // Skip if we already have a selected workspace (from localStorage or URL hash) + if (selectedWorkspace) return; + + // Only check once + const checkLaunchProject = async () => { + // Only available in server mode + if (!window.api.server?.getLaunchProject) return; + + const launchProjectPath = await window.api.server.getLaunchProject(); + if (!launchProjectPath) return; + + // Find first workspace in this project + const projectWorkspaces = Array.from(workspaceManagement.workspaceMetadata.values()).filter( + (meta) => meta.projectPath === launchProjectPath + ); + + if (projectWorkspaces.length > 0) { + // Select the first workspace in the project + const metadata = projectWorkspaces[0]; + setSelectedWorkspace({ + workspaceId: metadata.id, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + namedWorkspacePath: metadata.namedWorkspacePath, + }); + } + // If no workspaces exist yet, just leave the project in the sidebar + // The user will need to create a workspace + }; + + void checkLaunchProject(); + }, [ + storesSynced, + hasRestoredFromHash, + selectedWorkspace, + workspaceManagement.workspaceMetadata, + setSelectedWorkspace, + ]); + // Show loading screen until stores are synced if (workspaceManagement.loading || !storesSynced) { return ; diff --git a/src/main-server.ts b/src/main-server.ts index 3be56ecef..01b54e18f 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -13,6 +13,7 @@ import * as path from "path"; import type { RawData } from "ws"; import { WebSocket, WebSocketServer } from "ws"; import { Command } from "commander"; +import { validateProjectPath } from "./utils/pathUtils"; // Parse command line arguments const program = new Command(); @@ -22,11 +23,16 @@ program .description("HTTP/WebSocket server for cmux - allows accessing cmux backend from mobile devices") .option("-h, --host ", "bind to specific host", "localhost") .option("-p, --port ", "bind to specific port", "3000") + .option("--add-project ", "add and open project at the specified path (idempotent)") .parse(process.argv); const options = program.opts(); const HOST = options.host as string; const PORT = parseInt(options.port as string, 10); +const ADD_PROJECT_PATH = options.addProject as string | undefined; + +// Track the launch project path for initial navigation +let launchProjectPath: string | null = null; // Mock Electron's ipcMain for HTTP class HttpIpcMainAdapter { @@ -35,6 +41,13 @@ class HttpIpcMainAdapter { constructor(private readonly app: express.Application) {} + // Public method to get a handler (for internal use) + getHandler( + channel: string + ): ((event: unknown, ...args: unknown[]) => Promise) | undefined { + return this.handlers.get(channel); + } + handle(channel: string, handler: (event: unknown, ...args: unknown[]) => Promise): void { this.handlers.set(channel, handler); @@ -138,6 +151,11 @@ ipcMainService.register( mockWindow as unknown as BrowserWindow ); +// Add custom endpoint for launch project (only for server mode) +httpIpcMain.handle("server:getLaunchProject", () => { + return Promise.resolve(launchProjectPath); +}); + // Serve static files from dist directory (built renderer) app.use(express.static(path.join(__dirname, "."))); @@ -247,6 +265,85 @@ wss.on("connection", (ws) => { }); }); +/** + * Initialize a project from the --add-project flag + * This checks if a project exists at the given path, creates it if not, and opens it + */ +async function initializeProject( + projectPath: string, + ipcAdapter: HttpIpcMainAdapter +): Promise { + try { + // Trim trailing slashes to ensure proper project name extraction + projectPath = projectPath.replace(/\/+$/, ""); + + // Normalize path (expand tilde, make absolute) to match how PROJECT_CREATE normalizes paths + const validation = await validateProjectPath(projectPath); + if (!validation.valid) { + const errorMsg = validation.error ?? "Unknown validation error"; + console.error(`Invalid project path: ${errorMsg}`); + return; + } + projectPath = validation.expandedPath!; + + // First, check if project already exists by listing all projects + const handler = ipcAdapter.getHandler(IPC_CHANNELS.PROJECT_LIST); + if (!handler) { + console.error("PROJECT_LIST handler not found"); + return; + } + + const projectsList = await handler(null); + if (!Array.isArray(projectsList)) { + console.error("Unexpected PROJECT_LIST response format"); + return; + } + + // Check if the project already exists (projectsList is Array<[string, ProjectConfig]>) + const existingProject = (projectsList as Array<[string, unknown]>).find( + ([path]) => path === projectPath + ); + + if (existingProject) { + console.log(`Project already exists at: ${projectPath}`); + launchProjectPath = projectPath; + return; + } + + // Project doesn't exist, create it + console.log(`Creating new project at: ${projectPath}`); + const createHandler = ipcAdapter.getHandler(IPC_CHANNELS.PROJECT_CREATE); + if (!createHandler) { + console.error("PROJECT_CREATE handler not found"); + return; + } + + const createResult = await createHandler(null, projectPath); + + // Check if creation was successful using the Result type + if (createResult && typeof createResult === "object" && "success" in createResult) { + if (createResult.success) { + console.log(`Successfully created project at: ${projectPath}`); + launchProjectPath = projectPath; + } else if ("error" in createResult) { + const err = createResult as { error: unknown }; + const errorMsg = err.error instanceof Error ? err.error.message : String(err.error); + console.error(`Failed to create project: ${errorMsg}`); + } + } else { + console.error("Unexpected PROJECT_CREATE response format"); + } + } catch (error) { + console.error(`Error initializing project:`, error); + } +} + server.listen(PORT, HOST, () => { console.log(`Server is running on http://${HOST}:${PORT}`); + + // Handle --add-project flag if present + if (ADD_PROJECT_PATH) { + console.log(`Initializing project at: ${ADD_PROJECT_PATH}`); + void initializeProject(ADD_PROJECT_PATH, httpIpcMain); + } }); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 4a0a46c77..81b10627f 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -299,6 +299,9 @@ export interface IPCApi { install(): void; onStatus(callback: (status: UpdateStatus) => void): () => void; }; + server?: { + getLaunchProject(): Promise; + }; } // Update status type (matches updater service)