diff --git a/src/api/register-manifest.test.ts b/src/api/register-manifest.test.ts index 099afad..8b13ed7 100644 --- a/src/api/register-manifest.test.ts +++ b/src/api/register-manifest.test.ts @@ -150,3 +150,71 @@ test("registering a manifest onto a port already held by a bare portless route ( expect(r.status).toBe(409); expect(getRecord("routeconflict")).toBeUndefined(); }); + +test("port-only app reconciles a changed port via alt (route re-points)", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = appRepo({ name: "extapp", port: 4400, altConfigs: { dev: { port: 4500 } } }); + + await applyManifest(dir, undefined, drivers); + expect(getRecord("extapp")!.kind).toBe("external"); + expect(getRecord("extapp")!.port).toBe(4400); + expect(drivers.edge.aliases.get("extapp")).toBe(4400); + + const on = await applyManifest(dir, "dev", drivers); + expect(on.status).toBe(200); + expect(getRecord("extapp")!.port).toBe(4500); + expect(getRecord("extapp")!.activeAlt).toBe("dev"); + expect(drivers.edge.aliases.get("extapp")).toBe(4500); + + const off = await applyManifest(dir, undefined, drivers); + expect(off.status).toBe(200); + expect(getRecord("extapp")!.port).toBe(4400); + expect(getRecord("extapp")!.activeAlt).toBeUndefined(); + expect(drivers.edge.aliases.get("extapp")).toBe(4400); +}); + +test("adding commands.start to a route-only app is refused, not half-applied", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = appRepo({ name: "flip", port: 4600 }); + + await applyManifest(dir, undefined, drivers); + expect(getRecord("flip")!.kind).toBe("external"); + + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "flip", port: 4600, commands: { start: "bun run serve" } })); + const r = await applyManifest(dir, undefined, drivers); + expect(r.status).toBe(400); + expect(getRecord("flip")!.kind).toBe("external"); + expect(getRecord("flip")!.command).toBeUndefined(); +}); + +test("dropping commands.start on a supervised app is refused, service kept", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = appRepo({ name: "svc", port: 4700, commands: { start: "bun run serve" } }); + + await applyManifest(dir, undefined, drivers); + expect(getRecord("svc")!.kind).toBe("service"); + + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "svc", port: 4700 })); + const r = await applyManifest(dir, undefined, drivers); + expect(r.status).toBe(400); + expect(getRecord("svc")!.kind).toBe("service"); + expect(getRecord("svc")!.command).toEqual(["sh", "-c", "bun run serve"]); +}); diff --git a/src/api/register-manifest.ts b/src/api/register-manifest.ts index 4d71a41..e4d574d 100644 --- a/src/api/register-manifest.ts +++ b/src/api/register-manifest.ts @@ -49,6 +49,15 @@ export async function applyManifest( // CLI, so the caller is the app's own manager with force=true to clear // authorizeStructural (mirrors adoptApp's force-bless). Safe because the // whole mutation plane is 127.0.0.1-local and public mutations are already 403'd. + if (existing.kind !== "service") { + // external -> service: editApp keeps a record's kind, so it would set the + // command but never install launchd, leaving a route-only app with a + // command and no running service. Refuse loudly instead of half-applying. + return { + status: 400, + body: { error: `cannot add commands.start to route-only app ${manifest.name} via register; run \`deck remove ${manifest.name}\` then re-register` }, + }; + } const edited = await editApp( manifest.name, { command: shape.command, workingDirectory: dir, ...(shape.port !== undefined && { port: shape.port }) }, @@ -57,6 +66,22 @@ export async function applyManifest( drivers, ); if (edited.status !== 200) return edited; + } else if (existing.kind === "service") { + // service -> route-only: the manifest dropped commands.start on a supervised + // app. Tearing a live service down to route-only is a structural change + // editApp does not perform; silently keeping the old service is worse than + // refusing. `deck remove` + re-register is the explicit path. + return { + status: 400, + body: { error: `cannot drop commands.start on supervised app ${manifest.name} via register; run \`deck remove ${manifest.name}\` then re-register as route-only` }, + }; + } else if (shape.port !== undefined && shape.port !== existing.port) { + // Port-only (external) app whose declared or overlay port changed: propagate + // the new port and route alias. editApp's external path updates the alias + // without touching launchd, so `deck alt` actually re-routes a port-only app + // instead of returning success while the route stays on the old port. + const edited = await editApp(manifest.name, { port: shape.port }, existing.managedBy, true, drivers); + if (edited.status !== 200) return edited; } // Metadata the serve-shape flows above do not carry: action commands, the