Skip to content

Commit b33ab51

Browse files
hugocasaclaude
andcommitted
feat: serve from any dir + project-scope server-backed CLI commands
Two gaps surfaced while testing the multi-project flow: - `webmux serve` no longer requires a `.webmux.yaml` in the cwd. It serves every known project (from ~/.webmux/projects.json) on one port and auto-adds the cwd repo when it is a webmux project; a fresh dir just shows the empty state. - Server-backed CLI commands (`send`, `tab`, `linear`, `oneshot`) now resolve the cwd's project and target `/<prefix>/api/...` instead of the bare, now-nonexistent unprefixed routes. Added `resolveProjectRoot` / `resolveProjectBaseUrl` in shared.ts (matches the server's `projectRoot`, looks the project up via /api/projects). Falls back to the unscoped base when the root can't be determined; clear error when the repo isn't a served project. oneshot also prefixes its agents WebSocket URL. Verified: `send` to a missing branch returns a clean "Worktree not found" (i.e. it reached the prefixed endpoint); `webmux serve` from /tmp no longer errors on a missing config. Suite green: backend 454, frontend 114, bin+contract 185. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0c71dc1 commit b33ab51

5 files changed

Lines changed: 69 additions & 16 deletions

File tree

bin/src/linear-commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createApi, parseLinearTarget, type PostWorktreeToLinearTarget } from "@webmux/api-contract";
2-
import { CommandUsageError, formatServerError } from "./shared";
2+
import { CommandUsageError, formatServerError, resolveProjectBaseUrl } from "./shared";
33

44
export interface ParsedLinearPostCommand {
55
branch: string;
@@ -126,8 +126,8 @@ export async function runLinearCommand(args: string[], port: number): Promise<nu
126126
return 0;
127127
}
128128

