Skip to content

Commit 034b460

Browse files
hugocasaclaude
andauthored
feat: serve all projects from one webmux dashboard on one port (#271)
* feat(backend): add ProjectManager + persisted projects registry Introduce the building blocks for serving multiple projects from one webmux process: - `domain/projects.ts`: `ProjectEntry` type + validator. - `adapters/projects-registry.ts`: `~/.webmux/projects.json` read/write (atomic, tolerant of malformed entries), upsert-by-path. - `services/project-manager.ts`: holds one `WebmuxRuntime` per project keyed by URL prefix (via `deriveInstancePrefix`), with add/remove/ get/list/loadPersisted and light-vs-heavy loop hooks. DI-clean and fully unit-tested. Not yet wired into the server — that is the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(backend): serve all projects from one process on one port Refactor server.ts from a single-project module into a multi-project server: - Wrap the per-project state + handlers + WebSocket logic in a `createProjectApp(runtime, prefix)` factory (handler bodies unchanged). - A single Bun.serve hosts every project: each project's routes are registered under a literal `/${prefix}/...` key and the route map is rebuilt via `server.reload()` whenever a project is added/removed. WebSocket connections carry `ws.data.prefix` and are dispatched to the owning project. Local projects are always served in-process on the same port — never a cross-port redirect. - Add hub endpoints: `GET/POST /api/projects`, `DELETE /api/projects/:prefix`, and the (now global) `GET /api/instances`. - On startup: load persisted projects and auto-add the cwd repo when it is a webmux project (has `.webmux.yaml`), so `webmux serve` inside a repo behaves like before with zero setup. - The peer-routing redirect in the global fetch survives only for genuinely remote, separately-run instances ("keep both" migration). Light-tier loops (PR/CI, Linear auto-create, oneshot watcher, auto-pull) run per project; heavy work (reconciliation, terminal attach) stays on-demand and therefore active-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): add `webmux project add|ls|rm` + project API contract - api-contract: add `fetchProjects`, `addProject`, `removeProject` endpoints + `ProjectSummary`/`ProjectsResponse`/`AddProjectRequest` schemas, so both CLI and frontend get typed access. - backend: point the hub routes at the new `apiPaths` constants. - bin: new `webmux project` subcommand (ls/add/rm) that talks to the running server's hub API; `add` defaults to the current repo and resolves the path before sending. Wired into arg parsing, help text, and bash/zsh completions. Parse logic unit-tested. Verified end-to-end against a live server: ls / add . / rm / add /tmp (rejected as "Not a git repository"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): project switcher + project-scoped API/WS on one origin The dashboard now serves every project from one origin, scoped by the `/<prefix>` URL segment: - `api.ts`: derive `activePrefix`/`apiBase` from the URL; point the API client and both WebSocket URLs (terminal + agents) at `/<prefix>`. Add a hub client for the global project endpoints and `fetchProjects`/ `addProject`/`removeProject` + `ensureProjectPrefix` bootstrap helper. - `main.ts`: before mounting, redirect `/` (or an unknown prefix) to a real project so the API client has a valid base. - `ProjectSwitcher.svelte`: replaces `InstanceSwitcher` — lists projects (current marked), switches by navigating to `/<prefix>/`, and adds/removes projects via the hub API. Still lists remote peer instances underneath. - Update the App api mock for the new exports. Frontend: `bun run check` clean, `vitest` 73/73, `vite build` ok. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(backend): a local project always owns its /<prefix> namespace When a local project's prefix also matches a remote peer in the instance registry, a bare `/<prefix>/...` SPA deep-link fell through to the peer redirect and got 302'd to the other instance instead of serving the local project. Skip the peer-redirect entirely for prefixes we serve locally; only unknown prefixes may federate to a remote peer. Verified: with a real peer registered under `webmux`, the local `webmux` project serves its SPA (200) while `/windmill/` still federates (302) to the windmill peer — both on one port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document one-dashboard-many-projects + `webmux project` README Quick Start now explains that a single `webmux serve` hosts every added project on one port (each under `/<prefix>`), that `.webmux.yaml` is the only per-project step, and how to manage projects from the dashboard switcher or the new `webmux project ls|add|rm` CLI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: update prefix tests for the case-insensitive regex `d4e60ef` relaxed VALID_INSTANCE_PREFIX_RE / VALID_WORKTREE_NAME_RE to allow uppercase (the `/i` flag) but left two tests asserting uppercase is rejected, so they failed on main. Update them to match the intended behavior: - isValidInstancePrefix: uppercase is now accepted; keep rejecting leading hyphen / spaces / empty. - instance-registry: forge the "invalid prefix" entry with a leading hyphen instead of an (now-valid) uppercase prefix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address code-review findings on multi-project PR - Empty state (was: zero-project dashboard 404'd): `ensureProjectPrefix` now returns ready | redirecting | no-projects, and `main.ts` mounts a guided `EmptyProjects` screen (add a repo / run `webmux init`) instead of booting a dashboard whose every per-project call would 404. - WebSocket leak on removal: track open sockets per project and run their cleanup (tmux detach, agents unsubscribe) before `apps.delete`, so removing a project with a live terminal/agent socket no longer leaks it. - `active` is now meaningful: setActive is wired to socket open/close (a project is active while ≥1 client has a terminal/agent socket open); documented in the contract. Heavy-loop start/stop remain no-ops (separate follow-up). - Nits: drop the redundant `projectRoot()` in apiAddProject (manager resolves); align ProjectSwitcher peer links to a trailing slash like local projects. Verified: backend 454 tests, frontend 114, bin+contract 185, tsc + svelte-check clean. Runtime: `active` flips true→false on WS open/close; zero-project server returns an empty list (→ empty state). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * fix: target live server for project cmds + symlink-robust project match - `webmux project ls/add/rm` now use the resolved live-server port (effectivePort) like oneshot/linear/send/tab, instead of the raw --port default. Without --port they would hit 5111 and miss a hub that port-walked elsewhere. - resolveProjectBaseUrl canonicalizes both the local git root and each served project's path (realpath + resolve) before comparing, so a CLI invoked from a symlinked cwd or with a trailing slash no longer gets a false "isn't served by webmux" error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): scope notifications SSE + file upload under /<prefix> subscribeNotifications and uploadFiles issued raw "/api/..." requests without apiBase. Since every project is served under /<prefix>/ and `/` redirects there, apiBase is always non-empty, so these two calls fell through to the hub's globalFetch and got index.html (200) back instead of the real endpoint — breaking worktree notifications (EventSource saw text/html) and drag-and-drop upload (res.json() on HTML) in every deployment, single- and multi-project alike. Prefix both with apiBase, matching the conversation WS and terminal WS. Adds api.test.ts covering the prefixed SSE + upload URLs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(backend): note the synchronous ordering in apiRemoveProject closeProjectSockets + manager.remove must stay in order so the deferred real close event no-ops (apps.delete has already run) rather than double-running per-socket cleanup. Flagged twice in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): clear error when a server-backed command runs outside a git repo resolveProjectBaseUrl returned the bare base URL when projectDir wasn't a git repo. Since per-project routes now only exist under /<prefix>, that base 404s confusingly. Throw a CommandUsageError pointing the user at `webmux project ls` instead. - Add a resolveBaseUrl DI seam to runWorktreeCommand so send/tab HTTP shape stays testable without a live server / real git root. - Cover the non-git-cwd throw in shared.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: converge on one webmux server per machine; drop federation Two coupled changes that make the single-dashboard model safe and remove the now-dead plumbing from the old one-server-per-project topology. 1. cwd auto-add is in-memory only. `webmux serve` in a repo still serves that repo, but no longer writes it to the shared ~/.webmux/projects.json — so other running servers don't reload (and double-serve) it on their next restart. Only an explicit `webmux project add` persists. - ProjectManager gains addEphemeral(path) (register without persist); autoAddCwd() uses it. 2. Remove the redirect/federation layer (it only existed to bridge many per-project servers on different ports): - delete domain/peer-routing.ts + the globalFetch redirect block; an unknown prefix now just falls through to the SPA. - remove the ProjectSwitcher "Other instances" clickable-peer list. - delete the primary-project computation (primaryCwd/primaryRoot/ INSTANCE_PREFIX) and the --prefix / WEBMUX_PREFIX flag — an instance-level prefix is meaningless without federation. The instance registry survives as a transitional migration sensor only: selfEntry slims to { pid, port, projectDir } (projectDir is the repo to recover during migration), /api/instances + InstanceSummary follow. A follow-up commit adds the migration banner + `webmux project migrate`. Cleanups: rename the prefix helpers to project-* terminology (deriveProjectPrefix / sanitizeProjectPrefix, RESERVED_PROJECT_PREFIXES) since they now derive per-project prefixes, and drop the dead isValidInstancePrefix + the WEBMUX_PROJECT_DIR fallback in createWebmuxRuntime (ProjectManager always passes an explicit projectDir). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: install a single multi-project service; drop port machinery webmux is one server per machine, so `webmux service install` now creates one service under a fixed name (`webmux`) instead of one `webmux-<project>` per project. Add more projects from the dashboard or `webmux project add`; `webmux project migrate` (next commit) folds legacy per-project units in. - service.ts: fixed serviceName, no per-service port auto-pick. `--port` wins; a bare reinstall reuses the existing unit's port; otherwise 5111. Move readPortFromUnit here (still needed for reinstall + `webmux update`). - Delete install-ports.ts (pickFreePort / discoverTakenPorts / readInstalledServicePorts) — the per-service port scan only existed so many per-project servers could share nearby ports. - server.ts: bind the configured port and fail clearly on EADDRINUSE (almost always another webmux → the migration cue) instead of walking PORT..PORT+100. Drop WEBMUX_PORT_STRICT and the strict/walk split. - webmux.ts: drop the WEBMUX_PORT_STRICT passthrough + the "falls back to a free port" banner. - service-restart.ts: `webmux update` now also picks up `webmux.service`, not just legacy `webmux-*` units. - Move the readPortFromUnit tests into service.test.ts. allocateServicePorts (worktree dev-service ports in domain/policies.ts) is unrelated and untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: assisted migration from leftover per-project servers After updating, a machine may still have several old single-project webmux servers running (one per project, the previous model). This adds detection + a one-shot consolidation into the single dashboard. Detection: - /api/instances (the migration sensor) lists the other live servers. - Frontend MigrationBanner nudges the user to run `webmux project migrate`. - `webmux project` commands print the same one-line warning when peers exist. Action — `webmux project migrate` (CLI) + POST /api/projects/migrate: - The endpoint registers + persists each other instance's repo into this server (the part that needs the ProjectManager). - The CLI orchestrates: call the endpoint FIRST (survivor serves the repos before anything stops — no service gap), then stop + disable + remove each old server's service unit (found by matching its --port), so it neither respawns nor returns on reboot. Unit management stays in the CLI (bin owns service.ts) rather than inverting the bin→backend dependency. - If the survivor isn't itself an installed service, hint to install it. Pure helpers (otherInstances / findUnitForPort / disableUnitCommands) and the orchestrator are unit-tested via injected deps; the endpoint shape is covered by the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: ephemeral cwd auto-add, single service, and migration README claimed separate instances "cross-link to each other" (federation, now removed) and implied the cwd auto-add persists. Correct both: auto-add is session-only, only `webmux project add` persists, install one service per machine, and `webmux project migrate` consolidates legacy per-project servers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): don't retire a service whose repo failed to migrate runMigrate stopped + disabled + removed every other instance's service unit in step 2, even ones whose projectDir failed to register into the survivor in step 1. A repo that couldn't be added (path gone, unreadable config) would end up neither served here nor running in its old service — silently lost. Skip retiring units for instances whose path is in result.failed, with a warning telling the user to resolve the error and stop it themselves. Covered by a new partial-failure test. Flagged in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c24787f commit 034b460

47 files changed

Lines changed: 2275 additions & 1048 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,31 @@ bun install -g webmux
6565

6666
# 3. Set up your project
6767
cd /path/to/your/project
68-
webmux init # creates .webmux.yaml
68+
webmux init # creates .webmux.yaml (the only per-project step)
6969

7070
# 4. Start the dashboard
7171
webmux serve # dashboard on http://localhost:5111
7272
```
7373

7474
The primary dashboard remains the best desktop experience. On mobile, the same dashboard swaps the embedded terminal for a chat view on open Codex and Claude worktrees.
7575

76+
### One dashboard, many projects
77+
78+
You don't run a separate webmux per project. A single `webmux serve` serves **every project you've added, on one dashboard and one port** — each scoped under its own `/<prefix>` URL. The only per-project requirement is a `.webmux.yaml` in the repo (created by `webmux init`); webmux auto-loads it.
79+
80+
- The repo you launch `webmux serve` in is served automatically — but only for that session. It isn't written to `~/.webmux/projects.json`, so it isn't remembered across restarts unless you `webmux project add` it.
81+
- Switch projects, or add/remove them, from the project switcher in the dashboard.
82+
- Or manage them from the CLI:
83+
84+
```bash
85+
webmux project ls # list projects the dashboard is serving
86+
webmux project add ~/code/other # add another project (persists; must have a .webmux.yaml)
87+
webmux project rm other # remove by prefix
88+
webmux project migrate # fold other running webmux servers into this one
89+
```
90+
91+
Projects added with `webmux project add` are remembered in `~/.webmux/projects.json` and reloaded on the next start. Run webmux as a single service per machine with `webmux service install`. If you're upgrading from an older setup that ran one service per project, `webmux project migrate` consolidates those leftover servers into this one (the dashboard shows a banner when it detects them).
92+
7693
## Prerequisites
7794

7895
| Tool | Purpose |
Lines changed: 17 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { describe, expect, it } from "bun:test";
22
import {
33
allocateServicePorts,
4-
deriveInstancePrefix,
5-
isValidInstancePrefix,
6-
sanitizeInstancePrefix,
4+
deriveProjectPrefix,
5+
sanitizeProjectPrefix,
76
} from "../domain/policies";
87

98
describe("allocateServicePorts", () => {
@@ -46,64 +45,43 @@ describe("allocateServicePorts", () => {
4645
});
4746
});
4847

49-
describe("sanitizeInstancePrefix", () => {
48+
describe("sanitizeProjectPrefix", () => {
5049
it("lowercases and replaces non-alphanumerics with hyphens", () => {
51-
expect(sanitizeInstancePrefix("My Project")).toBe("my-project");
52-
expect(sanitizeInstancePrefix("Some_Repo.v2")).toBe("some-repo-v2");
50+
expect(sanitizeProjectPrefix("My Project")).toBe("my-project");
51+
expect(sanitizeProjectPrefix("Some_Repo.v2")).toBe("some-repo-v2");
5352
});
5453

5554
it("collapses runs of hyphens and trims edges", () => {
56-
expect(sanitizeInstancePrefix("--__foo bar__--")).toBe("foo-bar");
55+
expect(sanitizeProjectPrefix("--__foo bar__--")).toBe("foo-bar");
5756
});
5857

5958
it("returns an empty string when nothing usable remains", () => {
60-
expect(sanitizeInstancePrefix("***")).toBe("");
59+
expect(sanitizeProjectPrefix("***")).toBe("");
6160
});
6261
});
6362

64-
describe("isValidInstancePrefix", () => {
65-
it("accepts alphanumeric (any case) and hyphens", () => {
66-
expect(isValidInstancePrefix("webmux")).toBe(true);
67-
expect(isValidInstancePrefix("webmux-2")).toBe(true);
68-
expect(isValidInstancePrefix("ab12-cd")).toBe(true);
69-
expect(isValidInstancePrefix("Webmux")).toBe(true);
70-
});
71-
72-
it("rejects leading hyphen, spaces, or empty", () => {
73-
expect(isValidInstancePrefix("-bad")).toBe(false);
74-
expect(isValidInstancePrefix("has space")).toBe(false);
75-
expect(isValidInstancePrefix("")).toBe(false);
76-
});
77-
78-
it("rejects reserved path segments owned by the route map", () => {
79-
expect(isValidInstancePrefix("api")).toBe(false);
80-
expect(isValidInstancePrefix("ws")).toBe(false);
81-
expect(isValidInstancePrefix("assets")).toBe(false);
82-
});
83-
});
84-
85-
describe("deriveInstancePrefix", () => {
63+
describe("deriveProjectPrefix", () => {
8664
it("returns the basename when no collision", () => {
87-
expect(deriveInstancePrefix("/home/me/projects/webmux", [])).toBe("webmux");
88-
expect(deriveInstancePrefix("/srv/widgets/", [])).toBe("widgets");
65+
expect(deriveProjectPrefix("/home/me/projects/webmux", [])).toBe("webmux");
66+
expect(deriveProjectPrefix("/srv/widgets/", [])).toBe("widgets");
8967
});
9068

9169
it("falls back to a default when the basename has no alphanumerics", () => {
92-
expect(deriveInstancePrefix("/repo/...", [])).toBe("webmux");
70+
expect(deriveProjectPrefix("/repo/...", [])).toBe("webmux");
9371
});
9472

9573
it("appends -2, -3, ... to avoid collisions", () => {
96-
expect(deriveInstancePrefix("/a/webmux", ["webmux"])).toBe("webmux-2");
97-
expect(deriveInstancePrefix("/a/webmux", ["webmux", "webmux-2"])).toBe("webmux-3");
74+
expect(deriveProjectPrefix("/a/webmux", ["webmux"])).toBe("webmux-2");
75+
expect(deriveProjectPrefix("/a/webmux", ["webmux", "webmux-2"])).toBe("webmux-3");
9876
});
9977

10078
it("sanitizes weird basenames", () => {
101-
expect(deriveInstancePrefix("/projects/My Cool App!", [])).toBe("my-cool-app");
79+
expect(deriveProjectPrefix("/projects/My Cool App!", [])).toBe("my-cool-app");
10280
});
10381

10482
it("never returns a reserved prefix even when the basename matches one", () => {
105-
expect(deriveInstancePrefix("/srv/api", [])).toBe("api-2");
106-
expect(deriveInstancePrefix("/srv/ws", [])).toBe("ws-2");
107-
expect(deriveInstancePrefix("/srv/assets", [])).toBe("assets-2");
83+
expect(deriveProjectPrefix("/srv/api", [])).toBe("api-2");
84+
expect(deriveProjectPrefix("/srv/ws", [])).toBe("ws-2");
85+
expect(deriveProjectPrefix("/srv/assets", [])).toBe("assets-2");
10886
});
10987
});

backend/src/__tests__/instance-registry.test.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,9 @@ describe("instance-registry", () => {
2020

2121
function makeEntry(overrides: Partial<InstanceEntry> = {}): InstanceEntry {
2222
return {
23-
prefix: "demo",
2423
port: 5111,
2524
projectDir: "/repo/demo",
2625
pid: process.pid,
27-
startedAt: Date.now(),
2826
...overrides,
2927
};
3028
}
@@ -67,28 +65,24 @@ describe("instance-registry", () => {
6765
const { dir, registry } = await freshRegistry();
6866
registry.register(makeEntry({ port: 5111 }));
6967
writeFileSync(join(dir, "5112.json"), "not json");
70-
writeFileSync(join(dir, "5113.json"), JSON.stringify({ prefix: 1 }));
68+
writeFileSync(join(dir, "5113.json"), JSON.stringify({ port: 1 }));
7169

7270
expect(registry.listLive().map((e) => e.port)).toEqual([5111]);
7371
});
7472

75-
it("rejects entries whose prefix is not a valid instance prefix", async () => {
73+
it("rejects entries missing required fields", async () => {
7674
const { dir, registry } = await freshRegistry();
77-
registry.register(makeEntry({ port: 5111, prefix: "good" }));
78-
// Forge an entry with a bad prefix (leading hyphen, reserved, etc.)
75+
registry.register(makeEntry({ port: 5111 }));
76+
// Missing projectDir.
7977
writeFileSync(join(dir, "5112.json"), JSON.stringify({
80-
prefix: "-bad",
8178
port: 5112,
82-
projectDir: "/x",
8379
pid: process.pid,
84-
startedAt: 1,
8580
}));
81+
// Wrong type for port.
8682
writeFileSync(join(dir, "5113.json"), JSON.stringify({
87-
prefix: "api",
88-
port: 5113,
83+
port: "nope",
8984
projectDir: "/x",
9085
pid: process.pid,
91-
startedAt: 1,
9286
}));
9387

9488
expect(registry.listLive().map((e) => e.port)).toEqual([5111]);
@@ -110,11 +104,11 @@ describe("instance-registry", () => {
110104

111105
it("overwrites an existing entry when registering the same port twice", async () => {
112106
const { registry } = await freshRegistry();
113-
registry.register(makeEntry({ port: 5111, prefix: "alpha" }));
114-
registry.register(makeEntry({ port: 5111, prefix: "beta" }));
107+
registry.register(makeEntry({ port: 5111, projectDir: "/repo/alpha" }));
108+
registry.register(makeEntry({ port: 5111, projectDir: "/repo/beta" }));
115109

116110
const entries = registry.listLive();
117111
expect(entries).toHaveLength(1);
118-
expect(entries[0]?.prefix).toBe("beta");
112+
expect(entries[0]?.projectDir).toBe("/repo/beta");
119113
});
120114
});

backend/src/__tests__/peer-routing-integration.test.ts

Lines changed: 0 additions & 90 deletions
This file was deleted.

backend/src/__tests__/peer-routing.test.ts

Lines changed: 0 additions & 69 deletions
This file was deleted.

0 commit comments

Comments
 (0)