From d3ee9a8904a02ced9a02641fba07cee96f46149e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 22 Oct 2026 19:04:42 +0200 Subject: [PATCH 1/2] feat(platform): expose deployment command controls --- .changeset/platform-command-controls.md | 7 + .../wit/druid-plattform.wit | 35 +++++ .../druid-plattform/wit/druid-plattform.wit | 35 +++++ .../wit/druid-plattform.wit | 35 +++++ packages/plattform/src/index.ts | 66 ++++++++++ packages/plattform/wit/druid-plattform.wit | 35 +++++ tests/async-wit-contract.test.ts | 9 ++ tests/plattform-command-controls.test.ts | 124 ++++++++++++++++++ vitest.config.ts | 7 + 9 files changed, 353 insertions(+) create mode 100644 .changeset/platform-command-controls.md create mode 100644 tests/plattform-command-controls.test.ts diff --git a/.changeset/platform-command-controls.md b/.changeset/platform-command-controls.md new file mode 100644 index 0000000..8bbc9d7 --- /dev/null +++ b/.changeset/platform-command-controls.md @@ -0,0 +1,7 @@ +--- +"@druid-ui/plattform": minor +--- + +Add typed platform bindings for starting the deployment file service and +running or stopping deployment commands. Keep the example WIT contracts +aligned with the platform imports. diff --git a/examples/druid-plattform-no-sandbox/wit/druid-plattform.wit b/examples/druid-plattform-no-sandbox/wit/druid-plattform.wit index dfb1225..1e39fd9 100644 --- a/examples/druid-plattform-no-sandbox/wit/druid-plattform.wit +++ b/examples/druid-plattform-no-sandbox/wit/druid-plattform.wit @@ -1,9 +1,44 @@ package druid:ui; interface plattform { + record mount { + path: string, + sub-path: string, + read-only: bool, + } + + record expected-port { + name: string, + keep-alive-traffic: string, + } + + record procedure { + procedure-type: string, + id: option, + ignore-failure: bool, + image: string, + command: list, + working-dir: string, + env: list>, + expected-ports: list, + mounts: list, + target: string, + signal: string, + tty: bool, + } + + record command-instruction { + procedures: list, + needs: list, + run: string, + } + request: func(url: string, method: string, body: string, header: tuple) -> string; load-file-from-deployment: func(path: string) -> string; save-file-to-deployment: func(path: string, content: string) -> string; + ensure-file-service: func(watch: bool) -> string; + add-and-run-command: func(name: string, command: command-instruction) -> string; + stop-command: func(name: string) -> string; } world druid-plattform { diff --git a/examples/druid-plattform/wit/druid-plattform.wit b/examples/druid-plattform/wit/druid-plattform.wit index dfb1225..1e39fd9 100644 --- a/examples/druid-plattform/wit/druid-plattform.wit +++ b/examples/druid-plattform/wit/druid-plattform.wit @@ -1,9 +1,44 @@ package druid:ui; interface plattform { + record mount { + path: string, + sub-path: string, + read-only: bool, + } + + record expected-port { + name: string, + keep-alive-traffic: string, + } + + record procedure { + procedure-type: string, + id: option, + ignore-failure: bool, + image: string, + command: list, + working-dir: string, + env: list>, + expected-ports: list, + mounts: list, + target: string, + signal: string, + tty: bool, + } + + record command-instruction { + procedures: list, + needs: list, + run: string, + } + request: func(url: string, method: string, body: string, header: tuple) -> string; load-file-from-deployment: func(path: string) -> string; save-file-to-deployment: func(path: string, content: string) -> string; + ensure-file-service: func(watch: bool) -> string; + add-and-run-command: func(name: string, command: command-instruction) -> string; + stop-command: func(name: string) -> string; } world druid-plattform { diff --git a/examples/starter-component-plattform/wit/druid-plattform.wit b/examples/starter-component-plattform/wit/druid-plattform.wit index dfb1225..1e39fd9 100644 --- a/examples/starter-component-plattform/wit/druid-plattform.wit +++ b/examples/starter-component-plattform/wit/druid-plattform.wit @@ -1,9 +1,44 @@ package druid:ui; interface plattform { + record mount { + path: string, + sub-path: string, + read-only: bool, + } + + record expected-port { + name: string, + keep-alive-traffic: string, + } + + record procedure { + procedure-type: string, + id: option, + ignore-failure: bool, + image: string, + command: list, + working-dir: string, + env: list>, + expected-ports: list, + mounts: list, + target: string, + signal: string, + tty: bool, + } + + record command-instruction { + procedures: list, + needs: list, + run: string, + } + request: func(url: string, method: string, body: string, header: tuple) -> string; load-file-from-deployment: func(path: string) -> string; save-file-to-deployment: func(path: string, content: string) -> string; + ensure-file-service: func(watch: bool) -> string; + add-and-run-command: func(name: string, command: command-instruction) -> string; + stop-command: func(name: string) -> string; } world druid-plattform { diff --git a/packages/plattform/src/index.ts b/packages/plattform/src/index.ts index 24ef8dc..271ad26 100644 --- a/packages/plattform/src/index.ts +++ b/packages/plattform/src/index.ts @@ -4,7 +4,11 @@ import { request as requestRaw, loadFileFromDeployment as loadFileFromDeploymentRaw, saveFileToDeployment as saveFileToDeploymentRaw, + ensureFileService as ensureFileServiceRaw, + addAndRunCommand as addAndRunCommandRaw, + stopCommand as stopCommandRaw, } from "druid:ui/plattform"; +import type { CommandInstruction as RawCommandInstruction } from "druid:ui/plattform"; // it is very imporant that this is external, as druid-ui/component contains // functions that must be important and cannot just be copied in again // otherwise promises defined here will not resolve @@ -17,3 +21,65 @@ export const loadFileFromDeployment = rawAsyncToPromise( ); export const saveFileToDeployment = rawAsyncToPromise(saveFileToDeploymentRaw); + +export type CommandRunMode = "always" | "once" | "restart" | "persistent"; +export type ProcedureType = "container" | "signal"; + +export interface CommandMount { + path: string; + subPath: string; + readOnly: boolean; +} + +export interface CommandExpectedPort { + name: string; + keepAliveTraffic: string; +} + +export interface CommandProcedure { + type?: ProcedureType; + id?: string; + ignoreFailure?: boolean; + image?: string; + command?: string[]; + workingDir?: string; + env?: Record; + expectedPorts?: CommandExpectedPort[]; + mounts?: CommandMount[]; + target?: string; + signal?: string; + tty?: boolean; +} + +export interface CommandInstruction { + procedures: CommandProcedure[]; + needs?: string[]; + run?: CommandRunMode; +} + +const toRawCommandInstruction = (command: CommandInstruction): RawCommandInstruction => ({ + run: command.run ?? "always", + needs: command.needs ?? [], + procedures: command.procedures.map((procedure) => ({ + procedureType: procedure.type ?? "container", + ...(procedure.id === undefined ? {} : { id: procedure.id }), + ignoreFailure: procedure.ignoreFailure ?? false, + image: procedure.image ?? "", + command: procedure.command ?? [], + workingDir: procedure.workingDir ?? "", + env: Object.entries(procedure.env ?? {}), + expectedPorts: procedure.expectedPorts ?? [], + mounts: procedure.mounts ?? [], + target: procedure.target ?? "", + signal: procedure.signal ?? "", + tty: procedure.tty ?? false, + })), +}); + +export const ensureFileService = (watch = false): Promise => + rawAsyncToPromise(ensureFileServiceRaw)(watch).then(() => undefined); + +export const addAndRunCommand = (name: string, command: CommandInstruction): Promise => + rawAsyncToPromise(addAndRunCommandRaw)(name, toRawCommandInstruction(command)).then(() => undefined); + +export const stopCommand = (name: string): Promise => rawAsyncToPromise(stopCommandRaw)(name).then(() => undefined); diff --git a/packages/plattform/wit/druid-plattform.wit b/packages/plattform/wit/druid-plattform.wit index dfb1225..1e39fd9 100644 --- a/packages/plattform/wit/druid-plattform.wit +++ b/packages/plattform/wit/druid-plattform.wit @@ -1,9 +1,44 @@ package druid:ui; interface plattform { + record mount { + path: string, + sub-path: string, + read-only: bool, + } + + record expected-port { + name: string, + keep-alive-traffic: string, + } + + record procedure { + procedure-type: string, + id: option, + ignore-failure: bool, + image: string, + command: list, + working-dir: string, + env: list>, + expected-ports: list, + mounts: list, + target: string, + signal: string, + tty: bool, + } + + record command-instruction { + procedures: list, + needs: list, + run: string, + } + request: func(url: string, method: string, body: string, header: tuple) -> string; load-file-from-deployment: func(path: string) -> string; save-file-to-deployment: func(path: string, content: string) -> string; + ensure-file-service: func(watch: bool) -> string; + add-and-run-command: func(name: string, command: command-instruction) -> string; + stop-command: func(name: string) -> string; } world druid-plattform { diff --git a/tests/async-wit-contract.test.ts b/tests/async-wit-contract.test.ts index 9d08920..42a1f7e 100644 --- a/tests/async-wit-contract.test.ts +++ b/tests/async-wit-contract.test.ts @@ -2,6 +2,15 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; describe("async WIT contract", () => { + it("keeps the platform command bindings aligned with shipped examples", async () => { + const source = await readFile("packages/plattform/wit/druid-plattform.wit", "utf8"); + expect(source).toContain("ensure-file-service: func(watch: bool) -> string;"); + expect(source).toContain("add-and-run-command: func(name: string, command: command-instruction) -> string;"); + expect(source).toContain("stop-command: func(name: string) -> string;"); + for (const example of ["druid-plattform", "druid-plattform-no-sandbox", "starter-component-plattform"]) { + expect(await readFile(`examples/${example}/wit/druid-plattform.wit`, "utf8")).toBe(source); + } + }); it.each(["wit/druid-component.wit", "packages/build/wit/druid-component.wit"])( "%s transports error messages", async (path) => { diff --git a/tests/plattform-command-controls.test.ts b/tests/plattform-command-controls.test.ts new file mode 100644 index 0000000..ceaf0bd --- /dev/null +++ b/tests/plattform-command-controls.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { bridge, raw } = await vi.hoisted(async () => { + const { createAsyncBridge } = await import("../packages/component/src/async"); + return { + bridge: createAsyncBridge(), + raw: { + request: vi.fn(), + loadFileFromDeployment: vi.fn(), + saveFileToDeployment: vi.fn(), + ensureFileService: vi.fn(), + addAndRunCommand: vi.fn(), + stopCommand: vi.fn(), + }, + }; +}); + +vi.mock("druid:ui/plattform", () => raw); +vi.mock("@druid-ui/component", () => ({ rawAsyncToPromise: bridge.wrap })); + +import { + addAndRunCommand, + ensureFileService, + stopCommand, +} from "../packages/plattform/src/index"; + +describe("platform command controls", () => { + beforeEach(() => { + vi.resetAllMocks(); + raw.ensureFileService.mockReturnValue("ensure"); + raw.addAndRunCommand.mockReturnValue("run"); + raw.stopCommand.mockReturnValue("stop"); + }); + + it("lowers omitted command options to WIT defaults without inventing an id", async () => { + const result = addAndRunCommand("start", { procedures: [{}] }); + + expect(raw.addAndRunCommand).toHaveBeenCalledWith("start", { + run: "always", + needs: [], + procedures: [{ + procedureType: "container", + ignoreFailure: false, + image: "", + command: [], + workingDir: "", + env: [], + expectedPorts: [], + mounts: [], + target: "", + signal: "", + tty: false, + }], + }); + expect(raw.addAndRunCommand.mock.calls[0]?.[1].procedures[0]).not.toHaveProperty("id"); + bridge.complete("run", { tag: "ok", val: "host acknowledgement" }); + await expect(result).resolves.toBeUndefined(); + }); + + it("preserves explicit options and converts environment records to WIT tuples", async () => { + const procedure = { + type: "signal" as const, + id: "reload-config", + ignoreFailure: true, + image: "server:latest", + command: ["reload", "--force"], + workingDir: "/server", + env: { MODE: "development", EMPTY: "" }, + expectedPorts: [{ name: "web", keepAliveTraffic: "http" }], + mounts: [{ path: "/data", subPath: "world", readOnly: true }], + target: "server", + signal: "HUP", + tty: true, + }; + const result = addAndRunCommand("reload", { + run: "restart", + needs: ["install"], + procedures: [procedure], + }); + + const { type, env, ...expectedProcedure } = procedure; + expect(raw.addAndRunCommand).toHaveBeenCalledWith("reload", { + run: "restart", + needs: ["install"], + procedures: [{ + ...expectedProcedure, + procedureType: type, + env: [["MODE", "development"], ["EMPTY", ""]], + }], + }); + expect(env).toEqual({ MODE: "development", EMPTY: "" }); + bridge.complete("run", { tag: "ok", val: null }); + await expect(result).resolves.toBeUndefined(); + }); + + it.each([ + [undefined, false], + [false, false], + [true, true], + ])("forwards watch=%s to the file service as %s", async (watch, expected) => { + const result = watch === undefined ? ensureFileService() : ensureFileService(watch); + expect(raw.ensureFileService).toHaveBeenCalledWith(expected); + bridge.complete("ensure", { tag: "ok", val: "ready" }); + await expect(result).resolves.toBeUndefined(); + }); + + it("forwards the exact command name when stopping", async () => { + const result = stopCommand("custom command/name"); + expect(raw.stopCommand).toHaveBeenCalledWith("custom command/name"); + bridge.complete("stop", { tag: "ok", val: "stopped" }); + await expect(result).resolves.toBeUndefined(); + }); + + it.each([ + ["ensure", () => ensureFileService()], + ["run", () => addAndRunCommand("start", { procedures: [] })], + ["stop", () => stopCommand("start")], + ] as const)("propagates asynchronous host failures for %s", async (id, invoke) => { + const result = invoke(); + const rejection = expect(result).rejects.toThrow("host rejected operation"); + bridge.complete(id, { tag: "err", val: "host rejected operation" }); + await rejection; + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e373e70..c99861c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,13 @@ import { defineConfig } from "vite"; import { fileURLToPath } from "node:url"; export default defineConfig({ + plugins: [{ + name: "wit-platform-test-import", + // The WIT host supplies this module at runtime; tests provide its exports with vi.mock. + resolveId(id) { + if (id === "druid:ui/plattform") return id; + }, + }], resolve: { alias: { "@druid-ui/build": fileURLToPath( From 6ca0cc2c5e8d19daf532bf2a16c4908ac9f0637f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 22 Oct 2026 19:12:57 +0200 Subject: [PATCH 2/2] fix(test): resolve component source before build Vitest resolves mocked packages before applying mocks. Point the workspace alias at source so clean CI tests do not require dist output. --- vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index c99861c..b674603 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ }], resolve: { alias: { + "@druid-ui/component": fileURLToPath( + new URL("./packages/component/src/index.ts", import.meta.url), + ), "@druid-ui/build": fileURLToPath( new URL("./packages/build/src/index.ts", import.meta.url), ),