129-
const api = createApi(`http://localhost:${port}`);
130129
try {
130+
const api = createApi(await resolveProjectBaseUrl(port));
131131
const response = await api.postWorktreeToLinear({
132132
params: { name: parsed.post.branch },
133133
body: { target: parsed.post.target },

bin/src/oneshot.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@ import { apiPaths, AgentsUiConversationEventSchema, createApi, parseLinearTarget
33
import { createLinearIssue, fetchTeamByKey, type LinearIssue } from "../../backend/src/services/linear-service";
44
import { buildSeedFromLinear, defaultSeedFromLinearDeps } from "../../backend/src/services/conversation-export-service";
55
import { findDuplicateLinearIssue, polishLinearIssueTitle } from "../../backend/src/services/linear-title-service";
6-
import { CommandUsageError, formatServerError } from "./shared";
6+
import { CommandUsageError, formatServerError, resolveProjectBaseUrl } from "./shared";
7+
8+
// The server serves each project under `/<prefix>`. `runOneshot` resolves the
9+
// scoped base once and stashes it here so every helper (api calls + the agents
10+
// WebSocket) targets the right project. Null → unscoped fallback (port only).
11+
let oneshotBaseUrl: string | null = null;
12+
function oneshotApi(port: number): ReturnType<typeof createApi> {
13+
return createApi(oneshotBaseUrl ?? `http://localhost:${port}`);
14+
}
15+
function oneshotPathPrefix(): string {
16+
return oneshotBaseUrl ? new URL(oneshotBaseUrl).pathname.replace(/\/$/, "") : "";
17+
}
718

819
export interface ParsedOneshotCommand {
920
branch: string | null;
@@ -404,7 +415,7 @@ function streamConversation(
404415

405416
const connect = (): void => {
406417
if (closed) return;
407-
const url = `ws://localhost:${port}${apiPaths.streamAgentsWorktreeConversation.replace(":name", encodeURIComponent(branch))}`;
418+
const url = `ws://localhost:${port}${oneshotPathPrefix()}${apiPaths.streamAgentsWorktreeConversation.replace(":name", encodeURIComponent(branch))}`;
408419
const ws = new WebSocket(url);
409420
socket = ws;
410421
ws.addEventListener("open", () => {
@@ -499,7 +510,7 @@ function pollProjectState(
499510
},
500511
stderr: (line: string) => void,
501512
): { stop: () => void } {
502-
const api = createApi(`http://localhost:${port}`);
513+
const api = oneshotApi(port);
503514
let stopped = false;
504515
let timer: ReturnType<typeof setTimeout> | null = null;
505516

@@ -597,7 +608,7 @@ async function ensureWorktreeReady(
597608
port: number,
598609
stderr: (line: string) => void,
599610
): Promise<{ ready: true; worktree: ProjectWorktreeSnapshot } | { ready: false }> {
600-
const api = createApi(`http://localhost:${port}`);
611+
const api = oneshotApi(port);
601612
const deadline = Date.now() + 60_000;
602613
while (Date.now() < deadline) {
603614
try {
@@ -643,7 +654,7 @@ function pollConversationHistory(
643654
port: number,
644655
state: ConversationPrintState,
645656
): { stop: () => void } {
646-
const api = createApi(`http://localhost:${port}`);
657+
const api = oneshotApi(port);
647658
let stopped = false;
648659
let timer: ReturnType<typeof setTimeout> | null = null;
649660

@@ -717,7 +728,13 @@ export async function runOneshot(parsed: ParsedOneshotCommand, port: number): Pr
717728
process.stderr.write(`${line}\n`);
718729
};
719730

720-
const api = createApi(`http://localhost:${port}`);
731+
try {
732+
oneshotBaseUrl = await resolveProjectBaseUrl(port);
733+
} catch (error) {
734+
stderr(`[${timestamp()}] [error] ${formatServerError(error, port)}`);
735+
return 1;
736+
}
737+
const api = oneshotApi(port);
721738
let branch = parsed.branch;
722739
const body: CreateWorktreeRequest = { ...parsed.body };
723740
let fromLinearIssueId = parsed.fromLinearIssueId;

bin/src/shared.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { existsSync, readFileSync } from "node:fs";
2-
import { basename, join } from "node:path";
2+
import { basename, dirname, join, resolve } from "node:path";
3+
import { createApi } from "@webmux/api-contract";
34

45
export interface RunResult {
56
success: boolean;
@@ -69,3 +70,39 @@ export async function withServerConnection<T>(port: number, fn: () => Promise<T>
6970
throw new Error(formatServerError(error, port));
7071
}
7172
}
73+
74+
/** Resolve a directory to its canonical project (git) root — the shared root
75+
* even from a linked worktree — matching the server's `projectRoot()`. Returns
76+
* null when the dir isn't a git work tree (or git is unavailable). */
77+
export function resolveProjectRoot(cwd: string = process.cwd()): string | null {
78+
try {
79+
const common = run("git", ["rev-parse", "--git-common-dir"], { cwd });
80+
if (common.success) {
81+
const commonDir = common.stdout.toString().trim();
82+
if (commonDir) return dirname(resolve(cwd, commonDir));
83+
}
84+
const top = run("git", ["rev-parse", "--show-toplevel"], { cwd });
85+
return top.success ? top.stdout.toString().trim() : null;
86+
} catch {
87+
return null;
88+
}
89+
}
90+
91+
/** Base URL for talking to the active project on the running server. The server
92+
* serves each project under `/<prefix>`, so a server-backed CLI command must
93+
* target `http://localhost:<port>/<prefix>` for the project at `projectDir`.
94+
* Falls back to the unscoped base when the project root can't be determined;
95+
* throws a CommandUsageError when the root resolves but isn't a served project. */
96+
export async function resolveProjectBaseUrl(port: number, projectDir: string = process.cwd()): Promise<string> {
97+
const base = `http://localhost:${port}`;
98+
const root = resolveProjectRoot(projectDir);
99+
if (!root) return base;
100+
const { projects } = await createApi(base).fetchProjects();
101+
const match = projects.find((project) => project.path === root);
102+
if (!match) {
103+
throw new CommandUsageError(
104+
`This project (${root}) isn't served by webmux on port ${port}. Run \`webmux project add\` or start \`webmux serve\` in it first.`,
105+
);
106+
}
107+
return `${base}/${match.prefix}`;
108+
}

bin/src/webmux.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,10 +403,9 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
403403
process.exit(0);
404404
}
405405

406-
if (!existsSync(resolve(process.cwd(), ".webmux.yaml"))) {
407-
console.error("No .webmux.yaml found in this directory.\nRun `webmux init` to set up your project.");
408-
process.exit(1);
409-
}
406+
// No `.webmux.yaml` requirement here: the server serves every known project
407+
// (from ~/.webmux/projects.json) on one port and auto-adds the cwd repo when
408+
// it is a webmux project. Running from a fresh dir just shows the empty state.
410409

411410
const baseEnv = {
412411
...process.env,

bin/src/worktree-commands.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as p from "@clack/prompts";
22
import { createApi } from "@webmux/api-contract";
33
import { basename, resolve } from "node:path";
44
import { buildSeedFromLinear, defaultSeedFromLinearDeps } from "../../backend/src/services/conversation-export-service";
5-
import { CommandUsageError, withServerConnection } from "./shared";
5+
import { CommandUsageError, resolveProjectBaseUrl, withServerConnection } from "./shared";
66
import { readWorktreeArchiveState, readWorktreeMeta } from "../../backend/src/adapters/fs";
77
import { buildProjectSessionName, buildWorktreeWindowName } from "../../backend/src/adapters/tmux";
88
import type { AgentId } from "../../backend/src/domain/config";
@@ -851,7 +851,7 @@ export async function runWorktreeCommand(
851851
return 0;
852852
}
853853

854-
const api = createApi(`http://localhost:${context.port}`);
854+
const api = createApi(await withServerConnection(context.port, () => resolveProjectBaseUrl(context.port, context.projectDir)));
855855
await withServerConnection(context.port, () =>
856856
api.sendWorktreePrompt({
857857
params: { name: parsed.branch },
@@ -873,7 +873,7 @@ export async function runWorktreeCommand(
873873
return 0;
874874
}
875875

876-
const api = createApi(`http://localhost:${context.port}`);
876+
const api = createApi(await withServerConnection(context.port, () => resolveProjectBaseUrl(context.port, context.projectDir)));
877877
await withServerConnection(context.port, async () => {
878878
if (parsed.action === "new") {
879879
const { tab } = await api.createWorktreeTab({ params: { name: parsed.branch } });

0 commit comments

Comments
 (0)