Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/commands/init/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const initHelp: CommandHelp = {
},
{
flag: "-f, --force",
description: "Overwrite existing directory",
description: "Overwrite existing files and directories",
},
{
flag: "-c, --config <file>",
Expand Down
32 changes: 32 additions & 0 deletions cli/commands/init/init-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
});
});
2 changes: 1 addition & 1 deletion cli/commands/init/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions cli/shared/project-creation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "#veryfront/schemas/_test-setup.ts";

import {
assert,
assertEquals,
assertInstanceOf,
assertRejects,
Expand Down Expand Up @@ -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(() => {});
}
});
});
40 changes: 40 additions & 0 deletions cli/shared/project-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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 = {},
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions docs/api-reference/veryfront/scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |