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
73 changes: 73 additions & 0 deletions cli/commands/generate/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,77 @@ import { cliLogger } from "#cli/utils";
import { createError, toError } from "veryfront/errors";
import { generateIntegration } from "./integration-generator.ts";
import { isScaffoldType, scaffoldProjectFile } from "../../scaffold/engine.ts";
import { exists, readTextFile } from "#veryfront/compat/fs.ts";
import { join } from "#veryfront/compat/path";
import { parseExtensionManifest } from "#veryfront/extensions/manifest-reader.ts";

const PROJECT_MARKERS = [
"veryfront.config.ts",
"veryfront.config.js",
"veryfront.config.mjs",
// Legacy, but still read and merged by the CLI config loader, so a project
// identified only by this file is a real project. `veryfront.config.json` is
// deliberately absent — the loader does not recognise that name.
"veryfront.json",
] as const;

// Deno accepts JSONC grammar for `deno.json` as well as `deno.jsonc`, matching
// `src/extensions/discovery.ts`. Parsing those with strict JSON makes a
// commented manifest look like no evidence at all and fires a false warning.
const PROJECT_MANIFESTS = [
{ name: "package.json", syntax: "json" },
{ name: "deno.json", syntax: "jsonc" },
{ name: "deno.jsonc", syntax: "jsonc" },
] as const;

/**
* `generate` writes into whatever directory it is invoked from, so running it
* one level above the project (or in the wrong terminal tab) silently produces
* a stray `app/` tree. `dev` already warns in this situation; match it rather
* than failing, so scaffolding into a not-yet-configured directory still works.
*/
async function looksLikeVeryfrontProject(projectDir: string): Promise<boolean> {
for (const marker of PROJECT_MARKERS) {
if (await exists(join(projectDir, marker))) return true;
}

for (const manifest of PROJECT_MANIFESTS) {
const path = join(projectDir, manifest.name);
if (!(await exists(path))) continue;
try {
const parsed = parseExtensionManifest<{
dependencies?: Record<string, unknown>;
devDependencies?: Record<string, unknown>;
imports?: Record<string, unknown>;
}>(await readTextFile(path), manifest.syntax, manifest.name);
const specifiers = [
...Object.keys(parsed.dependencies ?? {}),
...Object.keys(parsed.devDependencies ?? {}),
...Object.keys(parsed.imports ?? {}),
];
if (
specifiers.some((s) => s === "veryfront" || s.startsWith("veryfront/"))
) {
return true;
}
} catch {
// An unparseable manifest is not evidence either way; keep looking.
}
}

return false;
}

