feat(server,app,website): prefix service URLs with project slug

Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.

Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
  and slugifies all labels at the boundary; `untitled` fallback only
  applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
  the slug; GitHub URL parsing now requires `host === "github.com"` to
  avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
  it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
  when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
  cleanup after a branch rename doesn't leave a stale entry behind.

App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.

Landing-page service URL examples updated; allowlist docs unchanged.
This commit is contained in:
Mohamed Boudra
2026-04-18 19:31:23 +07:00
parent 23b20169ef
commit 7819019cea
23 changed files with 680 additions and 224 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",
});
});
});

View File

@@ -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",
},

View File

@@ -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) => {

View File

@@ -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<string, ScriptRouteEntry>();
private workspaceHostnames = new Map<string, Set<string>>();
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) {

View File

@@ -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",
},
]);

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}`;
}

View File

@@ -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",
);
});
});

View File

@@ -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<string, string> | 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<WorktreeScriptResult[]> {
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,
}),
);

View File

@@ -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",

View File

@@ -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");
});
});

View File

@@ -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`;
}

View File

@@ -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);
}
});
});

View File

@@ -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 (