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
3 changes: 3 additions & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
45 changes: 45 additions & 0 deletions src/components/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <LoadingScreen />;
Expand Down
97 changes: 97 additions & 0 deletions src/main-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -22,11 +23,16 @@ program
.description("HTTP/WebSocket server for cmux - allows accessing cmux backend from mobile devices")
.option("-h, --host <host>", "bind to specific host", "localhost")
.option("-p, --port <port>", "bind to specific port", "3000")
.option("--add-project <path>", "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 {
Expand All @@ -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<unknown>) | undefined {
return this.handlers.get(channel);
}

handle(channel: string, handler: (event: unknown, ...args: unknown[]) => Promise<unknown>): void {
this.handlers.set(channel, handler);

Expand Down Expand Up @@ -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, ".")));

Expand Down Expand Up @@ -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<void> {
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);
}
});
3 changes: 3 additions & 0 deletions src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ export interface IPCApi {
install(): void;
onStatus(callback: (status: UpdateStatus) => void): () => void;
};
server?: {
getLaunchProject(): Promise<string | null>;
};
}

// Update status type (matches updater service)
Expand Down