From c67d0ded7f6370380394b5ae8d7acecb1751435b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 22 Aug 2026 22:05:07 +0200 Subject: [PATCH 1/3] fix(init): accept an existing directory unless a scaffold file would be overwritten `veryfront init app` refused any existing `app/`, including an empty one or a fresh clone holding only `.git`, with "Directory already exists". Every mainstream scaffolder accepts those, and `mkdir app && veryfront init app` is the first thing many developers type. A conflict is now a file the scaffold would write over, not the directory existing. `createProject` is the single authority: the named path uses the same `findExistingPaths` check the current-directory path already used, and both directory-existence checks in `initCommand` are gone. The refusal names the files and points at `--force`: Directory "app" already contains README.md. Use --force to overwrite. `.gitignore` is merged rather than replaced, so it never conflicts. The interactive wizard now runs before a refusal for a taken name; the message it ends on says exactly which files are in the way. The `vf_create_project` MCP tool keeps its own directory check and message; aligning it is a separate change. Tests: empty directory and unrelated-file cases at the `createProject`, `initCommand`, and subprocess levels; the conflict message for a named directory; existing expectations updated from "already exists" to the file-level message. API reference pins regenerated with CI's Deno. --- cli/commands/init/init-command.test.ts | 2 +- cli/commands/init/init-command.ts | 34 +---------- .../init/init-deploy.integration.test.ts | 6 +- cli/commands/init/init.integration.test.ts | 22 ++++++- cli/shared/project-creation.test.ts | 58 ++++++++++++++++++- cli/shared/project-creation.ts | 23 +++----- docs/api-reference/veryfront/scaffold.md | 12 ++-- 7 files changed, 99 insertions(+), 58 deletions(-) diff --git a/cli/commands/init/init-command.test.ts b/cli/commands/init/init-command.test.ts index 8bfdb8f0c2..70429cbc12 100644 --- a/cli/commands/init/init-command.test.ts +++ b/cli/commands/init/init-command.test.ts @@ -205,7 +205,7 @@ describe("initCommand target directory", () => { quiet: true, }), Error, - `Directory "${name}" already exists`, + `Directory "${name}" already contains README.md`, ); assertEquals(await Deno.readTextFile(keepsake), "keep me\n"); diff --git a/cli/commands/init/init-command.ts b/cli/commands/init/init-command.ts index 683cf60b58..7a0b036ed7 100644 --- a/cli/commands/init/init-command.ts +++ b/cli/commands/init/init-command.ts @@ -6,11 +6,9 @@ import { cliLogger as logger, isVerbose } from "#cli/utils"; import { brand, dim } from "#cli/ui"; import { createTransientSpinner } from "../../ui/progress.ts"; -import { join } from "veryfront/platform/path"; import { createError, toError } from "veryfront/errors"; import type { InitOptions, InitRuntime, InitTemplate } from "./types.ts"; import { cwd } from "veryfront/platform"; -import { createFileSystem } from "veryfront/platform"; import { getDlxCommand, getInstallCommand, getRunCommand } from "../../utils/package-manager.ts"; import { createProject, type ProjectCreationObserver } from "../../shared/project-creation.ts"; import { validateProjectName } from "../../shared/project-name.ts"; @@ -141,22 +139,6 @@ export async function initCommand( } } - // Refuse an existing directory before entering the wizard. This has to be an - // error rather than a printed message: `veryfront init x && cd x` must stop - // here, not carry on into a directory that was never scaffolded. - if (name && !options.force) { - const fs = createFileSystem(); - if (await fs.exists(join(parentDir, name))) { - throw toError( - createError({ - type: "config", - message: - `Directory "${name}" already exists. Choose a different name or use --force to overwrite.`, - }), - ); - } - } - let wizardRuntime: InitRuntime = "node"; if (shouldRunWizard(options)) { const wizardResult = await runInteractiveWizard(name, options.runtime); @@ -174,19 +156,9 @@ export async function initCommand( } const runtime: InitRuntime = options.runtime ?? wizardRuntime; - const projectDir = projectName ? join(parentDir, projectName) : parentDir; - if (projectName && !options.force) { - const fs = createFileSystem(); - if (await fs.exists(projectDir)) { - throw toError( - createError({ - type: "config", - message: - `Directory "${projectName}" already exists. Choose a different name or use --force to overwrite.`, - }), - ); - } - } + // Whether the target can be written to is `createProject`'s call: it knows + // which files the template ships, so it refuses exactly the files it would + // overwrite rather than any directory that happens to exist. let installSpinner: ReturnType | null = null; const installObserver: ProjectCreationObserver = { diff --git a/cli/commands/init/init-deploy.integration.test.ts b/cli/commands/init/init-deploy.integration.test.ts index 0dafd1df5f..dedd1500f0 100644 --- a/cli/commands/init/init-deploy.integration.test.ts +++ b/cli/commands/init/init-deploy.integration.test.ts @@ -98,8 +98,9 @@ describe("init command integration", () => { }); describe("validation", () => { - it("should reject existing directories without --force", async () => { + it("should reject a directory holding files the template writes without --force", async () => { await mkdir(projectDir); + await writeTextFile(join(projectDir, "README.md"), "mine"); const result = await runInitCommand(projectName, [ "-t", @@ -107,7 +108,8 @@ describe("init command integration", () => { "--skip-install", "--skip-env-prompt", ]); - assertEquals(result.code !== 0 || (result.stderr ?? "").includes("already exists"), true); + assertEquals(result.code !== 0, true); + assertEquals((result.stderr ?? "").includes("already contains README.md"), true); }); it("should overwrite with --force flag", async () => { diff --git a/cli/commands/init/init.integration.test.ts b/cli/commands/init/init.integration.test.ts index 1c6fad3b6f..8f7bb00b5b 100644 --- a/cli/commands/init/init.integration.test.ts +++ b/cli/commands/init/init.integration.test.ts @@ -712,17 +712,35 @@ describe("init command integration", () => { }); describe("existing directory", () => { - it("should show error when directory already exists", async () => { + it("should show error when the directory holds files the template writes", async () => { const dirName = `exists-${randomSuffix()}`; const dirPath = join(TEST_DIR, dirName); await Deno.mkdir(dirPath); + await Deno.writeTextFile(join(dirPath, "README.md"), "mine\n"); try { const result = await runInitCommand([dirName, "-t", "minimal", "--skip-install"]); const output = (result.stdout ?? "") + (result.stderr ?? ""); - assertEquals(output.includes("already exists"), true); + assertEquals(result.code === 0, false); + assertEquals(output.includes("already contains README.md"), true); assertEquals(output.includes("Stack trace"), false); + assertEquals(await Deno.readTextFile(join(dirPath, "README.md")), "mine\n"); + } finally { + await remove(dirPath, { recursive: true }).catch(() => {}); + } + }); + + it("should scaffold into an existing empty directory", async () => { + const dirName = `empty-${randomSuffix()}`; + const dirPath = join(TEST_DIR, dirName); + await Deno.mkdir(dirPath); + + try { + const result = await runInitCommand([dirName, "-t", "minimal", "--skip-install"]); + + assertEquals(result.code, 0); + assertEquals(await exists(join(dirPath, "app", "page.tsx")), true); } finally { await remove(dirPath, { recursive: true }).catch(() => {}); } diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index 4259671198..7710dafa77 100644 --- a/cli/shared/project-creation.test.ts +++ b/cli/shared/project-creation.test.ts @@ -104,7 +104,7 @@ describe("createProject", () => { await assertRejects( () => createProject({ ...request, conflictPolicy: "fail" }), Error, - 'Directory "contract-project" already exists', + 'Directory "contract-project" already contains', ); const overwritten = await createProject({ @@ -671,3 +671,59 @@ describe("createProject into the current directory", () => { } }); }); + +describe("createProject into an existing named directory", () => { + it("scaffolds into an existing empty directory", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-empty-named-" }); + + try { + // `mkdir app && veryfront init app`, or a freshly cloned empty repo. + await Deno.mkdir(join(parentDir, "contract-project")); + + const result = await createProject(baseRequest(parentDir)); + + assertEquals(result.projectDir, join(parentDir, "contract-project")); + assertEquals(await exists(join(parentDir, "contract-project", "app", "page.tsx")), true); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("scaffolds beside files the template does not write", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-beside-named-" }); + const projectDir = join(parentDir, "contract-project"); + + try { + await Deno.mkdir(join(projectDir, ".git"), { recursive: true }); + await Deno.writeTextFile(join(projectDir, "LICENSE"), "MIT\n"); + + await createProject(baseRequest(parentDir)); + + assertEquals(await exists(join(projectDir, "app", "page.tsx")), true); + assertEquals(await Deno.readTextFile(join(projectDir, "LICENSE")), "MIT\n"); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("names the files it would overwrite, not just the directory", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-conflict-named-" }); + const projectDir = join(parentDir, "contract-project"); + + try { + await Deno.mkdir(projectDir); + await Deno.writeTextFile(join(projectDir, "README.md"), "mine\n"); + + await assertRejects( + () => createProject(baseRequest(parentDir)), + Error, + 'Directory "contract-project" already contains README.md', + ); + + assertEquals(await Deno.readTextFile(join(projectDir, "README.md")), "mine\n"); + assertEquals(await exists(join(projectDir, "app")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); +}); diff --git a/cli/shared/project-creation.ts b/cli/shared/project-creation.ts index b62dff1be6..ccebe7bf47 100644 --- a/cli/shared/project-creation.ts +++ b/cli/shared/project-creation.ts @@ -517,29 +517,22 @@ export async function createProject( const projectDir = projectName === undefined ? request.parentDir : join(request.parentDir, projectName); - const fs = createFileSystem(); validateIntegrationsOrThrow(request.integrations); - if ( - projectName !== undefined && - request.conflictPolicy === "fail" && - await fs.exists(projectDir) - ) { - throw createConfigError(`Directory "${projectName}" already exists`); - } - 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") { + // A conflict is a file the scaffold would write over - a `package.json` with + // the author's scripts, a `README.md` - not the directory existing. So an + // empty directory, a fresh clone holding only `.git`, or the working + // directory itself (the no-name case) all scaffold, and a `--force` is asked + // for only when something would actually be replaced. + if (request.conflictPolicy === "fail") { const conflicts = await findExistingPaths(projectDir, scaffoldWritePaths(assembly, request)); if (conflicts.length) { + const where = projectName === undefined ? "Directory" : `Directory "${projectName}"`; throw createConfigError( - `Directory already contains ${conflicts.join(", ")}. Use --force to overwrite.`, + `${where} already contains ${conflicts.join(", ")}. Use --force to overwrite.`, ); } } diff --git a/docs/api-reference/veryfront/scaffold.md b/docs/api-reference/veryfront/scaffold.md index ae3934603b..8e062f8317 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#L604) | +| `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#L597) | ### 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#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) | +| `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#L612) | +| `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#L651) | +| `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#L604) | ### 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#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) | +| `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#L633) | +| `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#L617) | | `TemplateFile` | | [source](https://github.com/veryfront/veryfront-code/blob/main/templates/types.ts#L17) | From 865e20004caf223f3bafea742dacf4a71aba348a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 23 Aug 2026 01:05:26 +0200 Subject: [PATCH 2/3] fix(init): refuse a file or a link where the scaffold needs a directory Accepting an existing target directory means the scaffold now meets states the old "directory already exists" check never let it reach. One of them wrote outside the project. `findExistingPaths` asks whether `app/page.tsx` exists. When `app` is a regular file, that path cannot resolve, so the check reports no conflict. `writeScaffoldFiles` then writes the root files (`README.md`, `AGENTS.md`) and fails on `ensureDir("app")` with a raw stat error, leaving a half scaffold behind. When `app` is a link to another directory, nothing fails at all: `veryfront init app` exits 0, prints "app ready", and leaves `page.tsx`, `layout.tsx` and `about/page.mdx` in the link target instead of the project you named. `createProject` now checks every directory the scaffold has to create, before it writes anything, and refuses when one is already a file or a link: Directory "app" already contains app as a file or a link, and the scaffold needs a directory there. Move it aside or use a different name. The check runs whatever the conflict policy is. `--force` says you accept your own files being replaced, not the scaffold writing somewhere else. Every segment is checked, not just the first, so a real `app/` with a file at `app/about` is caught before `app/page.tsx` is written. A real directory that is already there is never blocked: it is exactly what the scaffold is about to create. The current-directory path had the same hole, so `cd repo && veryfront init` with a linked `app/` wrote outside the repo too. The check covers both paths because it sits in `createProject`. Tests: a file and a link at a scaffold directory, for the named path, the current-directory path, and under `--force`, plus a block one level down at `app/about`, each asserting nothing was written through or beside it; and an existing real `app/` that must still scaffold. Every refusal test fails without the check. --- cli/shared/project-creation.test.ts | 138 +++++++++++++++++++++++ cli/shared/project-creation.ts | 53 ++++++++- docs/api-reference/veryfront/scaffold.md | 12 +- 3 files changed, 195 insertions(+), 8 deletions(-) diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index 7710dafa77..08b9fd918f 100644 --- a/cli/shared/project-creation.test.ts +++ b/cli/shared/project-creation.test.ts @@ -727,3 +727,141 @@ describe("createProject into an existing named directory", () => { } }); }); + +describe("createProject when something blocks a scaffold directory", () => { + it("refuses a file where the scaffold needs a directory, before writing anything", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-file-" }); + const projectDir = join(parentDir, "contract-project"); + + try { + await Deno.mkdir(projectDir); + // `app/page.tsx` cannot resolve through a regular `app`, so the conflict + // check sees nothing and the scaffold used to write README.md and + // AGENTS.md before failing on the directory it could not create. + await Deno.writeTextFile(join(projectDir, "app"), "mine\n"); + + await assertRejects( + () => createProject(baseRequest(parentDir)), + Error, + 'Directory "contract-project" already contains app as a file or a link', + ); + + assertEquals(await Deno.readTextFile(join(projectDir, "app")), "mine\n"); + assertEquals(await exists(join(projectDir, "README.md")), false); + assertEquals(await exists(join(projectDir, "AGENTS.md")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("refuses a link where the scaffold needs a directory, and writes nothing through it", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-link-" }); + const projectDir = join(parentDir, "contract-project"); + const outside = join(parentDir, "outside"); + + try { + await Deno.mkdir(projectDir); + await Deno.mkdir(outside); + await Deno.symlink(outside, join(projectDir, "app")); + + await assertRejects( + () => createProject(baseRequest(parentDir)), + Error, + 'Directory "contract-project" already contains app as a file or a link', + ); + + // The scaffold would otherwise report success and leave page.tsx, + // layout.tsx and about/page.mdx outside the project it named. + assertEquals(await exists(join(outside, "page.tsx")), false); + assertEquals(await exists(join(outside, "layout.tsx")), false); + assertEquals(await exists(join(projectDir, "README.md")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("refuses a blocked directory in the current-directory path too", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-cwd-" }); + const outside = await makeTempDir({ prefix: "veryfront-create-blocked-target-" }); + + try { + await Deno.symlink(outside, join(parentDir, "app")); + + await assertRejects( + () => createProject({ ...baseRequest(parentDir), name: undefined }), + Error, + "Directory already contains app as a file or a link", + ); + + assertEquals(await exists(join(outside, "page.tsx")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + await remove(outside, { recursive: true }).catch(() => {}); + } + }); + + it("refuses under --force as well, because force overwrites files it does not redirect writes", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-force-" }); + const projectDir = join(parentDir, "contract-project"); + const outside = join(parentDir, "outside"); + + try { + await Deno.mkdir(projectDir); + await Deno.mkdir(outside); + await Deno.symlink(outside, join(projectDir, "app")); + + await assertRejects( + () => createProject({ ...baseRequest(parentDir), conflictPolicy: "overwrite" }), + Error, + 'Directory "contract-project" already contains app as a file or a link', + ); + + assertEquals(await exists(join(outside, "page.tsx")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("refuses a block nested below a directory that is genuinely there", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-nested-" }); + const projectDir = join(parentDir, "contract-project"); + + try { + // `app/` is a real directory, so only the second segment is in the way. + // Checking the first segment alone would let the scaffold write + // app/page.tsx and app/layout.tsx before failing on app/about. + await Deno.mkdir(join(projectDir, "app"), { recursive: true }); + await Deno.writeTextFile(join(projectDir, "app", "about"), "mine\n"); + + await assertRejects( + () => createProject(baseRequest(parentDir)), + Error, + 'Directory "contract-project" already contains app/about as a file or a link', + ); + + assertEquals(await Deno.readTextFile(join(projectDir, "app", "about")), "mine\n"); + assertEquals(await exists(join(projectDir, "app", "page.tsx")), false); + assertEquals(await exists(join(projectDir, "README.md")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("scaffolds normally when the directories it needs are absent or already directories", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-clear-" }); + const projectDir = join(parentDir, "contract-project"); + + try { + // A real `app/` directory is not in the way, it is exactly what the + // scaffold is about to create. + await Deno.mkdir(join(projectDir, "app"), { recursive: true }); + + await createProject(baseRequest(parentDir)); + + assertEquals(await exists(join(projectDir, "app", "page.tsx")), true); + assertEquals(await exists(join(projectDir, "README.md")), true); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); +}); diff --git a/cli/shared/project-creation.ts b/cli/shared/project-creation.ts index ccebe7bf47..f4a4d8df4e 100644 --- a/cli/shared/project-creation.ts +++ b/cli/shared/project-creation.ts @@ -504,6 +504,44 @@ async function findExistingPaths(dir: string, paths: string[]): Promise { + const fs = createFileSystem(); + // `lstat` reports a link as "not a directory", which is exactly the answer + // this needs. It is optional only for virtual filesystems that have no + // links of their own; every runtime this CLI scaffolds on provides it, and + // `stat` still catches a plain file in the way if one ever does not. + const describe = fs.lstat?.bind(fs) ?? fs.stat.bind(fs); + const blocked = new Set(); + + for (const path of paths) { + const segments = path.split("/").slice(0, -1); + for (let depth = 1; depth <= segments.length; depth++) { + const ancestor = segments.slice(0, depth).join("/"); + if (blocked.has(ancestor)) break; + let info: Awaited>; + try { + info = await describe(join(dir, ancestor)); + } catch { + break; // Nothing there yet, so nothing below it either. + } + if (!info.isDirectory) { + blocked.add(ancestor); + break; + } + } + } + + return [...blocked].sort(); +} + export async function createProject( request: CreateProjectRequest, dependencies: CreateProjectDependencies = {}, @@ -521,6 +559,18 @@ export async function createProject( validateIntegrationsOrThrow(request.integrations); const assembly = await assembleScaffold(request); + const writePaths = scaffoldWritePaths(assembly, request); + const where = projectName === undefined ? "Directory" : `Directory "${projectName}"`; + + // Checked whatever the conflict policy is: `--force` says you accept your + // files being replaced, not the scaffold writing somewhere else entirely. + const blocked = await findBlockedDirectories(projectDir, writePaths); + if (blocked.length) { + throw createConfigError( + `${where} already contains ${blocked.join(", ")} as a file or a link, ` + + `and the scaffold needs a directory there. Move it aside or use a different name.`, + ); + } // A conflict is a file the scaffold would write over - a `package.json` with // the author's scripts, a `README.md` - not the directory existing. So an @@ -528,9 +578,8 @@ export async function createProject( // directory itself (the no-name case) all scaffold, and a `--force` is asked // for only when something would actually be replaced. if (request.conflictPolicy === "fail") { - const conflicts = await findExistingPaths(projectDir, scaffoldWritePaths(assembly, request)); + const conflicts = await findExistingPaths(projectDir, writePaths); if (conflicts.length) { - const where = projectName === undefined ? "Directory" : `Directory "${projectName}"`; throw createConfigError( `${where} already contains ${conflicts.join(", ")}. Use --force to overwrite.`, ); diff --git a/docs/api-reference/veryfront/scaffold.md b/docs/api-reference/veryfront/scaffold.md index 8e062f8317..6ab1b5b16a 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#L597) | +| `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#L646) | ### 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#L612) | -| `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#L651) | -| `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#L604) | +| `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#L661) | +| `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#L700) | +| `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#L653) | ### 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#L633) | -| `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#L617) | +| `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#L682) | +| `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#L666) | | `TemplateFile` | | [source](https://github.com/veryfront/veryfront-code/blob/main/templates/types.ts#L17) | From 090585fd1d0bf2edc73f3680946b415272b5534f Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 23 Aug 2026 01:33:05 +0200 Subject: [PATCH 3/3] fix(init): refuse a link at a scaffold path, and keep the TUI on fresh directories Two more places where accepting an existing directory let a write land somewhere it was never asked to go. A link at the scaffold path itself escaped the preflight, which only walked the directories above it. `findExistingPaths` resolves a dangling link to nothing and reports it absent, so `proj/README.md -> ../outside.md` made `veryfront init proj` exit 0, print "proj ready", and write the README to `outside.md` outside the project. The check now walks every segment, including the last, and refuses a link anywhere along the path: Directory "proj" already contains README.md as a file or a link the scaffold cannot write through. Move it aside or use a different name. A real file at a scaffold path is deliberately not refused here. It resolves fine and stays the ordinary conflict pointing at `--force`, pinned by a test so this cannot drift into refusing any directory with a file in it. The named target being a link is still allowed on purpose. `ln -s /mnt/big/app app && veryfront init app` puts the project on another volume and every file is reachable at the path you named. Only a link you did not name can surprise you. The TUI is the second caller of `createProject` with a fail policy, and it relied on the directory check this branch removed. It reserves a new remote slug, then scaffolds into `projects/`, then writes the link for that slug. With the check gone it would adopt an existing `projects/` that holds none of the template files, and repoint a directory that is already another project. It now refuses before scaffolding. The constraint belongs in that caller, not in `createProject`: `veryfront init` accepting a directory that exists is the point of this branch, and the TUI wanting a fresh one is the opposite requirement. Tests: a dangling link at a scaffold path, the same under `--force`, a real file at a scaffold path that must stay an overwritable conflict, and a TUI slug whose directory already exists and is linked elsewhere. All fail without these changes. --- cli/app/operations/project-creation.test.ts | 69 +++++++++++++++++++++ cli/app/operations/project-creation.ts | 15 ++++- cli/shared/project-creation.test.ts | 67 +++++++++++++++++++- cli/shared/project-creation.ts | 53 ++++++++++------ docs/api-reference/veryfront/scaffold.md | 12 ++-- 5 files changed, 188 insertions(+), 28 deletions(-) diff --git a/cli/app/operations/project-creation.test.ts b/cli/app/operations/project-creation.test.ts index fe2a54f114..ecb760373d 100644 --- a/cli/app/operations/project-creation.test.ts +++ b/cli/app/operations/project-creation.test.ts @@ -89,4 +89,73 @@ describe("TUI project creation", () => { await Deno.remove(configHome, { recursive: true }); } }); + + it("refuses a slug whose directory already exists rather than adopting it", async () => { + const originalFetch = globalThis.fetch; + const envKeys = ["VERYFRONT_API_URL", "VERYFRONT_API_BASE_URL", "XDG_CONFIG_HOME"]; + const savedEnv = envKeys.map((key) => Deno.env.get(key)); + const workDir = await Deno.makeTempDir(); + const configHome = await Deno.makeTempDir(); + const existingDir = join(workDir, "projects", "my-app"); + + try { + await Deno.mkdir(join(configHome, "veryfront"), { recursive: true }); + await Deno.writeTextFile(join(configHome, "veryfront", "token"), TOKEN); + Deno.env.set("VERYFRONT_API_URL", API_URL); + Deno.env.delete("VERYFRONT_API_BASE_URL"); + Deno.env.set("XDG_CONFIG_HOME", configHome); + _resetEnvironmentConfig(); + + // A directory already linked to a different project, holding nothing the + // template writes. `veryfront init` scaffolds into a directory like this + // on purpose; this caller must not, because it would repoint the link. + await Deno.mkdir(join(existingDir, ".veryfront"), { recursive: true }); + await Deno.writeTextFile( + join(existingDir, ".veryfront", "project.json"), + '{"projectId":"proj_someone_else"}\n', + ); + await Deno.writeTextFile(join(existingDir, "notes.txt"), "mine\n"); + + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (request.method === "POST" && url.pathname === "/projects") { + return Promise.resolve(Response.json({ id: "proj_new", slug: "my-app" })); + } + if (request.method === "GET" && url.pathname === "/projects") { + return Promise.resolve(Response.json({ data: [], page_info: {} })); + } + throw new Error(`Unexpected request: ${request.method} ${url.pathname}`); + }) as typeof fetch; + + const state = await withCwd(workDir, () => + createProject( + { state: createInitialState(), render: () => {} }, + "My App", + "minimal", + )); + + assertEquals( + state.logs.some((entry) => entry.message.includes("projects/my-app already exists")), + true, + ); + // The existing link and the existing file are both untouched, and no + // scaffold file landed in the directory. + assertEquals( + (await Deno.readTextFile(join(existingDir, ".veryfront", "project.json"))).trim(), + '{"projectId":"proj_someone_else"}', + ); + assertEquals(await Deno.readTextFile(join(existingDir, "notes.txt")), "mine\n"); + assertEquals( + await Deno.stat(join(existingDir, "README.md")).then(() => true, () => false), + false, + ); + } finally { + globalThis.fetch = originalFetch; + envKeys.forEach((key, index) => restoreEnv(key, savedEnv[index])); + _resetEnvironmentConfig(); + await Deno.remove(workDir, { recursive: true }); + await Deno.remove(configHome, { recursive: true }); + } + }); }); diff --git a/cli/app/operations/project-creation.ts b/cli/app/operations/project-creation.ts index c10e479d03..cd68d6cab5 100644 --- a/cli/app/operations/project-creation.ts +++ b/cli/app/operations/project-creation.ts @@ -5,7 +5,7 @@ * including remote project registration and local scaffolding. */ -import { cwd } from "veryfront/platform"; +import { createFileSystem, cwd } from "veryfront/platform"; import { join } from "veryfront/platform/path"; import type { AppState } from "../state.ts"; import { addLog, setProjects, setRemoteProjects } from "../state.ts"; @@ -49,6 +49,19 @@ export async function createProject( const reserved = await reserveProjectSlug(normalizedSlug, token); const slug = reserved.slug; + // `veryfront init` deliberately scaffolds into a directory that is already + // there. This caller must not: it has just reserved a brand new remote + // slug, and `resolveOrCreateProject` below writes the link for it. Adopting + // an existing `projects/` would point a directory that is already + // someone else's project at the project just reserved. + const projectDir = join(cwd(), "projects", slug); + if (await createFileSystem().exists(projectDir)) { + return addLog( + "error", + `projects/${slug} already exists. Remove it or choose a different name.`, + )(state); + } + const creation = await createSharedProject({ name: slug, parentDir: join(cwd(), "projects"), diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index 08b9fd918f..b3ecdef6de 100644 --- a/cli/shared/project-creation.test.ts +++ b/cli/shared/project-creation.test.ts @@ -728,7 +728,7 @@ describe("createProject into an existing named directory", () => { }); }); -describe("createProject when something blocks a scaffold directory", () => { +describe("createProject when a path cannot be written through", () => { it("refuses a file where the scaffold needs a directory, before writing anything", async () => { const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-file-" }); const projectDir = join(parentDir, "contract-project"); @@ -847,6 +847,71 @@ describe("createProject when something blocks a scaffold directory", () => { } }); + it("refuses a link at a scaffold path itself, dangling or not", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-leaf-link-" }); + const projectDir = join(parentDir, "contract-project"); + const outside = join(parentDir, "outside.md"); + + try { + await Deno.mkdir(projectDir); + // A dangling link resolves to nothing, so `findExistingPaths` reports it + // absent and the write follows it out of the project. + await Deno.symlink(outside, join(projectDir, "README.md")); + + await assertRejects( + () => createProject(baseRequest(parentDir)), + Error, + 'Directory "contract-project" already contains README.md as a file or a link', + ); + + assertEquals(await exists(outside), false); + assertEquals(await exists(join(projectDir, "AGENTS.md")), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("refuses a link at a scaffold path under --force as well", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-leaf-force-" }); + const projectDir = join(parentDir, "contract-project"); + const outside = join(parentDir, "outside.md"); + + try { + await Deno.mkdir(projectDir); + await Deno.symlink(outside, join(projectDir, "README.md")); + + await assertRejects( + () => createProject({ ...baseRequest(parentDir), conflictPolicy: "overwrite" }), + Error, + 'Directory "contract-project" already contains README.md as a file or a link', + ); + + assertEquals(await exists(outside), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + + it("still reports a real file at a scaffold path as an overwritable conflict", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-create-leaf-file-" }); + const projectDir = join(parentDir, "contract-project"); + + try { + await Deno.mkdir(projectDir); + await Deno.writeTextFile(join(projectDir, "README.md"), "mine\n"); + + // A real file resolves fine, so it stays a conflict pointing at --force + // rather than the refusal above. + await assertRejects( + () => createProject(baseRequest(parentDir)), + Error, + 'Directory "contract-project" already contains README.md. Use --force to overwrite.', + ); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + it("scaffolds normally when the directories it needs are absent or already directories", async () => { const parentDir = await makeTempDir({ prefix: "veryfront-create-blocked-clear-" }); const projectDir = join(parentDir, "contract-project"); diff --git a/cli/shared/project-creation.ts b/cli/shared/project-creation.ts index f4a4d8df4e..a8295fcb34 100644 --- a/cli/shared/project-creation.ts +++ b/cli/shared/project-creation.ts @@ -505,35 +505,48 @@ async function findExistingPaths(dir: string, paths: string[]): Promise ../elsewhere` makes `app/page.tsx` + * resolve outside the project, and a dangling `README.md -> ../outside.md` + * resolves to nothing at all, so both are reported absent and the write then + * follows the link out of the project. + * - a regular file where a directory has to go. `app/page.tsx` cannot resolve + * through a file named `app`, so the write stops halfway through instead. + * + * A real file sitting at a scaffold path is not listed here. That one resolves + * fine and is the conflict `findExistingPaths` reports. */ -async function findBlockedDirectories(dir: string, paths: string[]): Promise { +async function findUnwritablePaths(dir: string, paths: string[]): Promise { const fs = createFileSystem(); - // `lstat` reports a link as "not a directory", which is exactly the answer - // this needs. It is optional only for virtual filesystems that have no - // links of their own; every runtime this CLI scaffolds on provides it, and - // `stat` still catches a plain file in the way if one ever does not. + // `lstat` is what makes a link visible: `stat` follows it and reports the + // target. It is optional only for virtual filesystems that have no links of + // their own; every runtime this CLI scaffolds on provides it, and `stat` + // still catches a plain file in the way if one ever does not. const describe = fs.lstat?.bind(fs) ?? fs.stat.bind(fs); const blocked = new Set(); for (const path of paths) { - const segments = path.split("/").slice(0, -1); + const segments = path.split("/"); for (let depth = 1; depth <= segments.length; depth++) { - const ancestor = segments.slice(0, depth).join("/"); - if (blocked.has(ancestor)) break; + const prefix = segments.slice(0, depth).join("/"); + if (blocked.has(prefix)) break; let info: Awaited>; try { - info = await describe(join(dir, ancestor)); + info = await describe(join(dir, prefix)); } catch { break; // Nothing there yet, so nothing below it either. } - if (!info.isDirectory) { - blocked.add(ancestor); + if (info.isSymlink) { + blocked.add(prefix); + break; + } + // The last segment is the file itself, and a real file there is a + // conflict rather than something to refuse outright. + if (depth < segments.length && !info.isDirectory) { + blocked.add(prefix); break; } } @@ -564,11 +577,11 @@ export async function createProject( // Checked whatever the conflict policy is: `--force` says you accept your // files being replaced, not the scaffold writing somewhere else entirely. - const blocked = await findBlockedDirectories(projectDir, writePaths); - if (blocked.length) { + const unwritable = await findUnwritablePaths(projectDir, writePaths); + if (unwritable.length) { throw createConfigError( - `${where} already contains ${blocked.join(", ")} as a file or a link, ` + - `and the scaffold needs a directory there. Move it aside or use a different name.`, + `${where} already contains ${unwritable.join(", ")} as a file or a link ` + + `the scaffold cannot write through. Move it aside or use a different name.`, ); } diff --git a/docs/api-reference/veryfront/scaffold.md b/docs/api-reference/veryfront/scaffold.md index 6ab1b5b16a..a486432414 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#L646) | +| `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#L659) | ### 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#L661) | -| `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#L700) | -| `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#L653) | +| `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#L674) | +| `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#L713) | +| `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#L666) | ### 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#L682) | -| `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#L666) | +| `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#L695) | +| `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#L679) | | `TemplateFile` | | [source](https://github.com/veryfront/veryfront-code/blob/main/templates/types.ts#L17) |