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/init-command.test.ts b/cli/commands/init/init-command.test.ts index 731cf7de94..8bfdb8f0c2 100644 --- a/cli/commands/init/init-command.test.ts +++ b/cli/commands/init/init-command.test.ts @@ -215,3 +215,35 @@ describe("initCommand target directory", () => { } }); }); + +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/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; diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index 3697f3d18c..4259671198 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,120 @@ 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("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"); + + 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) |