async function warnIfOutsideProject(projectDir: string): Promise<void> {
if (await looksLikeVeryfrontProject(projectDir)) return;
// Deliberately no path: `projectDir` is an absolute machine path, which
// AGENTS.md forbids in user-facing output. The directory is where the user
// already is, so naming it adds nothing they cannot see.
cliLogger.warn(
`The current directory does not look like a Veryfront project; scaffolding here anyway. ` +
`Run this from your project root, or create one with "npm create veryfront".`,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function getPreferredRouter(
projectDir: string,
Expand All @@ -25,6 +96,8 @@ export async function generateCommand(
type: string,
name: string,
): Promise<void> {
await warnIfOutsideProject(projectDir);

const preferred = await getPreferredRouter(projectDir);

if (type === "integration") {
Expand Down
120 changes: 119 additions & 1 deletion cli/commands/generate/generate.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import "#veryfront/schemas/_test-setup.ts";
import { assert } from "#veryfront/testing/assert";
import { join } from "#veryfront/compat/path";
import { describe, it } from "#veryfront/testing/bdd";
import { exists, remove, writeTextFile } from "#veryfront/compat/fs.ts";
import { exists, makeTempDir, remove, writeTextFile } from "#veryfront/compat/fs.ts";
import { generateCommand } from "./index.ts";
import { type TestContext, withTestContext } from "../../../tests/_helpers/context.ts";
import {
__registerLogRecordEmitter,
__resetLoggerConfigForTests,
__resetLogRecordEmitterForTests,
type LogEntry,
} from "#veryfront/utils/logger/logger.ts";

async function setPreferredRouter(
context: TestContext,
Expand Down Expand Up @@ -101,4 +107,116 @@ describe("CLI generate command", () => {
assert(await exists(join(context.projectDir, "skills", "code-review", "SKILL.md")));
});
});

describe("outside a Veryfront project", () => {
function captureLogs(): LogEntry[] {
const entries: LogEntry[] = [];
__resetLoggerConfigForTests();
__registerLogRecordEmitter((entry) => entries.push(entry));
return entries;
}

it("warns before scaffolding into a directory that is not a project", async () => {
const bare = await makeTempDir({ prefix: "generate-not-a-project-" });
const entries = captureLogs();
try {
await generateCommand(bare, "page", "about");

const warning = entries.find((entry) =>
entry.level === "warn" && entry.message.includes("does not look like a Veryfront project")
);
assert(
warning !== undefined,
"expected a warning that the target directory is not a Veryfront project",
);
// Still scaffolds — the warning informs, it does not block.
assert(await exists(join(bare, "app", "about", "page.tsx")));
} finally {
__resetLogRecordEmitterForTests();
__resetLoggerConfigForTests();
await remove(bare, { recursive: true });
}
});

it("does not leak an absolute machine path into the warning", async () => {
// AGENTS.md forbids local absolute paths in user-facing output.
const bare = await makeTempDir({ prefix: "generate-no-path-leak-" });
const entries = captureLogs();
try {
await generateCommand(bare, "page", "about");

const warning = entries.find((entry) =>
entry.level === "warn" && entry.message.includes("does not look like a Veryfront project")
);
assert(warning !== undefined, "expected the outside-project warning");
assert(
!warning.message.includes(bare),
`warning must not contain the absolute path: ${warning?.message}`,
);
} finally {
__resetLogRecordEmitterForTests();
__resetLoggerConfigForTests();
await remove(bare, { recursive: true });
}
});

it("treats a commented deno.jsonc with a veryfront import as a project", async () => {
// Deno permits comments and trailing commas here; strict JSON parsing
// made such a project look like no project at all.
const dir = await makeTempDir({ prefix: "generate-denojsonc-" });
const entries = captureLogs();
try {
await writeTextFile(
join(dir, "deno.jsonc"),
'{\n // the framework\n "imports": {\n "veryfront": "npm:veryfront@^0.1.0",\n },\n}\n',
);
await generateCommand(dir, "page", "about");

const warning = entries.find((entry) =>
entry.level === "warn" && entry.message.includes("does not look like a Veryfront project")
);
assert(warning === undefined, "a commented deno.jsonc must count as project evidence");
} finally {
__resetLogRecordEmitterForTests();
__resetLoggerConfigForTests();
await remove(dir, { recursive: true });
}
});

it("treats a legacy veryfront.json as a project marker", async () => {
const dir = await makeTempDir({ prefix: "generate-legacy-config-" });
const entries = captureLogs();
try {
await writeTextFile(join(dir, "veryfront.json"), '{ "projectSlug": "legacy-app" }\n');
await generateCommand(dir, "page", "about");

const warning = entries.find((entry) =>
entry.level === "warn" && entry.message.includes("does not look like a Veryfront project")
);
assert(warning === undefined, "veryfront.json is still read by the config loader");
} finally {
__resetLogRecordEmitterForTests();
__resetLoggerConfigForTests();
await remove(dir, { recursive: true });
}
});

it("stays quiet inside a real project", async () => {
await withTestContext("generate-in-project-quiet", async (context: TestContext) => {
const entries = captureLogs();
try {
await generateCommand(context.projectDir, "page", "about");

const warning = entries.find((entry) =>
entry.level === "warn" &&
entry.message.includes("does not look like a Veryfront project")
);
assert(warning === undefined, "must not warn inside a real project");
} finally {
__resetLogRecordEmitterForTests();
__resetLoggerConfigForTests();
}
});
});
});
});