diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index 920f7b20b..6b9b0003b 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -26,9 +26,9 @@ function workspace(input: { const runningScript: WorkspaceScriptPayload = { scriptName: "web", type: "service", - hostname: "main.web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://main.web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 960d285dc..40ae92b41 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -47,9 +47,9 @@ function workspace( const runningScript: WorkspaceScriptPayload = { scriptName: "web", type: "service", - hostname: "main.web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://main.web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -58,9 +58,9 @@ const runningScript: WorkspaceScriptPayload = { const stoppedScript: WorkspaceScriptPayload = { scriptName: "api", type: "service", - hostname: "main.api.localhost", + hostname: "api.paseo.localhost", port: 3001, - proxyUrl: "http://main.api.localhost:6767", + proxyUrl: "http://api.paseo.localhost:6767", lifecycle: "stopped", health: null, exitCode: null, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 2191af301..a286cb0ca 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -38,9 +38,9 @@ describe("normalizeWorkspaceDescriptor", () => { { scriptName: "web", type: "service" as const, - hostname: "main.web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://main.web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running" as const, health: "healthy" as const, exitCode: null, @@ -65,9 +65,9 @@ describe("normalizeWorkspaceDescriptor", () => { { scriptName: "web", type: "service", - hostname: "main.web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://main.web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -114,9 +114,9 @@ describe("mergeWorkspaces", () => { { scriptName: "web", type: "service", - hostname: "main.web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://main.web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -129,9 +129,9 @@ describe("mergeWorkspaces", () => { { scriptName: "web", type: "service", - hostname: "main.web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://main.web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, diff --git a/packages/app/src/utils/workspace-script-links.test.ts b/packages/app/src/utils/workspace-script-links.test.ts index 6a235203e..399d6a703 100644 --- a/packages/app/src/utils/workspace-script-links.test.ts +++ b/packages/app/src/utils/workspace-script-links.test.ts @@ -6,9 +6,9 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links"; const runningService: WorkspaceScriptPayload = { scriptName: "web", type: "service", - hostname: "feature.web.localhost", + hostname: "web.feature.paseo.localhost", port: 3000, - proxyUrl: "http://feature.web.localhost:6767", + proxyUrl: "http://web.feature.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -26,8 +26,8 @@ describe("resolveWorkspaceScriptLink", () => { expect( resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }), ).toEqual({ - openUrl: "http://feature.web.localhost:6767", - labelUrl: "http://feature.web.localhost:6767", + openUrl: "http://web.feature.paseo.localhost:6767", + labelUrl: "http://web.feature.paseo.localhost:6767", }); }); @@ -35,8 +35,8 @@ describe("resolveWorkspaceScriptLink", () => { expect( resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }), ).toEqual({ - openUrl: "http://feature.web.localhost:6767", - labelUrl: "http://feature.web.localhost:6767", + openUrl: "http://web.feature.paseo.localhost:6767", + labelUrl: "http://web.feature.paseo.localhost:6767", }); }); @@ -58,7 +58,7 @@ describe("resolveWorkspaceScriptLink", () => { resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }), ).toEqual({ openUrl: null, - labelUrl: "http://feature.web.localhost:6767", + labelUrl: "http://web.feature.paseo.localhost:6767", }); }); }); diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index 141e634c1..ff0e3daf2 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -160,27 +160,28 @@ describe("ScriptHealthMonitor", () => { monitor.start(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: healthy.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); - expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("pending"); await advancePoll(4_000); - expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("pending"); expect(onChange).not.toHaveBeenCalled(); await advancePoll(1_000); monitor.stop(); - expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { scriptName: "api", - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: healthy.port, health: "healthy", }, @@ -193,9 +194,10 @@ describe("ScriptHealthMonitor", () => { const deadPort = await findFreePort(); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: deadPort, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); @@ -209,22 +211,22 @@ describe("ScriptHealthMonitor", () => { failuresBeforeStopped: 2, }); - expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("pending"); monitor.start(); await advancePoll(2_000); - expect(monitor.getHealthForHostname("api.localhost")).toBe("pending"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("pending"); expect(onChange).not.toHaveBeenCalled(); await advancePoll(1_000); monitor.stop(); - expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("unhealthy"); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith("workspace-a", [ { scriptName: "api", - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: deadPort, health: "unhealthy", }, @@ -239,9 +241,10 @@ describe("ScriptHealthMonitor", () => { const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: healthy.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); @@ -269,9 +272,10 @@ describe("ScriptHealthMonitor", () => { const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: healthy.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); @@ -293,18 +297,18 @@ describe("ScriptHealthMonitor", () => { servers.delete(healthy.server); await advancePoll(1_000); - expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1); await advancePoll(1_000); monitor.stop(); - expect(monitor.getHealthForHostname("api.localhost")).toBe("unhealthy"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("unhealthy"); expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenLastCalledWith("workspace-a", [ { scriptName: "api", - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: healthy.port, health: "unhealthy", }, @@ -319,9 +323,10 @@ describe("ScriptHealthMonitor", () => { const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: healthy.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); @@ -337,17 +342,17 @@ describe("ScriptHealthMonitor", () => { monitor.start(); await advancePoll(1_000); - expect(monitor.getHealthForHostname("api.localhost")).toBe("healthy"); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBe("healthy"); expect(onChange).toHaveBeenCalledTimes(1); - routeStore.removeRoute("api.localhost"); + routeStore.removeRoute("route-b.example.localhost"); await closeServer(healthy.server); servers.delete(healthy.server); await advancePoll(3_000); monitor.stop(); - expect(monitor.getHealthForHostname("api.localhost")).toBeNull(); + expect(monitor.getHealthForHostname("route-b.example.localhost")).toBeNull(); expect(onChange).toHaveBeenCalledTimes(1); }); @@ -361,15 +366,17 @@ describe("ScriptHealthMonitor", () => { const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: api.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); routeStore.registerRoute({ - hostname: "web.localhost", + hostname: "route-c.example.localhost", port: web.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "web", }); @@ -390,13 +397,13 @@ describe("ScriptHealthMonitor", () => { expect(onChange).toHaveBeenCalledWith("workspace-a", [ { scriptName: "api", - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: api.port, health: "healthy", }, { scriptName: "web", - hostname: "web.localhost", + hostname: "route-c.example.localhost", port: web.port, health: "healthy", }, @@ -436,9 +443,10 @@ describe("ScriptHealthMonitor", () => { expect(createTerminalCalls).toHaveLength(2); expect(routeStore.listRoutes()).toEqual([ { - hostname: "api.localhost", + hostname: "api.repo.localhost", port: service.port, workspaceId: workspace.repoDir, + projectSlug: "repo", scriptName: "api", }, ]); @@ -460,7 +468,7 @@ describe("ScriptHealthMonitor", () => { expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [ { scriptName: "api", - hostname: "api.localhost", + hostname: "api.repo.localhost", port: service.port, health: "healthy", }, @@ -481,15 +489,17 @@ describe("ScriptHealthMonitor", () => { const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: api.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "api", }); routeStore.registerRoute({ - hostname: "web.localhost", + hostname: "route-c.example.localhost", port: web.port, workspaceId: "workspace-a", + projectSlug: "repo", scriptName: "web", }); @@ -523,13 +533,13 @@ describe("ScriptHealthMonitor", () => { expect(onChange).toHaveBeenCalledWith("workspace-a", [ { scriptName: "api", - hostname: "api.localhost", + hostname: "route-b.example.localhost", port: api.port, health: "unhealthy", }, { scriptName: "web", - hostname: "web.localhost", + hostname: "route-c.example.localhost", port: web.port, health: "unhealthy", }, diff --git a/packages/server/src/server/script-proxy.test.ts b/packages/server/src/server/script-proxy.test.ts index bef928d0c..f75de1857 100644 --- a/packages/server/src/server/script-proxy.test.ts +++ b/packages/server/src/server/script-proxy.test.ts @@ -31,40 +31,43 @@ describe("ScriptRouteStore", () => { it("registerRoute and findRoute with exact match", () => { const store = new ScriptRouteStore(); store.registerRoute({ - hostname: "editor.localhost", + hostname: "route-a.example.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "editor", }); - const route = store.findRoute("editor.localhost"); - expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + const route = store.findRoute("route-a.example.localhost"); + expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 }); }); it("findRoute strips port from host header", () => { const store = new ScriptRouteStore(); store.registerRoute({ - hostname: "editor.localhost", + hostname: "route-a.example.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "editor", }); - const route = store.findRoute("editor.localhost:6767"); - expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + const route = store.findRoute("route-a.example.localhost:6767"); + expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 }); }); it("findRoute subdomain match", () => { const store = new ScriptRouteStore(); store.registerRoute({ - hostname: "editor.localhost", + hostname: "editor.example.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "editor", }); - const route = store.findRoute("fix-auth.editor.localhost"); - expect(route).toEqual({ hostname: "editor.localhost", port: 3000 }); + const route = store.findRoute("tenant.editor.example.localhost"); + expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 }); }); it("listRoutes returns enriched entries", () => { @@ -73,12 +76,14 @@ describe("ScriptRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", scriptName: "docs", }); @@ -88,12 +93,14 @@ describe("ScriptRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "web", }); expect(routes).toContainEqual({ hostname: "b.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", scriptName: "docs", }); }); @@ -104,18 +111,21 @@ describe("ScriptRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", scriptName: "docs", }); store.registerRoute({ hostname: "c.localhost", port: 5000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "api", }); @@ -124,12 +134,14 @@ describe("ScriptRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "web", }, { hostname: "c.localhost", port: 5000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "api", }, ]); @@ -138,26 +150,28 @@ describe("ScriptRouteStore", () => { it("removeRoute works", () => { const store = new ScriptRouteStore(); store.registerRoute({ - hostname: "editor.localhost", + hostname: "route-a.example.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "editor", }); - store.removeRoute("editor.localhost"); + store.removeRoute("route-a.example.localhost"); - expect(store.findRoute("editor.localhost")).toBeNull(); + expect(store.findRoute("route-a.example.localhost")).toBeNull(); }); it("removeRoute cleans up workspace index", () => { const store = new ScriptRouteStore(); store.registerRoute({ - hostname: "editor.localhost", + hostname: "route-a.example.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "editor", }); - store.removeRoute("editor.localhost"); + store.removeRoute("route-a.example.localhost"); expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); }); @@ -168,18 +182,21 @@ describe("ScriptRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "api", }); store.registerRoute({ hostname: "c.localhost", port: 4000, workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", scriptName: "docs", }); @@ -199,12 +216,14 @@ describe("ScriptRouteStore", () => { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "web", }); store.registerRoute({ hostname: "b.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "api", }); @@ -216,9 +235,10 @@ describe("ScriptRouteStore", () => { it("findRoute returns null for unknown hosts", () => { const store = new ScriptRouteStore(); store.registerRoute({ - hostname: "editor.localhost", + hostname: "route-a.example.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", scriptName: "editor", }); @@ -304,7 +324,13 @@ describe("HTTP proxy", () => { it("proxies requests to the correct upstream based on Host header", async () => { const upstream = await startUpstream(); const routeStore = new ScriptRouteStore(); - routeStore.addRoute("test-service.localhost", upstream.port); + routeStore.registerRoute({ + hostname: "test-service.localhost", + port: upstream.port, + workspaceId: "workspace-test", + projectSlug: "test", + scriptName: "service", + }); const proxy = await startProxy(routeStore); const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`); @@ -332,7 +358,13 @@ describe("HTTP proxy", () => { const deadPort = await findFreePort(); const routeStore = new ScriptRouteStore(); - routeStore.addRoute("dead-service.localhost", deadPort); + routeStore.registerRoute({ + hostname: "dead-service.localhost", + port: deadPort, + workspaceId: "workspace-dead", + projectSlug: "dead", + scriptName: "service", + }); const proxy = await startProxy(routeStore); const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`); @@ -384,7 +416,13 @@ describe("WebSocket proxy", () => { // 2. Create the proxy server with the upgrade handler const routeStore = new ScriptRouteStore(); - routeStore.addRoute("ws-service.localhost", upstreamPort); + routeStore.registerRoute({ + hostname: "ws-service.localhost", + port: upstreamPort, + workspaceId: "workspace-ws", + projectSlug: "ws", + scriptName: "service", + }); const proxyPort = await findFreePort(); const proxyServer = http.createServer((_req, res) => { diff --git a/packages/server/src/server/script-proxy.ts b/packages/server/src/server/script-proxy.ts index 936fe48a2..d15cf05f2 100644 --- a/packages/server/src/server/script-proxy.ts +++ b/packages/server/src/server/script-proxy.ts @@ -31,6 +31,7 @@ export interface ScriptRoute { export interface ScriptRouteEntry extends ScriptRoute { workspaceId: string; + projectSlug: string; scriptName: string; } @@ -38,15 +39,6 @@ export class ScriptRouteStore { private routes = new Map(); private workspaceHostnames = new Map>(); - addRoute(hostname: string, port: number): void { - this.registerRoute({ - hostname, - port, - workspaceId: "", - scriptName: hostname, - }); - } - registerRoute(entry: ScriptRouteEntry): void { const previous = this.routes.get(entry.hostname); if (previous) { @@ -67,6 +59,15 @@ export class ScriptRouteStore { this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname); } + removeRouteForWorkspaceScript(params: { workspaceId: string; scriptName: string }): void { + const routes = this.listRoutesForWorkspace(params.workspaceId); + const route = routes.find((entry) => entry.scriptName === params.scriptName); + if (!route) { + return; + } + this.removeRoute(route.hostname); + } + removeRoutesForPort(port: number): void { for (const [hostname, entry] of this.routes) { if (entry.port === port) { diff --git a/packages/server/src/server/script-route-branch-handler.test.ts b/packages/server/src/server/script-route-branch-handler.test.ts index b03281fb9..ad1c428ed 100644 --- a/packages/server/src/server/script-route-branch-handler.test.ts +++ b/packages/server/src/server/script-route-branch-handler.test.ts @@ -38,11 +38,13 @@ function registerRoute( hostname, port, workspaceId = "workspace-a", + projectSlug = "paseo", scriptName, }: { hostname: string; port: number; workspaceId?: string; + projectSlug?: string; scriptName: string; }, ): void { @@ -50,6 +52,7 @@ function registerRoute( hostname, port, workspaceId, + projectSlug, scriptName, }); } @@ -58,7 +61,7 @@ describe("script-route-branch-handler", () => { it("updates routes on branch rename by removing old hostnames and registering new ones", () => { const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { - hostname: "feature-auth.api.localhost", + hostname: "api.feature-auth.paseo.localhost", port: 3001, scriptName: "api", }); @@ -71,9 +74,9 @@ describe("script-route-branch-handler", () => { handleBranchChange("workspace-a", "feature/auth", "feature/billing"); - expect(routeStore.findRoute("feature-auth.api.localhost")).toBeNull(); - expect(routeStore.findRoute("feature-billing.api.localhost")).toEqual({ - hostname: "feature-billing.api.localhost", + expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull(); + expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({ + hostname: "api.feature-billing.paseo.localhost", port: 3001, }); }); @@ -95,7 +98,7 @@ describe("script-route-branch-handler", () => { it("is a no-op when the resolved hostnames do not change", () => { const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { - hostname: "api.localhost", + hostname: "api.paseo.localhost", port: 3001, scriptName: "api", }); @@ -110,9 +113,10 @@ describe("script-route-branch-handler", () => { expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ { - hostname: "api.localhost", + hostname: "api.paseo.localhost", port: 3001, workspaceId: "workspace-a", + projectSlug: "paseo", scriptName: "api", }, ]); @@ -122,7 +126,7 @@ describe("script-route-branch-handler", () => { it("triggers shared reprojection after a route change", () => { const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { - hostname: "feature-auth.api.localhost", + hostname: "api.feature-auth.paseo.localhost", port: 3001, scriptName: "api", }); @@ -141,19 +145,20 @@ describe("script-route-branch-handler", () => { it("updates all services for a workspace when multiple routes are registered", () => { const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { - hostname: "feature-auth.api.localhost", + hostname: "api.feature-auth.paseo.localhost", port: 3001, scriptName: "api", }); registerRoute(routeStore, { - hostname: "feature-auth.web.localhost", + hostname: "web.feature-auth.paseo.localhost", port: 3002, scriptName: "web", }); registerRoute(routeStore, { - hostname: "docs.localhost", + hostname: "docs.docs-app.localhost", port: 3003, workspaceId: "workspace-b", + projectSlug: "docs-app", scriptName: "docs", }); @@ -167,23 +172,26 @@ describe("script-route-branch-handler", () => { expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ { - hostname: "feature-billing.api.localhost", + hostname: "api.feature-billing.paseo.localhost", port: 3001, workspaceId: "workspace-a", + projectSlug: "paseo", scriptName: "api", }, { - hostname: "feature-billing.web.localhost", + hostname: "web.feature-billing.paseo.localhost", port: 3002, workspaceId: "workspace-a", + projectSlug: "paseo", scriptName: "web", }, ]); expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([ { - hostname: "docs.localhost", + hostname: "docs.docs-app.localhost", port: 3003, workspaceId: "workspace-b", + projectSlug: "docs-app", scriptName: "docs", }, ]); @@ -192,7 +200,7 @@ describe("script-route-branch-handler", () => { it("does not emit a status update when no changes are needed", () => { const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { - hostname: "web.localhost", + hostname: "web.paseo.localhost", port: 3002, scriptName: "web", }); @@ -220,9 +228,10 @@ describe("script-route-branch-handler", () => { }); const routeStore = new ScriptRouteStore(); registerRoute(routeStore, { - hostname: "feature-auth.api.localhost", + hostname: "api.feature-auth.repo.localhost", port: 3001, workspaceId: workspace.repoDir, + projectSlug: "repo", scriptName: "api", }); @@ -237,9 +246,10 @@ describe("script-route-branch-handler", () => { expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([ { - hostname: "feature-billing.api.localhost", + hostname: "api.feature-billing.repo.localhost", port: 3001, workspaceId: workspace.repoDir, + projectSlug: "repo", scriptName: "api", }, ]); diff --git a/packages/server/src/server/script-route-branch-handler.ts b/packages/server/src/server/script-route-branch-handler.ts index 5d20a553d..99b46bb65 100644 --- a/packages/server/src/server/script-route-branch-handler.ts +++ b/packages/server/src/server/script-route-branch-handler.ts @@ -26,7 +26,11 @@ export function createBranchChangeRouteHandler( const updates: RouteHostnameUpdate[] = []; for (const route of routes) { - const newHostname = buildScriptHostname(newBranch, route.scriptName); + const newHostname = buildScriptHostname({ + projectSlug: route.projectSlug, + branchName: newBranch, + scriptName: route.scriptName, + }); if (newHostname !== route.hostname) { updates.push({ oldHostname: route.hostname, @@ -46,6 +50,7 @@ export function createBranchChangeRouteHandler( hostname: newHostname, port: route.port, workspaceId: route.workspaceId, + projectSlug: route.projectSlug, scriptName: route.scriptName, }); options.logger?.info( diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index da13785c2..579499086 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -93,9 +93,9 @@ describe("script-status-projection", () => { { scriptName: "web", type: "service", - hostname: "web.localhost", + hostname: "web.repo.localhost", port: 3000, - proxyUrl: "http://web.localhost:6767", + proxyUrl: "http://web.repo.localhost:6767", lifecycle: "stopped", health: null, exitCode: null, @@ -118,9 +118,10 @@ describe("script-status-projection", () => { }); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "feature-card.web.localhost", + hostname: "web.feature-card.repo.localhost", port: 4321, workspaceId, + projectSlug: "repo", scriptName: "web", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -147,9 +148,9 @@ describe("script-status-projection", () => { { scriptName: "web", type: "service", - hostname: "feature-card.web.localhost", + hostname: "web.feature-card.repo.localhost", port: 4321, - proxyUrl: "http://feature-card.web.localhost:6767", + proxyUrl: "http://web.feature-card.repo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -171,9 +172,10 @@ describe("script-status-projection", () => { }); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "web.localhost", + hostname: "web.repo.localhost", port: 4321, workspaceId, + projectSlug: "repo", scriptName: "web", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -200,9 +202,9 @@ describe("script-status-projection", () => { { scriptName: "web", type: "service", - hostname: "web.localhost", + hostname: "web.repo.localhost", port: 4321, - proxyUrl: "http://web.localhost:6767", + proxyUrl: "http://web.repo.localhost:6767", lifecycle: "running", health: null, exitCode: null, @@ -218,9 +220,10 @@ describe("script-status-projection", () => { const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "docs.localhost", + hostname: "docs.repo.localhost", port: 3002, workspaceId, + projectSlug: "repo", scriptName: "docs", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -246,9 +249,9 @@ describe("script-status-projection", () => { { scriptName: "docs", type: "service", - hostname: "docs.localhost", + hostname: "docs.repo.localhost", port: 3002, - proxyUrl: "http://docs.localhost:6767", + proxyUrl: "http://docs.repo.localhost:6767", lifecycle: "running", health: null, exitCode: null, @@ -311,9 +314,10 @@ describe("script-status-projection", () => { }); const routeStore = new ScriptRouteStore(); routeStore.registerRoute({ - hostname: "api.localhost", + hostname: "api.repo.localhost", port: 3001, workspaceId, + projectSlug: "repo", scriptName: "api", }); const runtimeStore = new WorkspaceScriptRuntimeStore(); @@ -340,7 +344,7 @@ describe("script-status-projection", () => { emitUpdate(workspaceId, [ { scriptName: "api", - hostname: "api.localhost", + hostname: "api.repo.localhost", port: 3001, health: "healthy", }, @@ -355,9 +359,9 @@ describe("script-status-projection", () => { { scriptName: "api", type: "service", - hostname: "api.localhost", + hostname: "api.repo.localhost", port: 3001, - proxyUrl: "http://api.localhost:6767", + proxyUrl: "http://api.repo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 8b0e865b0..7da82a582 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -5,7 +5,7 @@ import type { } from "../shared/messages.js"; import { buildScriptHostname } from "../utils/script-hostname.js"; import { getScriptConfigs, isServiceScript } from "../utils/worktree.js"; -import { readGitCommand } from "./workspace-git-metadata.js"; +import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js"; import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; @@ -30,10 +30,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n return daemonPort; } -function resolveWorkspaceBranchName(workspaceDirectory: string): string | null { - return readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); -} - function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null { if (daemonPort === null) { return null; @@ -48,30 +44,6 @@ function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload[ return health; } -function createConfiguredPayload(input: { - scriptName: string; - type: WorkspaceScriptPayload["type"]; - branchName: string | null; - daemonPort: number | null; - configuredPort: number | null; -}): WorkspaceScriptPayload { - const hostname = - input.type === "service" - ? buildScriptHostname(input.branchName, input.scriptName) - : input.scriptName; - - return { - scriptName: input.scriptName, - type: input.type, - hostname, - port: input.type === "service" ? input.configuredPort : null, - proxyUrl: input.type === "service" ? toServiceProxyUrl(hostname, input.daemonPort) : null, - lifecycle: "stopped", - health: null, - exitCode: null, - }; -} - function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayload[] { return payloads.sort((left, right) => left.scriptName.localeCompare(right.scriptName, undefined, { @@ -86,7 +58,8 @@ export function buildWorkspaceScriptPayloads( ): WorkspaceScriptPayload[] { const workspaceId = options.workspaceId; const workspaceDirectory = options.workspaceDirectory; - const branchName = resolveWorkspaceBranchName(workspaceDirectory); + const projectSlug = deriveProjectSlug(workspaceDirectory); + const branchName = readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); const scriptConfigs = getScriptConfigs(workspaceDirectory); const runtimeEntries = new Map( options.runtimeStore @@ -102,32 +75,29 @@ export function buildWorkspaceScriptPayloads( const payloads: WorkspaceScriptPayload[] = []; for (const [scriptName, config] of scriptConfigs.entries()) { - const type = isServiceScript(config) ? "service" : "script"; + const configIsService = isServiceScript(config); + const type = configIsService ? "service" : "script"; + const configuredPort = configIsService ? (config.port ?? null) : null; const runtimeEntry = runtimeEntries.get(scriptName) ?? null; const routeEntry = routesByScriptName.get(scriptName) ?? null; - - const payload = createConfiguredPayload({ - scriptName, - type, - branchName, - daemonPort: options.daemonPort, - configuredPort: isServiceScript(config) ? (config.port ?? null) : null, - }); + const hostname = + type === "service" + ? (routeEntry?.hostname ?? + buildScriptHostname({ + projectSlug, + branchName, + scriptName, + })) + : scriptName; payloads.push({ - ...payload, - hostname: - type === "service" ? (routeEntry?.hostname ?? payload.hostname) : payload.scriptName, - port: type === "service" ? (routeEntry?.port ?? payload.port) : null, - proxyUrl: - type === "service" - ? toServiceProxyUrl(routeEntry?.hostname ?? payload.hostname, options.daemonPort) - : null, - lifecycle: runtimeEntry?.lifecycle ?? payload.lifecycle, - health: - type === "service" - ? toWireHealth(options.resolveHealth?.(routeEntry?.hostname ?? payload.hostname) ?? null) - : null, + scriptName, + type, + hostname, + port: type === "service" ? (routeEntry?.port ?? configuredPort) : null, + proxyUrl: type === "service" ? toServiceProxyUrl(hostname, options.daemonPort) : null, + lifecycle: runtimeEntry?.lifecycle ?? "stopped", + health: type === "service" ? toWireHealth(options.resolveHealth?.(hostname) ?? null) : null, exitCode: runtimeEntry?.exitCode ?? null, }); } @@ -141,7 +111,12 @@ export function buildWorkspaceScriptPayloads( const type = runtimeEntry.type; const hostname = type === "service" - ? (routeEntry?.hostname ?? buildScriptHostname(branchName, runtimeEntry.scriptName)) + ? (routeEntry?.hostname ?? + buildScriptHostname({ + projectSlug, + branchName, + scriptName: runtimeEntry.scriptName, + })) : runtimeEntry.scriptName; payloads.push({ scriptName: runtimeEntry.scriptName, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fbf12afd5..84e2dcd9b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -75,7 +75,7 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; -import { readGitCommand } from "./workspace-git-metadata.js"; +import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; @@ -6378,6 +6378,7 @@ export class Session { const serviceResult = await spawnWorkspaceScript({ repoRoot: workspace.cwd, workspaceId: workspace.workspaceId, + projectSlug: deriveProjectSlug(workspace.cwd), branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"), scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, diff --git a/packages/server/src/server/workspace-git-metadata.test.ts b/packages/server/src/server/workspace-git-metadata.test.ts new file mode 100644 index 000000000..ebfe27056 --- /dev/null +++ b/packages/server/src/server/workspace-git-metadata.test.ts @@ -0,0 +1,166 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { deriveProjectSlug, parseGitHubRepoNameFromRemote } from "./workspace-git-metadata.js"; + +function runGit(cwd: string, args: string[]): void { + execFileSync("git", args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function createWorkspace(directoryName: string): string { + return mkdtempSync(path.join(os.tmpdir(), `${directoryName}-`)); +} + +function createGitWorkspace(directoryName: string, remoteUrl?: string): string { + const cwd = createWorkspace(directoryName); + runGit(cwd, ["init"]); + if (remoteUrl !== undefined) { + runGit(cwd, ["config", "remote.origin.url", remoteUrl]); + } + return cwd; +} + +describe("parseGitHubRepoNameFromRemote", () => { + test.each([ + ["https://github.com/anthropics/claude-code.git", "claude-code"], + ["http://github.com/anthropics/claude-code.git", "claude-code"], + ["git@github.com:anthropics/claude-code.git", "claude-code"], + ["ssh://git@github.com/anthropics/claude-code.git", "claude-code"], + ["https://github.com/anthropics/claude-code", "claude-code"], + ["https://github.com/acme/repo.with.dots.git", "repo.with.dots"], + ["https://github.com/acme/Claude Code.git", "Claude Code"], + ["https://github.com/acme/Repo_Name! 2026.git", "Repo_Name! 2026"], + ])("extracts %s as %s", (remoteUrl, repoName) => { + expect(parseGitHubRepoNameFromRemote(remoteUrl)).toBe(repoName); + }); + + test("returns null for non-GitHub remotes", () => { + expect(parseGitHubRepoNameFromRemote("git@gitlab.com:anthropics/claude-code.git")).toBeNull(); + }); + + test.each([ + "https://gitlab.example/mirror/github.com/acme/claude-code.git", + "ssh://git@gitlab.example/mirror/github.com/acme/claude-code.git", + ])("returns null for embedded GitHub paths in non-GitHub remotes: %s", (remoteUrl) => { + expect(parseGitHubRepoNameFromRemote(remoteUrl)).toBeNull(); + }); +}); + +describe("deriveProjectSlug", () => { + let tmpDirs: string[]; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const tmpDir of tmpDirs) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + function track(cwd: string): string { + tmpDirs.push(cwd); + return cwd; + } + + test.each([ + ["https://github.com/acme/repo.with.dots.git", "repo-with-dots"], + ["http://github.com/acme/http-repo.git", "http-repo"], + ["git@github.com:acme/scp-repo.git", "scp-repo"], + ["ssh://git@github.com/acme/ssh-repo.git", "ssh-repo"], + ["https://github.com/acme/Claude Code.git", "claude-code"], + ["https://github.com/acme/Repo_Name! 2026.git", "repo-name-2026"], + ])("slugifies the GitHub repo name from %s", (remoteUrl, expectedSlug) => { + const cwd = track(createGitWorkspace("fallback-name", remoteUrl)); + + expect(deriveProjectSlug(cwd)).toBe(expectedSlug); + }); + + test("uses only the repo name, so identical repo names collide across owners", () => { + const acmeCwd = track( + createGitWorkspace("acme-fallback", "https://github.com/acme/claude-code"), + ); + const otherCwd = track( + createGitWorkspace("other-fallback", "https://github.com/other/claude-code"), + ); + + expect(deriveProjectSlug(acmeCwd)).toBe("claude-code"); + expect(deriveProjectSlug(otherCwd)).toBe("claude-code"); + }); + + test("falls through to the cwd basename for non-GitHub remotes", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "non-github-parent-"))); + const cwd = path.join(parentDir, "My Local Repo"); + mkdirSync(cwd); + runGit(cwd, ["init"]); + runGit(cwd, ["config", "remote.origin.url", "git@gitlab.com:acme/claude-code.git"]); + + expect(deriveProjectSlug(cwd)).toBe("my-local-repo"); + }); + + test("falls through to the cwd basename for embedded GitHub paths in non-GitHub remotes", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "embedded-github-parent-"))); + const cwd = path.join(parentDir, "Embedded GitHub Path"); + mkdirSync(cwd); + runGit(cwd, ["init"]); + runGit(cwd, [ + "config", + "remote.origin.url", + "https://gitlab.example/mirror/github.com/acme/claude-code.git", + ]); + + expect(deriveProjectSlug(cwd)).toBe("embedded-github-path"); + }); + + test("falls through to the cwd basename for an empty remote", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "empty-remote-parent-"))); + const cwd = path.join(parentDir, "Empty Remote Repo"); + mkdirSync(cwd); + runGit(cwd, ["init"]); + runGit(cwd, ["config", "remote.origin.url", ""]); + + expect(deriveProjectSlug(cwd)).toBe("empty-remote-repo"); + }); + + test("falls through to the cwd basename when the remote is missing", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "missing-remote-parent-"))); + const cwd = path.join(parentDir, "Missing Remote Repo"); + mkdirSync(cwd); + runGit(cwd, ["init"]); + + expect(deriveProjectSlug(cwd)).toBe("missing-remote-repo"); + }); + + test("uses the cwd basename for a non-git directory", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "plain-parent-"))); + const cwd = path.join(parentDir, "Plain Directory"); + mkdirSync(cwd); + + expect(deriveProjectSlug(cwd)).toBe("plain-directory"); + }); + + test("uses the cwd basename when no directory name is provided", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "basename-project-parent-"))); + const cwd = path.join(parentDir, "Basename Project!"); + mkdirSync(cwd); + + expect(deriveProjectSlug(cwd)).toBe("basename-project"); + }); + + test("uses untitled when the project source collapses to an empty hostname label", () => { + const parentDir = track(mkdtempSync(path.join(os.tmpdir(), "empty-project-parent-"))); + const cwd = path.join(parentDir, "日本語"); + mkdirSync(cwd); + + expect(deriveProjectSlug(cwd)).toBe("untitled"); + }); +}); diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts index 1af8fd192..e0a2d22d5 100644 --- a/packages/server/src/server/workspace-git-metadata.ts +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -1,4 +1,6 @@ import { execSync } from "child_process"; +import { basename } from "path"; +import { slugify } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; export type WorkspaceGitMetadata = { @@ -32,17 +34,23 @@ export function parseGitHubRepoFromRemote(remoteUrl: string): string | null { if (cleaned.startsWith("git@github.com:")) { cleaned = cleaned.slice("git@github.com:".length); - } else if (cleaned.startsWith("https://github.com/")) { - cleaned = cleaned.slice("https://github.com/".length); - } else if (cleaned.startsWith("http://github.com/")) { - cleaned = cleaned.slice("http://github.com/".length); } else { - const marker = "github.com/"; - const markerIndex = cleaned.indexOf(marker); - if (markerIndex === -1) { + let parsed: URL; + try { + parsed = new URL(cleaned); + } catch { + return null; + } + + if (parsed.hostname !== "github.com") { + return null; + } + + try { + cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); + } catch { return null; } - cleaned = cleaned.slice(markerIndex + marker.length); } if (cleaned.endsWith(".git")) { @@ -56,6 +64,23 @@ export function parseGitHubRepoFromRemote(remoteUrl: string): string | null { return cleaned; } +export function parseGitHubRepoNameFromRemote(remoteUrl: string): string | null { + const githubRepo = parseGitHubRepoFromRemote(remoteUrl); + if (!githubRepo) { + return null; + } + + const repoName = githubRepo.split("/").pop(); + return repoName && repoName.length > 0 ? repoName : null; +} + +export function deriveProjectSlug(cwd: string): string { + const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url"); + const githubRepoName = gitRemote ? parseGitHubRepoNameFromRemote(gitRemote) : null; + const sourceName = githubRepoName ?? basename(cwd); + return slugify(sourceName) || "untitled"; +} + export function detectWorkspaceGitMetadata( cwd: string, directoryName: string, diff --git a/packages/server/src/server/workspace-service-env.test.ts b/packages/server/src/server/workspace-service-env.test.ts index 199a3e62e..28343f964 100644 --- a/packages/server/src/server/workspace-service-env.test.ts +++ b/packages/server/src/server/workspace-service-env.test.ts @@ -19,6 +19,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "main", daemonPort: 6767, daemonListenHost: null, @@ -29,6 +30,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "main", daemonPort: 6767, daemonListenHost: "localhost", @@ -41,6 +43,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "main", daemonPort: 6767, daemonListenHost: "100.64.0.20", @@ -53,6 +56,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "main", daemonPort: 6767, daemonListenHost: null, @@ -61,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => { ).toEqual({ HOST: "127.0.0.1", PASEO_PORT: "5173", - PASEO_URL: "http://daemon.localhost:6767", + PASEO_URL: "http://daemon.paseo.localhost:6767", PASEO_SERVICE_DAEMON_PORT: "5173", - PASEO_SERVICE_DAEMON_URL: "http://daemon.localhost:6767", + PASEO_SERVICE_DAEMON_URL: "http://daemon.paseo.localhost:6767", }); }); @@ -71,6 +75,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "feature-x", daemonPort: 6767, daemonListenHost: null, @@ -79,15 +84,16 @@ describe("buildWorkspaceServiceEnv", () => { ).toEqual({ HOST: "127.0.0.1", PASEO_PORT: "5173", - PASEO_URL: "http://feature-x.daemon.localhost:6767", + PASEO_URL: "http://daemon.feature-x.paseo.localhost:6767", PASEO_SERVICE_DAEMON_PORT: "5173", - PASEO_SERVICE_DAEMON_URL: "http://feature-x.daemon.localhost:6767", + PASEO_SERVICE_DAEMON_URL: "http://daemon.feature-x.paseo.localhost:6767", }); }); it("omits PORT while keeping PASEO_PORT", () => { const env = buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "main", daemonPort: 6767, daemonListenHost: null, @@ -102,6 +108,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "daemon", + projectSlug: "paseo", branchName: "main", daemonPort: null, daemonListenHost: null, @@ -118,6 +125,7 @@ describe("buildWorkspaceServiceEnv", () => { expect( buildWorkspaceServiceEnv({ scriptName: "web", + projectSlug: "paseo", branchName: "feature-x", daemonPort: 6767, daemonListenHost: null, @@ -129,11 +137,11 @@ describe("buildWorkspaceServiceEnv", () => { ).toEqual({ HOST: "127.0.0.1", PASEO_PORT: "5173", - PASEO_URL: "http://feature-x.web.localhost:6767", + PASEO_URL: "http://web.feature-x.paseo.localhost:6767", PASEO_SERVICE_API_PORT: "4000", - PASEO_SERVICE_API_URL: "http://feature-x.api.localhost:6767", + PASEO_SERVICE_API_URL: "http://api.feature-x.paseo.localhost:6767", PASEO_SERVICE_WEB_PORT: "5173", - PASEO_SERVICE_WEB_URL: "http://feature-x.web.localhost:6767", + PASEO_SERVICE_WEB_URL: "http://web.feature-x.paseo.localhost:6767", }); }); @@ -141,6 +149,7 @@ describe("buildWorkspaceServiceEnv", () => { expect(() => buildWorkspaceServiceEnv({ scriptName: "app-server", + projectSlug: "paseo", branchName: "main", daemonPort: 6767, daemonListenHost: null, diff --git a/packages/server/src/server/workspace-service-env.ts b/packages/server/src/server/workspace-service-env.ts index a81f6bcaf..61bf97725 100644 --- a/packages/server/src/server/workspace-service-env.ts +++ b/packages/server/src/server/workspace-service-env.ts @@ -7,6 +7,7 @@ export interface WorkspaceServicePeer { export interface BuildWorkspaceServiceEnvOptions { scriptName: string; + projectSlug: string; branchName: string | null; daemonPort: number | null | undefined; daemonListenHost: string | null | undefined; @@ -38,6 +39,7 @@ export function buildWorkspaceServiceEnv( if (options.daemonPort !== null && options.daemonPort !== undefined) { env.PASEO_URL = buildServiceProxyUrl({ + projectSlug: options.projectSlug, branchName: options.branchName, scriptName: options.scriptName, daemonPort: options.daemonPort, @@ -50,6 +52,7 @@ export function buildWorkspaceServiceEnv( if (options.daemonPort !== null && options.daemonPort !== undefined) { env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({ + projectSlug: options.projectSlug, branchName: options.branchName, scriptName: peer.scriptName, daemonPort: options.daemonPort, @@ -65,13 +68,18 @@ export function resolveServiceBindHost(daemonListenHost: string | null | undefin } interface BuildServiceProxyUrlOptions { + projectSlug: string; branchName: string | null; scriptName: string; daemonPort: number; } function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string { - const hostname = buildScriptHostname(options.branchName, options.scriptName); + const hostname = buildScriptHostname({ + projectSlug: options.projectSlug, + branchName: options.branchName, + scriptName: options.scriptName, + }); return `http://${hostname}:${options.daemonPort}`; } diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 06ec7ef1f..e5dea0ddb 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -12,6 +12,7 @@ import { } from "./worktree-bootstrap.js"; import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptRouteStore } from "./script-proxy.js"; +import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { createWorktree as createWorktreePrimitive, @@ -726,6 +727,7 @@ describe("runAsyncWorktreeBootstrap", () => { const result = await spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, + projectSlug: "repo", branchName: "feature-socket-service", scriptName: "api", daemonPort: 6767, @@ -737,9 +739,10 @@ describe("runAsyncWorktreeBootstrap", () => { expect(result.scriptName).toBe("api"); expect(routeStore.listRoutes()).toEqual([ { - hostname: "feature-socket-service.api.localhost", + hostname: "api.feature-socket-service.repo.localhost", port: expect.any(Number), workspaceId: repoDir, + projectSlug: "repo", scriptName: "api", }, ]); @@ -750,13 +753,13 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( - "http://feature-socket-service.api.localhost:6767", + "http://api.feature-socket-service.repo.localhost:6767", ); expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe( createTerminalCalls[0]?.env?.PASEO_PORT, ); expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe( - "http://feature-socket-service.api.localhost:6767", + "http://api.feature-socket-service.repo.localhost:6767", ); const plannedPorts = await ensureWorkspaceServicePortPlan({ workspaceId: repoDir, @@ -773,7 +776,7 @@ describe("runAsyncWorktreeBootstrap", () => { String(plannedAppServerPort), ); expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe( - "http://feature-socket-service.app-server.localhost:6767", + "http://app-server.feature-socket-service.repo.localhost:6767", ); expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "api" })).toMatchObject({ type: "service", @@ -813,6 +816,7 @@ describe("runAsyncWorktreeBootstrap", () => { const firstResult = await spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, + projectSlug: "repo", branchName: "feature-respawn-service", scriptName: "api", daemonPort: 6767, @@ -824,6 +828,7 @@ describe("runAsyncWorktreeBootstrap", () => { const workerResult = await spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, + projectSlug: "repo", branchName: "feature-respawn-service", scriptName: "worker", daemonPort: 6767, @@ -852,11 +857,12 @@ describe("runAsyncWorktreeBootstrap", () => { lifecycle: "stopped", exitCode: 0, }); - expect(routeStore.getRouteEntry("feature-respawn-service.api.localhost")).toBeNull(); + expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toBeNull(); const secondResult = await spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, + projectSlug: "repo", branchName: "feature-respawn-service", scriptName: "api", daemonPort: 6767, @@ -873,14 +879,73 @@ describe("runAsyncWorktreeBootstrap", () => { expect(secondPort).not.toBe(firstPort); expect(secondPort).toEqual(expect.any(Number)); expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort)); - expect(routeStore.getRouteEntry("feature-respawn-service.api.localhost")).toMatchObject({ - hostname: "feature-respawn-service.api.localhost", + expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toMatchObject({ + hostname: "api.feature-respawn-service.repo.localhost", port: secondPort, workspaceId: repoDir, + projectSlug: "repo", scriptName: "api", }); }); + it("removes the current service route on exit after a branch rename", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + api: { + type: "service", + command: "npm run api", + }, + }, + }), + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add renamed service script config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + + await spawnWorkspaceScript({ + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-before-rename", + scriptName: "api", + daemonPort: 6767, + routeStore, + runtimeStore, + terminalManager, + }); + + const updateRoutesForBranchChange = createBranchChangeRouteHandler({ + routeStore, + onRoutesChanged: () => {}, + }); + updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename"); + + expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([ + expect.objectContaining({ + hostname: "api.feature-after-rename.repo.localhost", + scriptName: "api", + }), + ]); + + const terminal = terminalRecords[0]; + if (!terminal) { + throw new Error("Expected terminal record"); + } + terminal.triggerExit(0); + + expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([]); + }); + it("fails normalized service env name collisions before terminal creation", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -911,6 +976,7 @@ describe("runAsyncWorktreeBootstrap", () => { spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, + projectSlug: "repo", branchName: "feature-collision-service", scriptName: "app-server", daemonPort: 6767, @@ -922,7 +988,9 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls).toHaveLength(0); expect(routeStore.listRoutes()).toEqual([]); - expect(routeStore.getRouteEntry("feature-collision-service.app-server.localhost")).toBeNull(); + expect( + routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"), + ).toBeNull(); writeFileSync( join(repoDir, "paseo.json"), @@ -943,6 +1011,7 @@ describe("runAsyncWorktreeBootstrap", () => { await spawnWorkspaceScript({ repoRoot: repoDir, workspaceId: repoDir, + projectSlug: "repo", branchName: "feature-collision-service", scriptName: "app-server", daemonPort: 6767, @@ -1012,16 +1081,16 @@ describe("runAsyncWorktreeBootstrap", () => { const apiEnv = readEnvFile(apiEnvPath); const webEnv = readEnvFile(webEnvPath); - expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://feature-peer-env.api.localhost:6767"); - expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://feature-peer-env.web.localhost:6767"); + expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); + expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/)); expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/)); expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL); expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); expect(apiEnv).not.toHaveProperty("PORT"); - expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://feature-peer-env.api.localhost:6767"); - expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://feature-peer-env.web.localhost:6767"); + expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); + expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT); expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL); @@ -1034,15 +1103,17 @@ describe("runAsyncWorktreeBootstrap", () => { expect(Number.isInteger(webPort)).toBe(true); expect(routeStore.listRoutes()).toEqual([ { - hostname: "feature-peer-env.api.localhost", + hostname: "api.feature-peer-env.repo.localhost", port: apiPort, workspaceId: repoDir, + projectSlug: "repo", scriptName: "api", }, { - hostname: "feature-peer-env.web.localhost", + hostname: "web.feature-peer-env.repo.localhost", port: webPort, workspaceId: repoDir, + projectSlug: "repo", scriptName: "web", }, ]); @@ -1084,7 +1155,7 @@ describe("runAsyncWorktreeBootstrap", () => { expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0"); expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( - "http://feature-remote-service.web.localhost:6767", + "http://web.feature-remote-service.repo.localhost:6767", ); }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index e53b6bafa..edb93b0b3 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -3,6 +3,7 @@ import type { Logger } from "pino"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { buildScriptHostname } from "../utils/script-hostname.js"; +import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { getScriptConfigs, getWorktreeTerminalSpecs, @@ -693,6 +694,7 @@ interface WorkspaceServiceDeclaration { interface SpawnWorkspaceScriptOptions { repoRoot: string; workspaceId: string; + projectSlug: string; branchName: string | null; scriptName: string; daemonPort?: number | null; @@ -710,6 +712,7 @@ export async function spawnWorkspaceScript( const { repoRoot, workspaceId, + projectSlug, branchName, scriptName, daemonPort, @@ -727,7 +730,7 @@ export async function spawnWorkspaceScript( } const serviceScript = isServiceScript(config); - const hostname = serviceScript ? buildScriptHostname(branchName, scriptName) : null; + let hostname: string | null = null; let port: number | null = null; let terminal: TerminalSession | null = null; let runtimeRegistered = false; @@ -740,6 +743,12 @@ export async function spawnWorkspaceScript( let env: Record | undefined; if (serviceScript) { + const serviceHostname = buildScriptHostname({ + projectSlug, + branchName, + scriptName, + }); + hostname = serviceHostname; const serviceDeclarations: WorkspaceServiceDeclaration[] = []; for (const [configuredScriptName, scriptConfig] of scriptConfigs) { if (isServiceScript(scriptConfig)) { @@ -780,6 +789,7 @@ export async function spawnWorkspaceScript( env = buildWorkspaceServiceEnv({ scriptName, + projectSlug, branchName, daemonPort, daemonListenHost, @@ -787,9 +797,10 @@ export async function spawnWorkspaceScript( }); routeStore.registerRoute({ - hostname: hostname!, + hostname: serviceHostname, port: servicePort, workspaceId, + projectSlug, scriptName, }); routeRegistered = true; @@ -813,7 +824,7 @@ export async function spawnWorkspaceScript( terminal.onExit((info) => { if (hostname) { - routeStore.removeRoute(hostname); + routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName }); } runtimeStore.set({ workspaceId, @@ -894,6 +905,7 @@ export async function spawnWorktreeScripts(options: { onLifecycleChanged?: () => void; }): Promise { const { repoRoot } = options; + const projectSlug = deriveProjectSlug(repoRoot); const scriptConfigs = getScriptConfigs(repoRoot); if (scriptConfigs.size === 0) { return []; @@ -904,6 +916,7 @@ export async function spawnWorktreeScripts(options: { results.push( await spawnWorkspaceScript({ ...options, + projectSlug, scriptName, }), ); diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 848be6a4f..7d5f1361a 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -138,9 +138,9 @@ describe("workspace message schemas", () => { scripts: [ { scriptName: "web", - hostname: "web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", }, @@ -157,9 +157,9 @@ describe("workspace message schemas", () => { { scriptName: "web", type: "service", - hostname: "web.localhost", + hostname: "web.paseo.localhost", port: 3000, - proxyUrl: "http://web.localhost:6767", + proxyUrl: "http://web.paseo.localhost:6767", lifecycle: "running", health: "healthy", exitCode: null, @@ -204,7 +204,7 @@ describe("workspace message schemas", () => { scripts: [ { scriptName: "web", - hostname: "web.localhost", + hostname: "web.paseo.localhost", port: null, proxyUrl: null, lifecycle: "stopped", diff --git a/packages/server/src/utils/script-hostname.test.ts b/packages/server/src/utils/script-hostname.test.ts index ec16b44ef..babff3818 100644 --- a/packages/server/src/utils/script-hostname.test.ts +++ b/packages/server/src/utils/script-hostname.test.ts @@ -2,28 +2,70 @@ import { describe, expect, it } from "vitest"; import { buildScriptHostname } from "./script-hostname.js"; describe("buildScriptHostname", () => { - it("slugifies script names with spaces on default branches", () => { - expect(buildScriptHostname(null, "npm run dev")).toBe("npm-run-dev.localhost"); + it("builds default branch hostnames with script and project labels", () => { + expect( + buildScriptHostname({ + projectSlug: "paseo", + branchName: null, + scriptName: "web", + }), + ).toBe("web.paseo.localhost"); }); - it("slugifies script names with special characters", () => { - expect(buildScriptHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost"); + it("omits the branch label for main and master", () => { + expect( + buildScriptHostname({ + projectSlug: "paseo", + branchName: "main", + scriptName: "web", + }), + ).toBe("web.paseo.localhost"); + expect( + buildScriptHostname({ + projectSlug: "paseo", + branchName: "master", + scriptName: "web", + }), + ).toBe("web.paseo.localhost"); }); - it("omits the branch prefix for main and master", () => { - expect(buildScriptHostname("main", "npm run dev")).toBe("npm-run-dev.localhost"); - expect(buildScriptHostname("master", "npm run dev")).toBe("npm-run-dev.localhost"); + it("builds non-default branch hostnames with script, branch, and project labels", () => { + expect( + buildScriptHostname({ + projectSlug: "paseo", + branchName: "feature-auth", + scriptName: "web", + }), + ).toBe("web.feature-auth.paseo.localhost"); }); - it("adds a slugified branch prefix for non-default branches", () => { - expect(buildScriptHostname("feature/cool stuff", "api")).toBe( - "feature-cool-stuff.api.localhost", - ); + it("slugifies script, default branch project, and non-default branch labels", () => { + expect( + buildScriptHostname({ + projectSlug: "Paseo App", + branchName: "Feature/Auth Flow", + scriptName: "Web/API @ Dev", + }), + ).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost"); }); - it("slugifies both the branch name and script name together", () => { - expect(buildScriptHostname("feat/add auth", "npm run dev")).toBe( - "feat-add-auth.npm-run-dev.localhost", - ); + it("accepts already slugified labels because slugify is idempotent", () => { + expect( + buildScriptHostname({ + projectSlug: "paseo-app", + branchName: "feature-auth-flow", + scriptName: "web-api-dev", + }), + ).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost"); + }); + + it("uses untitled as the hostname-label fallback when labels collapse to empty", () => { + expect( + buildScriptHostname({ + projectSlug: "日本語", + branchName: "***", + scriptName: "---", + }), + ).toBe("untitled.untitled.untitled.localhost"); }); }); diff --git a/packages/server/src/utils/script-hostname.ts b/packages/server/src/utils/script-hostname.ts index 332908369..15a271b5a 100644 --- a/packages/server/src/utils/script-hostname.ts +++ b/packages/server/src/utils/script-hostname.ts @@ -1,12 +1,27 @@ import { slugify } from "./worktree.js"; -export function buildScriptHostname(branchName: string | null, scriptName: string): string { - const serviceHostnameLabel = slugify(scriptName); +type BuildScriptHostnameOptions = { + projectSlug: string; + branchName: string | null; + scriptName: string; +}; + +function toHostnameLabel(value: string): string { + return slugify(value) || "untitled"; +} + +export function buildScriptHostname({ + projectSlug, + branchName, + scriptName, +}: BuildScriptHostnameOptions): string { + const serviceHostnameLabel = toHostnameLabel(scriptName); + const projectHostnameLabel = toHostnameLabel(projectSlug); const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master"; if (isDefaultBranch) { - return `${serviceHostnameLabel}.localhost`; + return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`; } - return `${slugify(branchName)}.${serviceHostnameLabel}.localhost`; + return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`; } diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 89532dc7d..87d024d1b 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -1034,9 +1034,41 @@ describe("paseo worktree manager", () => { }); describe("slugify", () => { + function expectValidHostnameLabel(label: string): void { + expect(label.length).toBeGreaterThan(0); + expect(label.length).toBeLessThanOrEqual(63); + expect(label).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); + } + it("converts to lowercase kebab-case", () => { expect(slugify("Hello World")).toBe("hello-world"); expect(slugify("FOO_BAR")).toBe("foo-bar"); + expect(slugify("My GREAT App")).toBe("my-great-app"); + }); + + it("replaces dots with hyphens", () => { + expect(slugify("my.app")).toBe("my-app"); + expect(slugify("v1.2.3")).toBe("v1-2-3"); + }); + + it("collapses multiple consecutive spaces to one hyphen", () => { + expect(slugify("feature cool stuff")).toBe("feature-cool-stuff"); + }); + + it("replaces slashes with hyphens", () => { + expect(slugify("feature/cool stuff")).toBe("feature-cool-stuff"); + expect(slugify("owner/repo")).toBe("owner-repo"); + }); + + it("strips unsupported unicode characters", () => { + expect(slugify("café")).toBe("caf"); + expect(slugify("日本語")).toBe(""); + }); + + it("removes leading and trailing punctuation", () => { + expect(slugify("-foo-")).toBe("foo"); + expect(slugify("__bar__")).toBe("bar"); + expect(slugify(".baz.")).toBe("baz"); }); it("truncates long strings at word boundary", () => { @@ -1044,6 +1076,7 @@ describe("slugify", () => { "https-stackoverflow-com-questions-68349031-only-run-actions-on-non-draft-pull-request"; const result = slugify(longInput); expect(result.length).toBeLessThanOrEqual(50); + expectValidHostnameLabel(result); expect(result).toBe("https-stackoverflow-com-questions-68349031-only"); }); @@ -1052,5 +1085,35 @@ describe("slugify", () => { const result = slugify(longInput); expect(result.length).toBe(50); expect(result.endsWith("-")).toBe(false); + expectValidHostnameLabel(result); + }); + + it("keeps very long names within the hostname label length limit", () => { + const result = slugify("Release Candidate ".repeat(12)); + + expect(result.length).toBeLessThanOrEqual(63); + expectValidHostnameLabel(result); + }); + + it("returns empty when names collapse to empty", () => { + expect(slugify("---")).toBe(""); + expect(slugify("***")).toBe(""); + expect(slugify("日本語")).toBe(""); + }); + + it("is idempotent for representative inputs", () => { + const inputs = [ + "my.app", + "feature/cool stuff", + " Café Launch ", + "__bar__", + "Release Candidate ".repeat(12), + "release***candidate", + ]; + + for (const input of inputs) { + const slug = slugify(input); + expect(slugify(slug)).toBe(slug); + } }); }); diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 2d418f4cb..e28ec048a 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -615,9 +615,9 @@ function SelfHostedSection() { function ServiceProxySection() { const workspaces = [ - { name: "fix-auth", url: "fix-auth.my-app.localhost" }, - { name: "add-search", url: "add-search.my-app.localhost" }, - { name: "upgrade-deps", url: "upgrade-deps.my-app.localhost" }, + { name: "fix-auth", url: "web.fix-auth.my-app.localhost" }, + { name: "add-search", url: "web.add-search.my-app.localhost" }, + { name: "upgrade-deps", url: "web.upgrade-deps.my-app.localhost" }, ]; return (