Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/api/register-manifest.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"]);
});
25 changes: 25 additions & 0 deletions src/api/register-manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }) },
Expand All@@ -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
Expand Down