From 940a156acb692676340a130bd81bb113923bad94 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 22 Aug 2026 21:36:33 +0200 Subject: [PATCH 1/3] fix(init): refuse to overwrite files when scaffolding into the current directory Without a project name, `veryfront init` scaffolds into the working directory (the non-interactive default: piped stdin, CI, `--template` with no name). `createProject` only checked for conflicts when a name was given, so an existing `package.json`, `README.md`, or any other file the template ships was silently replaced, with no `--force` and a "Project ready" banner. The current-directory path now refuses when any file the scaffold would write already exists, naming every conflicting file and pointing at `--force`. `.gitignore` is merged rather than replaced and so is never a conflict; an empty directory, or one holding unrelated files, scaffolds as before. Regression tests cover the refusal, the message, the empty and unrelated-file cases, `--force`, and the CLI path through `initCommand`. The API reference pins for `cli/shared/project-creation.ts` are regenerated with CI's Deno. --- cli/commands/init/init-command.test.ts | 34 +++++++++- cli/shared/project-creation.test.ts | 86 ++++++++++++++++++++++++ cli/shared/project-creation.ts | 40 +++++++++++ docs/api-reference/veryfront/scaffold.md | 12 ++-- 4 files changed, 165 insertions(+), 7 deletions(-) diff --git a/cli/commands/init/init-command.test.ts b/cli/commands/init/init-command.test.ts index db230c9eba..20eb5bb0da 100644 --- a/cli/commands/init/init-command.test.ts +++ b/cli/commands/init/init-command.test.ts @@ -5,7 +5,7 @@ import "#veryfront/schemas/_test-setup.ts"; * Tests the init command types and options validation. */ -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { exists, makeTempDir, remove } from "#veryfront/testing/deno-compat.ts"; import { cwd } from "veryfront/platform"; @@ -180,3 +180,35 @@ describe("InitCommand Types", () => { }); }); }); + +describe("initCommand into the current directory", () => { + it("refuses to overwrite files already in the directory without --force", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-init-cwd-" }); + const readme = join(parentDir, "README.md"); + + try { + await Deno.writeTextFile(readme, "mine\n"); + + // Non-interactive `veryfront init` with no name scaffolds into the + // current directory. It must hold the same line as the named path: an + // existing file is refused, not replaced. + await assertRejects( + () => + initCommand({ + parentDir, + template: "minimal", + skipInstall: true, + skipEnvPrompt: true, + quiet: true, + }), + Error, + "README.md", + ); + + assertEquals(await Deno.readTextFile(readme), "mine\n"); + assertEquals(await exists(join(parentDir, "app")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); +}); diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index 3697f3d18c..5fe2470932 100644 --- a/cli/shared/project-creation.test.ts +++ b/cli/shared/project-creation.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { + assert, assertEquals, assertInstanceOf, assertRejects, @@ -553,3 +554,88 @@ describe("cli/project-creation MDX extension declaration", () => { assertEquals(declared.includes("@veryfront/ext-content-mdx"), false); }); }); + +describe("createProject into the current directory", () => { + /** The request `veryfront init` builds when no project name is given. */ + function cwdRequest(parentDir: string): CreateProjectRequest { + return { ...baseRequest(parentDir), name: undefined }; + } + + it("refuses to overwrite files the scaffold would write when the policy is fail", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-cwd-conflict-" }); + const readme = join(parentDir, "README.md"); + const packageJson = join(parentDir, "package.json"); + + try { + await Deno.writeTextFile(readme, "mine\n"); + await Deno.writeTextFile(packageJson, '{"name":"mine"}\n'); + + await assertRejects( + () => createProject(cwdRequest(parentDir)), + Error, + "README.md", + ); + + assertEquals(await Deno.readTextFile(readme), "mine\n"); + assertEquals(await Deno.readTextFile(packageJson), '{"name":"mine"}\n'); + assertEquals(await exists(join(parentDir, "app")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("names every file that would be overwritten", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-cwd-conflicts-" }); + + try { + await Deno.writeTextFile(join(parentDir, "README.md"), "mine\n"); + await Deno.writeTextFile(join(parentDir, "package.json"), "{}\n"); + + const error = await createProject(cwdRequest(parentDir)).then( + () => null, + (caught: unknown) => caught, + ); + + assert(error instanceof Error, "expected the conflict to reject"); + assertStringIncludes(error.message, "README.md"); + assertStringIncludes(error.message, "package.json"); + assertStringIncludes(error.message, "--force"); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("scaffolds into an empty directory, and beside unrelated files", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-cwd-empty-" }); + + try { + await Deno.writeTextFile(join(parentDir, "notes.txt"), "unrelated\n"); + // .gitignore is merged rather than replaced, so it is never a conflict. + await Deno.writeTextFile(join(parentDir, ".gitignore"), "dist\n"); + + const result = await createProject(cwdRequest(parentDir)); + + assertEquals(result.projectDir, parentDir); + assertEquals(await exists(join(parentDir, "app", "page.tsx")), true); + assertEquals(await Deno.readTextFile(join(parentDir, "notes.txt")), "unrelated\n"); + assertStringIncludes(await Deno.readTextFile(join(parentDir, ".gitignore")), "dist"); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("overwrites when the policy says so", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-cwd-overwrite-" }); + const readme = join(parentDir, "README.md"); + + try { + await Deno.writeTextFile(readme, "mine\n"); + + await createProject({ ...cwdRequest(parentDir), conflictPolicy: "overwrite" }); + + assertEquals((await Deno.readTextFile(readme)) === "mine\n", false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); +}); diff --git a/cli/shared/project-creation.ts b/cli/shared/project-creation.ts index 9c426c1dfd..b62dff1be6 100644 --- a/cli/shared/project-creation.ts +++ b/cli/shared/project-creation.ts @@ -477,6 +477,33 @@ async function assembleScaffold(request: { }; } +/** + * Every path `createProject` writes outright, in the order it writes them. + * + * `.gitignore` is absent on purpose: it is merged with whatever is already + * there rather than replaced, so an existing one is never a conflict. + */ +function scaffoldWritePaths(assembly: ScaffoldAssembly, request: CreateProjectRequest): string[] { + const paths = assembly.files + .map((file) => file.path) + .filter((path) => path !== ".env" && path !== ".env.example"); + if (request.includePackageMetadata) { + paths.push("package.json"); + if (request.runtime === "deno") paths.push("deno.json"); + } + if (assembly.envVars.length) paths.push(".env", ".env.example"); + return paths; +} + +async function findExistingPaths(dir: string, paths: string[]): Promise { + const fs = createFileSystem(); + const existing: string[] = []; + for (const path of paths) { + if (await fs.exists(join(dir, path))) existing.push(path); + } + return existing; +} + export async function createProject( request: CreateProjectRequest, dependencies: CreateProjectDependencies = {}, @@ -504,6 +531,19 @@ export async function createProject( const assembly = await assembleScaffold(request); + // A named project gets a fresh directory, checked above. Without a name the + // scaffold lands in `parentDir` itself, which always exists, so the conflict + // is any file the scaffold would write over - a `package.json` with the + // author's scripts, a `README.md` - and those are refused the same way. + if (projectName === undefined && request.conflictPolicy === "fail") { + const conflicts = await findExistingPaths(projectDir, scaffoldWritePaths(assembly, request)); + if (conflicts.length) { + throw createConfigError( + `Directory already contains ${conflicts.join(", ")}. Use --force to overwrite.`, + ); + } + } + if (projectName !== undefined) await ensureDir(projectDir); const createdPaths = await writeScaffoldFiles(projectDir, assembly.files); diff --git a/docs/api-reference/veryfront/scaffold.md b/docs/api-reference/veryfront/scaffold.md index 4171d46cfb..ae3934603b 100644 --- a/docs/api-reference/veryfront/scaffold.md +++ b/docs/api-reference/veryfront/scaffold.md @@ -38,20 +38,20 @@ for (const file of files) { | Name | Description | Source | | --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `SCAFFOLD_TEMPLATE_ALIASES` | Slugs other product surfaces use for a template this CLI names differently. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L564) | +| `SCAFFOLD_TEMPLATE_ALIASES` | Slugs other product surfaces use for a template this CLI names differently. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L604) | ### Functions | Name | Description | Source | | ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `listScaffoldTemplates` | Every template slug a caller may ask for, canonical names and aliases. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L579) | -| `materializeScaffold` | Produce the complete contents of a new project without touching a disk. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L618) | -| `resolveScaffoldTemplate` | Canonical starter template for a slug, or `null` when nothing matches. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L571) | +| `listScaffoldTemplates` | Every template slug a caller may ask for, canonical names and aliases. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L619) | +| `materializeScaffold` | Produce the complete contents of a new project without touching a disk. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L658) | +| `resolveScaffoldTemplate` | Canonical starter template for a slug, or `null` when nothing matches. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L611) | ### Types | Name | Description | Source | | ---------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `MaterializedScaffold` | A new project: every file it starts with, plus anything worth telling the author. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L600) | -| `MaterializeScaffoldRequest` | What to build: which starter, under what name, for which runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L584) | +| `MaterializedScaffold` | A new project: every file it starts with, plus anything worth telling the author. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L640) | +| `MaterializeScaffoldRequest` | What to build: which starter, under what name, for which runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L624) | | `TemplateFile` | | [source](https://github.com/veryfront/veryfront-code/blob/main/templates/types.ts#L17) | From 553d7adfc7e30f0ad8b1eca83940e97c322ba80a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 22 Aug 2026 23:41:22 +0200 Subject: [PATCH 2/3] docs(init): say --force overwrites files, not just a directory `--force` now also overwrites files the scaffold would write into the current directory, so "Overwrite existing directory" no longer describes the whole flag. The help line and the InitOptions doc comment both say files and directories. --- cli/commands/init/command-help.ts | 2 +- cli/commands/init/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/commands/init/command-help.ts b/cli/commands/init/command-help.ts index 11ba6123cd..80eff06065 100644 --- a/cli/commands/init/command-help.ts +++ b/cli/commands/init/command-help.ts @@ -26,7 +26,7 @@ export const initHelp: CommandHelp = { }, { flag: "-f, --force", - description: "Overwrite existing directory", + description: "Overwrite existing files and directories", }, { flag: "-c, --config ", diff --git a/cli/commands/init/types.ts b/cli/commands/init/types.ts index 9ba62c8613..3096d81d45 100644 --- a/cli/commands/init/types.ts +++ b/cli/commands/init/types.ts @@ -26,7 +26,7 @@ export interface InitOptions { quiet?: boolean; /** Deploy to cloud after scaffolding */ deploy?: boolean; - /** Overwrite existing directory */ + /** Overwrite existing files and directories */ force?: boolean; /** Runtime for the scaffolded project. Defaults to "node". */ runtime?: InitRuntime; From 0112f484ec4ea00b063dd697a50f4070d1ab2811 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 22 Aug 2026 23:44:32 +0200 Subject: [PATCH 3/3] test(init): lock the conflict list to what the scaffold actually writes `scaffoldWritePaths` is a hand-maintained mirror of the writes in `createProject`. A new write added without a matching entry silently narrows the guard, and every existing test still passes because they each name one or two files. The new test scaffolds with the deno runtime and an integration, which is the widest write set (template files, package.json, deno.json, .env, .env.example), then runs again over that directory and requires every created path back in the refusal. Dropping deno.json or the env files from the mirror turns it red and leaves the other four tests green. --- cli/shared/project-creation.test.ts | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index 5fe2470932..4259671198 100644 --- a/cli/shared/project-creation.test.ts +++ b/cli/shared/project-creation.test.ts @@ -624,6 +624,38 @@ describe("createProject into the current directory", () => { } }); + it("names every path a fresh scaffold writes, so the conflict list cannot drift", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-cwd-drift-" }); + // Deno plus an integration is the widest scaffold there is: template files, + // package.json, deno.json, .env and .env.example all get written. + const request: CreateProjectRequest = { + ...cwdRequest(parentDir), + runtime: "deno", + integrations: ["github"], + }; + + try { + const written = await createProject(request); + + // Run again over what the first run just wrote. Every one of those paths + // has to come back named, which is what stops the conflict list drifting + // when a new write lands in createProject and nobody mirrors it into + // scaffoldWritePaths. .gitignore is merged, so it stays off the list. + const error = await createProject(request).then( + () => null, + (caught: unknown) => caught, + ); + + assert(error instanceof Error, "expected the second run to reject"); + for (const path of written.createdPaths) { + if (path === ".gitignore") continue; + assertStringIncludes(error.message, path); + } + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + it("overwrites when the policy says so", async () => { const parentDir = await makeTempDir({ prefix: "veryfront-create-cwd-overwrite-" }); const readme = join(parentDir, "README.md");