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
69 changes: 69 additions & 0 deletions cli/app/operations/project-creation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
15 changes: 14 additions & 1 deletion cli/app/operations/project-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/<slug>` 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"),
Expand Down
2 changes: 1 addition & 1 deletion cli/commands/init/init-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
34 changes: 3 additions & 31 deletions cli/commands/init/init-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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<typeof createTransientSpinner> | null = null;
const installObserver: ProjectCreationObserver = {
Expand Down
6 changes: 4 additions & 2 deletions cli/commands/init/init-deploy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,18 @@ 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",
"ai-agent",
"--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 () => {
Expand Down
22 changes: 20 additions & 2 deletions cli/commands/init/init.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
Expand Down
Loading
Loading