import { afterEach, expect, test } from "bun:test"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { YAML } from "bun"; import { addAppToCompose, createCompose, type AppOptions, } from "../../lib/scaffold"; import { resolveAppInput } from "../../command/add"; import { readCompose } from "../../lib/yaml"; import type { ScaffoldQuestion } from "../../lib/scaffold-prompts"; import { listDockerfileTemplates } from "../../lib/scaffold-templates"; function snippetValues( question: ScaffoldQuestion, overrides: Record = {}, ) { return { values: { ...Object.fromEntries( (question.fields ?? []).map((field) => [ field.name, field.initial ?? "", ]), ), ...overrides, }, result: "services:\n malicious:\n image: ignored-preview", }; } const roots: string[] = []; afterEach(async () => { for (const path of roots.splice(0)) await rm(path, { recursive: true, force: true }); }); async function root() { const path = await mkdtemp(join(tmpdir(), "kuber-add-test-")); roots.push(path); return path; } test("add preserves compose metadata, existing services and comments while adding claims/resources", async () => { const cwd = await root(); const original = "name: shop\nx-owner: team\nservices:\n api:\n image: example/api:1 # keep this comment\nx-hook: untouched\n"; await writeFile(join(cwd, "compose.yml"), original); await addAppToCompose(cwd, { name: "worker", path: ".", source: { kind: "image", image: "example/worker:2" }, postgres: "user/db", s3: "key/bucket", cpu: "0.5", memory: "512m", replicas: 2, }); const updated = await readFile(join(cwd, "compose.yml"), "utf8"); expect(updated).toContain("image: example/api:1 # keep this comment"); expect(updated).toContain("x-hook: untouched"); const compose = await readCompose(join(cwd, "compose.yml")); expect(compose.services?.worker).toMatchObject({ image: "example/worker:2", volumes: ["postgresql:user/db", "s3:key/bucket"], deploy: { replicas: 2, resources: { limits: { cpus: "0.5", memory: "512m" } }, }, }); expect(compose.services?.api?.image).toBe("example/api:1"); await expect( addAppToCompose(cwd, { name: "api", path: ".", source: { kind: "image", image: "x" }, }), ).rejects.toThrow("already exists"); }); test("add inserts into an inline empty services mapping and preserves surrounding content", async () => { const cwd = await root(); const original = "# before marker\nname: demo\nservices: {}\n# after marker\nx-hook: untouched\n"; await writeFile(join(cwd, "compose.yml"), original); await addAppToCompose(cwd, { name: "web", path: ".", source: { kind: "image", image: "example/web:1" }, }); const updated = await readFile(join(cwd, "compose.yml"), "utf8"); expect(updated).toContain("# before marker\nname: demo\nservices:\n"); expect(updated).toContain("# after marker\nx-hook: untouched\n"); expect( (await readCompose(join(cwd, "compose.yml"))).services?.web?.image, ).toBe("example/web:1"); }); test("unsupported inline services mappings fail without changing the compose file", async () => { const cwd = await root(); const original = "# before marker\nname: demo\nservices: {existing: {image: busybox}}\n# after marker\n"; await writeFile(join(cwd, "compose.yml"), original); await expect( addAppToCompose(cwd, { name: "web", path: ".", source: { kind: "image", image: "example/web:1" }, }), ).rejects.toThrow("Cannot safely insert a service"); expect(await readFile(join(cwd, "compose.yml"), "utf8")).toBe(original); }); test("template creates paired files exclusively; collision rolls back and preserves compose", async () => { const cwd = await root(); await mkdir(join(cwd, "app")); await createCompose(cwd, "sample", { name: "starter", path: ".", source: { kind: "image", image: "nginx:stable" }, }); const original = await readFile(join(cwd, "compose.yml"), "utf8"); const app: AppOptions = { name: "app", path: "app", source: { kind: "template", template: "bun-service" }, }; await writeFile(join(cwd, "app", ".dockerignore"), "custom\n"); await expect(addAppToCompose(cwd, app)).rejects.toThrow(); expect(await readFile(join(cwd, "compose.yml"), "utf8")).toBe(original); expect(await Bun.file(join(cwd, "app", "Dockerfile")).exists()).toBe(false); expect(await readFile(join(cwd, "app", ".dockerignore"), "utf8")).toBe( "custom\n", ); await rm(join(cwd, "app", ".dockerignore")); await addAppToCompose(cwd, app); expect(await Bun.file(join(cwd, "app", "Dockerfile")).exists()).toBe(true); expect(await Bun.file(join(cwd, "app", ".dockerignore")).exists()).toBe(true); }); test("auto selects subproject and emits Buildpacks mode with path", async () => { const cwd = await root(); await mkdir(join(cwd, "web")); await writeFile(join(cwd, "web", "package.json"), "{}"); const app = await resolveAppInput(cwd, { nonInteractive: true }); expect(app).toMatchObject({ path: "web", source: { kind: "auto" } }); await createCompose(cwd, "sample", app); const compose = YAML.parse( await readFile(join(cwd, "compose.yml"), "utf8"), ) as { services: Record> }; expect(compose.services[app.name]!.build).toBe("auto"); expect(compose.services[app.name]!["x-kuber-build-context"]).toBe("web"); expect( (await readCompose(join(cwd, "compose.yml"))).services?.[app.name]?.build, ).toBe("auto"); expect(await Bun.file(join(cwd, "web", "Dockerfile")).exists()).toBe(false); }); test("rejects path traversal and symlink outside the project", async () => { const cwd = await root(); await createCompose(cwd, "sample", { name: "main", path: ".", source: { kind: "image", image: "nginx" }, }); await expect( addAppToCompose(cwd, { name: "bad", path: "../elsewhere", source: { kind: "auto" }, }), ).rejects.toThrow(); }); test("interactive image flow selects source before path and omits blank fields from generated YAML", async () => { const cwd = await root(); const questions: ScaffoldQuestion[] = []; const app = await resolveAppInput(cwd, {}, async (question) => { questions.push(question); if (question.name === "source") { expect(question.initial).toBe(0); return "image"; } if (question.name === "image") { expect(question.initial).toBe("nginx:stable"); return "example/web:1"; } if (question.name === "path") return "."; expect(question.type).toBe("snippet"); expect(question.template).not.toContain("managedBy"); expect(question.template).toContain('image: "example/web:1"'); expect(question.fields?.some((field) => field.name === "image")).toBe( false, ); return snippetValues(question, { name: "web", postgres: "", s3: "", cpu: "", memory: "", }); }); expect(questions.map(({ type }) => type)).toEqual([ "select", "input", "autocomplete", "snippet", ]); await createCompose(cwd, "demo", app); const compose = await readCompose(join(cwd, "compose.yml")); expect(compose.services?.web).toEqual({ image: "example/web:1", deploy: { replicas: 1 }, }); expect(compose.services?.malicious).toBeUndefined(); }); test("auto flow accepts CNB URI and valid custom directory while keeping the root context rule", async () => { const cwd = await root(); await mkdir(join(cwd, "web")); await mkdir(join(cwd, "custom")); await writeFile(join(cwd, "web", "package.json"), "{}"); const names: string[] = []; const app = await resolveAppInput(cwd, {}, async (question) => { names.push(question.name); if (question.name === "source") { expect(question.initial).toBe(1); return "auto"; } if (question.name === "cnb") { expect(question.validate?.("https://example.com/buildpack.tgz")).not.toBe( true, ); expect(question.validate?.("")).toBe(true); return "https://github.com/example/buildpack/releases/download/v1/web.cnb"; } if (question.name === "path") { expect(question.choices?.map(({ name }) => name)).toEqual(["web", "."]); expect(await question.validate?.("../outside")).not.toBe(true); expect(await question.validate?.("custom")).toBe(true); expect( await question.suggest?.("custom", question.choices!), ).toContainEqual({ name: "custom", message: "custom" }); return "custom"; } return snippetValues(question, { name: "custom" }); }); expect(names).toEqual(["source", "cnb", "path", "compose"]); await createCompose(cwd, "demo", app); expect( (await readCompose(join(cwd, "compose.yml"))).services?.custom, ).toMatchObject({ build: "auto:https://github.com/example/buildpack/releases/download/v1/web.cnb", "x-kuber-build-context": "custom", }); const rootApp = await resolveAppInput(cwd, { source: "auto", path: ".", name: "root", nonInteractive: true, cnb: "https://github.com/example/buildpack/releases/download/v1/root.cnb", }); await addAppToCompose(cwd, rootApp); expect((await readCompose(join(cwd, "compose.yml"))).services?.root).toEqual({ build: "auto:https://github.com/example/buildpack/releases/download/v1/root.cnb", }); }); test("template branch exposes all eight paired templates and locks explicitly flagged fields", async () => { const cwd = await root(); const names: string[] = []; const app = await resolveAppInput( cwd, { path: ".", name: "fixed", cpu: "0.5" }, async (question) => { names.push(question.name); if (question.name === "source") return "template"; if (question.name === "template") { expect(question.choices?.map(({ name }) => name)).toEqual( listDockerfileTemplates().map(({ id }) => id), ); expect(question.choices).toHaveLength(8); return "bun-service"; } expect( question.fields?.some(({ name }) => name === "name" || name === "cpu"), ).toBe(false); return snippetValues(question, { name: "ignored", cpu: "99", postgres: "user/db", s3: "key/bucket", memory: "512m", replicas: "2", }); }, ); expect(names).toEqual(["source", "template", "compose"]); expect(app).toMatchObject({ name: "fixed", cpu: "0.5", source: { kind: "template", template: "bun-service" }, postgres: "user/db", s3: "key/bucket", memory: "512m", replicas: 2, }); }); test("add preview contains literal existing names; collision is corrected in Snippet and existing content is preserved", async () => { const cwd = await root(); const original = "name: demo\nservices:\n existing:\n image: private:1 # retain\n app:\n image: private:2\nx-owner: same\n"; const file = join(cwd, "compose.yml"); await writeFile(file, original); const app = await resolveAppInput( cwd, { source: "image", image: "nginx", path: "." }, async (question) => { expect(question.type).toBe("snippet"); expect(question.template).toContain( "services:\n existing: ...\n app: ...\n ${name}:", ); expect(question.template).not.toContain("private:1"); const name = question.fields?.find(({ name }) => name === "name"); expect(name?.validate?.("app")).toBe("Service app already exists"); expect(name?.validate?.("worker")).toBe(true); expect( question.fields ?.find(({ name }) => name === "replicas") ?.validate?.("0"), ).toContain("positive integer"); return snippetValues(question, { name: "worker" }); }, ); await addAppToCompose(cwd, app); const updated = await readFile(file, "utf8"); expect(updated).toContain( " existing:\n image: private:1 # retain\n app:\n image: private:2\n", ); expect(updated).not.toContain("..."); expect((await readCompose(file)).services?.worker?.image).toBe("nginx"); await expect( resolveAppInput( cwd, { source: "image", image: "nginx", path: "." }, async (question) => snippetValues(question, { name: "app" }), ), ).rejects.toThrow("already exists"); expect(await readFile(file, "utf8")).toBe(updated); }); test("cancelled add and a service appearing after the preview do not overwrite Compose", async () => { const cwd = await root(); const file = await createCompose(cwd, "demo", { name: "original", path: ".", source: { kind: "image", image: "nginx" }, }); const original = await readFile(file, "utf8"); await expect( resolveAppInput(cwd, {}, async () => { throw new Error("cancelled"); }), ).rejects.toThrow("cancelled"); expect(await readFile(file, "utf8")).toBe(original); const app = await resolveAppInput( cwd, { source: "image", image: "nginx", path: "." }, async (question) => snippetValues(question, { name: "worker" }), ); await addAppToCompose(cwd, app); const updated = await readFile(file, "utf8"); await expect(addAppToCompose(cwd, app)).rejects.toThrow("already exists"); expect(await readFile(file, "utf8")).toBe(updated); });