diff --git a/cli/commands/generate/command.ts b/cli/commands/generate/command.ts index 5b7b2ffc1a..510c66c3a4 100644 --- a/cli/commands/generate/command.ts +++ b/cli/commands/generate/command.ts @@ -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 { + 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; + devDependencies?: Record; + imports?: Record; + }>(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 { + 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".`, + ); +} async function getPreferredRouter( projectDir: string, @@ -25,6 +96,8 @@ export async function generateCommand( type: string, name: string, ): Promise { + await warnIfOutsideProject(projectDir); + const preferred = await getPreferredRouter(projectDir); if (type === "integration") { diff --git a/cli/commands/generate/generate.integration.test.ts b/cli/commands/generate/generate.integration.test.ts index ae8724afa3..ae90ee7358 100644 --- a/cli/commands/generate/generate.integration.test.ts +++ b/cli/commands/generate/generate.integration.test.ts @@ -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, @@ -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(); + } + }); + }); + }); });