feat: release 2.7.0-rc3

This commit is contained in:
2026-10-07 05:10:59 +00:00 Unverified
parent c8de9ddcba
commit 2a24b2ff56
14 changed files with 1302 additions and 249 deletions
+225
View File
@@ -10,6 +10,26 @@ import {
} 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<string, string> = {},
) {
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 () => {
@@ -156,3 +176,208 @@ test("rejects path traversal and symlink outside the project", async () => {
}),
).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);
});