Skip to content

fix(init): accept an existing directory unless a scaffold file would be overwritten - #4002

Merged
kwakayama merged 3 commits into
mainfrom
fix/dx-init-empty-dir
Aug 23, 2026
Merged

kwakayama merged 3 commits into
mainfrom
fix/dx-init-empty-dir

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #3983 and #3985.

  • veryfront init app refused any existing app/ — including an empty one (mkdir app && veryfront init app) or a fresh clone holding only .git. Every mainstream scaffolder accepts those.
  • A conflict is now a file the scaffold would write over, not the directory existing. createProject is the single authority for both the named and current-directory paths (same findExistingPaths check fix(init): refuse to overwrite files when scaffolding into the current directory #3985 introduced); the two directory-existence checks in initCommand are removed. The refusal names the files:
    Directory "app" already contains README.md. Use --force to overwrite.
    
  • .gitignore is merged, never a conflict. Behaviour change to be aware of: the interactive wizard now runs before a refusal for a taken name (the old pre-wizard check could not know the template's file list). The message it ends on says exactly what is in the way.
  • Left alone on purpose: the vf_create_project MCP tool keeps its own Directory already exists check/message (cli/mcp/tools/catalog-tools.ts). Aligning it is a separate change.
  • docs/api-reference/veryfront/scaffold.md pins regenerated with CI's Deno 2.7.7.

Found by the vf-dx-dogfood edge-case pass against veryfront@0.1.1251; recorded there as friction and deliberately kept out of #3983.

Test plan

  • RED on the stacked base: createProject into an empty named dir and beside .git/LICENSE threw "already exists"; initCommand into an empty dir was refused pre-wizard
  • GREEN: cli/commands/init/ (incl. init.integration.test.ts, subprocess-level empty-dir + conflict cases), cli/shared/project-creation.test.ts, cli/app/ — 24 files pass
  • Pass 2 from a sandbox outside the repo with deno run -A cli/main.ts: empty dir → scaffolded; .git-only dir → scaffolded beside .git; dir with README.md → refused, exit 1, file intact; no-name into non-empty cwd → still refused (fix(init): refuse to overwrite files when scaffolding into the current directory #3985 guard)
  • deno lint, deno fmt --check, deno check, lint:anti-slop, lint:sanitizer-baseline, lint:test-typecheck, docs:api-reference:check clean

Update: refuse a file or a link where the scaffold needs a directory

Accepting an existing target directory lets the scaffold reach states the old
Directory already exists check never let it see. One of them wrote outside the
project, so this branch now closes it.

findExistingPaths asks whether app/page.tsx exists. When app is a regular
file that path cannot resolve, so the conflict check reports nothing:

  • app is a file: README.md and AGENTS.md get written, then
    ensureDir("app") fails with a raw Not a directory (os error 20), leaving a
    half scaffold behind.
  • app is a link to another directory: nothing fails at all.
    veryfront init proj exits 0, prints proj ready, and leaves page.tsx,
    layout.tsx and about/page.mdx in the link target instead of proj/.

createProject now checks every directory the scaffold has to create, before it
writes anything:

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, so a real app/ holding a file at app/about is
    caught before app/page.tsx is written.
  • A real directory that is already there is never blocked, which is the point of
    this PR: mkdir app && veryfront init app still scaffolds.
  • The project directory itself is not checked. ln -s /mnt/big/app app && veryfront init app is a legitimate setup and still works.
  • The current-directory path had the same hole before this branch, so
    cd repo && veryfront init with a linked app/ wrote outside the repo on
    main too. The check sits in createProject and covers both paths.

Nothing is ever overwritten through a link: findExistingPaths resolves through
it, so an existing elsewhere/page.tsx is still reported as a conflict and
refused with the file byte-identical.

Verification

  • Scenario matrix against the real CLI in a sandbox outside the repo, run on
    origin/main and on this branch side by side: target holding README.md,
    partial scaffold holding only app/page.tsx, symlink to a populated
    directory, a directory where a file is expected, a file where a directory is
    expected, non-empty and unreadable, empty, unrelated files plus an existing
    .gitignore, .git-only, no-name non-empty cwd, and --force. No case where
    main refused and this branch overwrites. Where behaviour opens up (empty
    directory, unrelated files, .git-only) the user's files are byte-identical
    afterwards and .gitignore is merged, not replaced.
  • Five new tests for the blocked-directory check, all red without it.
  • Mutation: skipping the first segment, ignoring nested segments, treating a
    missing path as blocked, refusing any existing ancestor, refusing only when
    every scaffold path exists, refusing any non-empty directory, dropping
    deno.json or the env files from scaffoldWritePaths, and removing either
    guard outright are each caught by the suite.
  • deno task typecheck, deno task lint:ci, deno fmt --check, and
    deno test cli/shared/project-creation.test.ts cli/commands/init/ all exit 0.

Update 2: a link at a scaffold path, and the TUI caller

Codex found two more consequences of accepting an existing directory. Both are fixed.

A link at the scaffold path itself. The first preflight only walked the
directories above a path, so a dangling proj/README.md -> ../outside.md slipped
through: findExistingPaths resolves a dangling link to nothing and reports it
absent. veryfront init proj exited 0, printed proj ready, and wrote the README
to outside.md outside the project. Every segment is now checked, including the
last, and a link anywhere along the path is refused whatever the conflict policy:

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 still the ordinary conflict pointing at
--force, pinned by a test so this cannot drift into refusing any non-empty
directory. The named target being a link stays 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 caller. cli/app/operations/project-creation.ts is the second caller
of createProject with a fail policy, and it relied on the directory check this
branch removes. It reserves a new remote slug, scaffolds into projects/<slug>,
then writes the link for that slug, so with the check gone it would adopt an
existing projects/<slug> holding none of the template files and repoint a
directory that is already another project. It now refuses first. The constraint
lives in that caller rather than in createProject, because the two want opposite
things: veryfront init accepting an existing directory is the point of this PR,
and a freshly reserved slug wanting a fresh directory is the opposite requirement.

Considered and not changed: an orphan package-lock.json is rewritten by
npm install. A real project is already refused, because package.json is in
scaffoldWritePaths; --skip-install never touches the lockfile; so the exposure
is a lockfile with no manifest, which npm regenerates from the manifest the
scaffold just wrote. Adding lockfiles to the preflight would refuse a directory
holding a stray lockfile, which is exactly the behaviour this PR exists to allow.
Reasoning and evidence are on the thread.

Verification

  • Real CLI, sandbox outside the repo: the dangling-leaf case, the same under
    --force, and the same on the no-name path all exit 1 and create nothing,
    inside the project or outside it.
  • The three cases this PR opens up still scaffold: empty directory (9 files),
    unrelated files (notes.txt intact, .gitignore merged), and a .git-only
    directory.
  • Mutation, 10 mutants, all killed. Both coarse mutants from fix(init): refuse to overwrite files when scaffolding into the current directory #3985 (refuse only
    when every scaffold path exists; refuse whenever the directory is non-empty)
    are still killed, and the drift test is still the sole killer of the two
    scaffoldWritePaths mutants.
  • deno task typecheck, deno task lint:ci, deno fmt --check, and
    deno test cli/shared/project-creation.test.ts cli/app/ cli/commands/init/
    (24 files, 334 steps) all exit 0.

…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.
@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@kwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c0144795-7bae-4bbd-8f9a-5915f63e033a

📥 Commits

Reviewing files that changed from the base of the PR and between 7eee200 and 090585f.

📒 Files selected for processing (9)
  • cli/app/operations/project-creation.test.ts
  • cli/app/operations/project-creation.ts
  • cli/commands/init/init-command.test.ts
  • cli/commands/init/init-command.ts
  • cli/commands/init/init-deploy.integration.test.ts
  • cli/commands/init/init.integration.test.ts
  • cli/shared/project-creation.test.ts
  • cli/shared/project-creation.ts
  • docs/api-reference/veryfront/scaffold.md

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kojiwakayama
kojiwakayama enabled auto-merge August 22, 2026 22:27
@github-actions

Copy link
Copy Markdown

📦 Client bundle boundary

Entrypoint Modules Source size Server leaks
src/index.client.ts 327 1961 KiB ✅ 0

A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in scripts/lint/client-bundle-baseline.json to burn down.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c67d0ded7f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cli/shared/project-creation.ts Outdated
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 22, 2026
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.
@kwakayama
kwakayama removed this pull request from the merge queue due to a manual request Aug 22, 2026
@kwakayama

Copy link
Copy Markdown
Contributor

@coderabbitai review

The first review attempt on this PR was rate limited, so the green CodeRabbit check on the earlier head carried no signal. The branch has since gained a commit that closes a path where the scaffold wrote outside the project directory, so a real pass over the current head is worth having.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

@kwakayama I will review the current PR head, including the new directory and link safety checks.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kwakayama

Copy link
Copy Markdown
Contributor

@codex review

Your P2 on cli/shared/project-creation.ts was correct on both halves and is fixed in 865e20004c. Worth a look at the new head, since your earlier review was against c67d0ded7f and the ancestor preflight you asked for is the main thing that changed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 865e20004c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cli/shared/project-creation.ts
Comment thread cli/shared/project-creation.ts
Comment thread cli/shared/project-creation.ts
…h 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/<slug>`, then writes the link for that
slug. With the check gone it would adopt an existing `projects/<slug>` 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.
@kwakayama

Copy link
Copy Markdown
Contributor

@codex review

Your three P2s on 865e20004c are all answered in 090585fd1d. The leaf-link escape and the TUI adoption regression are fixed with tests; the lockfile one is answered on its thread with the reasoning and the evidence bounding it. Worth a pass over the new head.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Deep review — merge confidence 55/100 (→ 85+ once the one blocker below lands)

Verdict: right approach, real-CLI matrix holds up for everything the body lists — except one reproducible hole in the exact class the last two commits exist to close.

What I verified (isolated worktree at 090585f)

  • cli/shared/project-creation.test.ts + cli/app/operations/project-creation.test.ts + cli/commands/init/ → 18 files, 193 steps, 0 failed (1m55s). deno check on the touched entrypoints exit 0. Merges clean with origin/main. Docs diff is pin-churn only.
  • Real-CLI sandbox outside the repo (deno run -A cli/main.ts init proj --template minimal --skip-install --skip-env-prompt), 17 cases. All of these behave as the body says: empty dir → scaffolds; README.md present → exit 1 naming README.md, file intact; app -> ../elsewhere → refused, nothing written; dangling README.md -> ../outside.md → refused; app/about -> … → refused; public -> … → refused; proj -> real-empty-dir → allowed; --force with app link → still refused; no-name path with linked app → refused; --force with real README → overwrites.

BLOCKING

.gitignore is the one scaffold write not covered by the link preflight, and it escapes the project. scaffoldWritePaths (cli/shared/project-creation.ts:490-501) deliberately omits .gitignore so it merges instead of conflicting — but findUnwritablePaths walks only writePaths, so <target>/.gitignore is never lstat'ed, and writeGitignore (:374-386) writes through whatever is there.

Reproduced twice (reviewer agent, then independently by me) with the real CLI:

mkdir proj && ln -s ../outside-gitignore.txt proj/.gitignore
veryfront init proj --template minimal --skip-install --skip-env-prompt
# exit 0, "proj ready" — and ../outside-gitignore.txt now exists OUTSIDE proj/ with the full default gitignore

(readTextFile on the dangling link fails → undefined → default content is written through the link.) A link to an existing outside file gets rewritten with merged content; same under --force; same on the no-name cd dir && veryfront init path.

This contradicts the body's "a link anywhere along the path is refused whatever the conflict policy" / "Nothing is ever overwritten through a link". On main the named path was unreachable (any existing proj/ was refused), so for init <name> this is newly reachable via this PR; the no-name path pre-dates it (#3985).

Fix is small: pass [...writePaths, ".gitignore"] to findUnwritablePaths only (keep it out of findExistingPaths so merge semantics stay), plus one test mirroring the dangling-README.md case.

Non-blocking

  1. TUI slug reservation leaks on refusal (cli/app/operations/project-creation.ts:47-61): reserveProjectSlug POSTs /projects (creates a real remote project) before the local projects/<slug> exists check, so a refusal leaves an orphan remote project. Pre-existing in shape, but this PR added the check and could cheaply lstat projects/<normalizedSlug> before reserving. Message text confirmed: projects/<slug> already exists. Remove it or choose a different name.
  2. Git-init into an existing checkout: the wizard defaults "Initialize Git?" to Yes (interactive-wizard.ts:186-190) and initializeGitRepo runs git init && git add -A && git commit (cli/utils/git.ts:26-60). A wizard user scaffolding into a fresh clone (the .git-only case the PR advertises) or a dir with unrelated files gets a silent "Initial commit" on their checked-out branch that also stages their unrelated untracked files. Non-interactive never runs git. Consider skipping git when .git already exists, or at least a doc note.
  3. vf_create_project divergence (cli/mcp/tools/catalog-tools.ts:400-402) is documented only in the PR body; a one-line in-code comment would stop the next reader "fixing" it.
  4. TOCTOU between lstat preflight and write (no O_NOFOLLOW in the fs abstraction). Normal CLI-scaffolder threat model (attacker already has write access to the dir) — acceptable; state it in the findUnwritablePaths docblock.
  5. Pre-existing, not introduced here: refusals render as ✗ [unknown-error] Unknown/unclassified error with the real text under Detail: (createConfigError path); dangling <name> symlink → raw stat ENOENT.
  6. cli/app/operations/project-creation.test.ts assigns globalThis.fetch directly instead of withMockFetch — matches the file's pre-existing pattern (line 39), nit.

.gitignore merge semantics verified: existing content kept verbatim; appended after a blank line: # Environment files + missing .env/.env.local/.env.*.local (exact trimmed-line dedupe), then .veryfront/ unless present. node_modules/ is not added to an existing file. Identical bytes rewritten if nothing is missing. Wizard-before-refusal is an acceptable trade.

Not re-verified

"10 mutants all killed"; deno task lint:ci (CI green).

Reviewed with Claude Code; the blocking case was reproduced with the real CLI in a sandbox outside the repo.

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Deep review

Merge confidence: 38/100
Recommendation: REQUEST CHANGES
Reviewed head: 090585fd1d0b against current origin/main

Finding

  • HIGH / BLOCK: cli/shared/project-creation.ts:486 excludes .gitignore from scaffoldWritePaths(), and cli/shared/project-creation.ts:575 reuses that list as the only symlink/unwritable preflight. writeGitignore() still writes projectDir/.gitignore at line 384.

    Failure scenario: in a newly accepted existing named directory, proj/.gitignore -> ../outside.gitignore passes preflight. The init succeeds and writes generated ignore content outside the selected project directory. This was reproduced on the current head. A directory at .gitignore also fails only after other scaffold files have been written, leaving a partial scaffold.

    Fix: keep .gitignore out of overwrite-conflict detection so merging remains supported, but include it in a separate complete write-target preflight. Reject symlink leaves/ancestors and directory-at-file-leaf cases. Add named-init and --force regressions for existing and dangling .gitignore symlinks.

Standards

The single-authority move into createProject is good, but the new filesystem trust boundary omits a real write target.

Spec

Partial. Empty/unrelated directories and normal scaffold-path links are handled, but the stated link-anywhere protection is not complete.

Architecture

BLOCK: overwrite conflicts and safe write targets are different sets; conflating them creates an out-of-directory write.

Verification

  • Five focused init/project-creation test files: passed, 119 steps, but do not cover this target
  • Direct symlink and directory repros: confirmed
  • deno check, format, and git diff --check: passed

@kwakayama
kwakayama added this pull request to the merge queue Aug 23, 2026
Merged via the queue into main with commit 4f28bb9 Aug 23, 2026
38 of 43 checks passed
@kwakayama
kwakayama deleted the fix/dx-init-empty-dir branch August 23, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants