mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
166
packages/server/src/server/workspace-git-metadata.test.ts
Normal file
166
packages/server/src/server/workspace-git-metadata.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user