Commit 034b460
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
File tree
- backend/src
- __tests__
- adapters
- domain
- services
- bin/src
- frontend/src
- lib
- packages/api-contract/src
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | | - | |
| 68 | + | |
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
76 | 93 | | |
77 | 94 | | |
78 | 95 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
| 4 | + | |
| 5 | + | |
7 | 6 | | |
8 | 7 | | |
9 | 8 | | |
| |||
46 | 45 | | |
47 | 46 | | |
48 | 47 | | |
49 | | - | |
| 48 | + | |
50 | 49 | | |
51 | | - | |
52 | | - | |
| 50 | + | |
| 51 | + | |
53 | 52 | | |
54 | 53 | | |
55 | 54 | | |
56 | | - | |
| 55 | + | |
57 | 56 | | |
58 | 57 | | |
59 | 58 | | |
60 | | - | |
| 59 | + | |
61 | 60 | | |
62 | 61 | | |
63 | 62 | | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
| 63 | + | |
86 | 64 | | |
87 | | - | |
88 | | - | |
| 65 | + | |
| 66 | + | |
89 | 67 | | |
90 | 68 | | |
91 | 69 | | |
92 | | - | |
| 70 | + | |
93 | 71 | | |
94 | 72 | | |
95 | 73 | | |
96 | | - | |
97 | | - | |
| 74 | + | |
| 75 | + | |
98 | 76 | | |
99 | 77 | | |
100 | 78 | | |
101 | | - | |
| 79 | + | |
102 | 80 | | |
103 | 81 | | |
104 | 82 | | |
105 | | - | |
106 | | - | |
107 | | - | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
108 | 86 | | |
109 | 87 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
24 | 23 | | |
25 | 24 | | |
26 | 25 | | |
27 | | - | |
28 | 26 | | |
29 | 27 | | |
30 | 28 | | |
| |||
67 | 65 | | |
68 | 66 | | |
69 | 67 | | |
70 | | - | |
| 68 | + | |
71 | 69 | | |
72 | 70 | | |
73 | 71 | | |
74 | 72 | | |
75 | | - | |
| 73 | + | |
76 | 74 | | |
77 | | - | |
78 | | - | |
| 75 | + | |
| 76 | + | |
79 | 77 | | |
80 | | - | |
81 | 78 | | |
82 | | - | |
83 | 79 | | |
84 | | - | |
85 | 80 | | |
| 81 | + | |
86 | 82 | | |
87 | | - | |
88 | | - | |
| 83 | + | |
89 | 84 | | |
90 | 85 | | |
91 | | - | |
92 | 86 | | |
93 | 87 | | |
94 | 88 | | |
| |||
110 | 104 | | |
111 | 105 | | |
112 | 106 | | |
113 | | - | |
114 | | - | |
| 107 | + | |
| 108 | + | |
115 | 109 | | |
116 | 110 | | |
117 | 111 | | |
118 | | - | |
| 112 | + | |
119 | 113 | | |
120 | 114 | | |
This file was deleted.
This file was deleted.
0 commit comments