From 7014a00c79fcdf4a171c5ac450847f40f90fac31 Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Thu, 4 Jun 2026 13:07:04 +0500 Subject: [PATCH 1/3] fix(builder): consolidate schema builder on dual-write (retire file mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder now writes ui-schema.json on every create/edit — fixing two reported issues: - Create pages (collections/singles/components) ignored the mode flag and applied to the DB with fields: [], producing title+slug-only tables. They now mirror the new entity to ui-schema.json (best-effort) after the DB create. - File mode (NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE=1) was half-built: it skipped the DB but the builder loads entities from the DB and has no read path, so it could not apply migrations. Retire the flag + isUiSchemaWriteMode(); the builder always dual-writes (DB + ui-schema.json), which is the path that already works end-to-end. Database mode already dual-wrote ui-schema.json, so the committable manifest + migrate workflow is unchanged. Keeps UI_SCHEMA_FIELD_TYPES for the field picker. --- docs/guides/testing-migrations.md | 12 +++--- .../lib/builder/to-manifest-entity.test.ts | 14 +++++++ .../src/lib/builder/ui-schema-mode.test.ts | 31 +--------------- .../admin/src/lib/builder/ui-schema-mode.ts | 18 +++------ .../dashboard/collections/builder/[slug].tsx | 28 -------------- .../dashboard/collections/builder/index.tsx | 37 ++++++++++++++++--- .../dashboard/component/builder/index.tsx | 25 ++++++++++++- .../pages/dashboard/singles/builder/index.tsx | 28 +++++++++++++- packages/admin/src/services/schemaFileApi.ts | 3 +- 9 files changed, 111 insertions(+), 85 deletions(-) diff --git a/docs/guides/testing-migrations.md b/docs/guides/testing-migrations.md index 81ac655..91947dc 100644 --- a/docs/guides/testing-migrations.md +++ b/docs/guides/testing-migrations.md @@ -9,6 +9,11 @@ the migration CLI, and authoring schema two ways — **code-first** and via the > Make sure `DATABASE_URL` in `apps/playground/.env` points at a **throwaway** > Postgres DB — several commands drop tables. +> **Looking for the full playbook?** This page is the quick reference. For an +> exhaustive manual-testing guide — every command + flag, the migrate lock, +> rollback, production run-on-boot, CI/build integration, and troubleshooting — +> see [manual-testing-migrations.md](./manual-testing-migrations.md). + --- ## 1. The migration commands @@ -61,11 +66,8 @@ export const Books = defineCollection({ ### B. Admin UI (the builder) -1. In `apps/playground/.env`, set the builder mode: - - `NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE=0` → **database mode** (applies to the DB - **and** writes `ui-schema.json` — the new dual-write). - - `NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE=1` → **file mode** (writes - `ui-schema.json` only, no DB change). +1. The builder always **dual-writes**: every create/edit/delete applies to the + dev DB **and** writes the committable `ui-schema.json`. (No mode flag.) 2. Start the dev server: `pnpm dev:app` (from the repo root). 3. Go to `/admin` → builder → create a **collection** or **single**, add fields (the picker now offers the full canonical set, incl. `relationship` + diff --git a/packages/admin/src/lib/builder/to-manifest-entity.test.ts b/packages/admin/src/lib/builder/to-manifest-entity.test.ts index 88c53f4..4e50911 100644 --- a/packages/admin/src/lib/builder/to-manifest-entity.test.ts +++ b/packages/admin/src/lib/builder/to-manifest-entity.test.ts @@ -88,4 +88,18 @@ describe("collectionToManifestEntity", () => { }) ).toThrowError(/unsupported field type/i); }); + + it("maps an empty field list to a field-less entity with labels + status (create case)", () => { + const entity = collectionToManifestEntity({ + slug: "widgets", + settings: { singularName: "Widget", pluralName: "Widgets", status: true }, + fields: [], + }); + expect(entity).toEqual({ + slug: "widgets", + fields: [], + labels: { singular: "Widget", plural: "Widgets" }, + status: true, + }); + }); }); diff --git a/packages/admin/src/lib/builder/ui-schema-mode.test.ts b/packages/admin/src/lib/builder/ui-schema-mode.test.ts index 14754bd..9639aaf 100644 --- a/packages/admin/src/lib/builder/ui-schema-mode.test.ts +++ b/packages/admin/src/lib/builder/ui-schema-mode.test.ts @@ -1,36 +1,9 @@ /** * @module lib/builder/ui-schema-mode.test - * @since v0.0.3-alpha (Plan D4) */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; -import { isUiSchemaWriteMode, UI_SCHEMA_FIELD_TYPES } from "./ui-schema-mode"; - -const ORIGINAL_ENV = process.env.NODE_ENV; -const ORIGINAL_FLAG = process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE; - -beforeEach(() => { - process.env.NODE_ENV = "development"; - process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE = "1"; -}); -afterEach(() => { - process.env.NODE_ENV = ORIGINAL_ENV; - process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE = ORIGINAL_FLAG; -}); - -describe("isUiSchemaWriteMode", () => { - it("is true in development with the flag on", () => { - expect(isUiSchemaWriteMode()).toBe(true); - }); - it("is false when the flag is off", () => { - process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE = ""; - expect(isUiSchemaWriteMode()).toBe(false); - }); - it("is false in production even with the flag on", () => { - process.env.NODE_ENV = "production"; - expect(isUiSchemaWriteMode()).toBe(false); - }); -}); +import { UI_SCHEMA_FIELD_TYPES } from "./ui-schema-mode"; describe("UI_SCHEMA_FIELD_TYPES", () => { it("is the full canonical field-type set (mirrors the package UI_FIELD_TYPES)", () => { diff --git a/packages/admin/src/lib/builder/ui-schema-mode.ts b/packages/admin/src/lib/builder/ui-schema-mode.ts index 5ad9fc0..1601f70 100644 --- a/packages/admin/src/lib/builder/ui-schema-mode.ts +++ b/packages/admin/src/lib/builder/ui-schema-mode.ts @@ -1,10 +1,10 @@ /** - * UI-schema write-mode gate for the admin schema builder (spec §4.12). + * UI-schema field-type allowlist for the admin schema builder. * - * The builder is dev-only. When this mode is active, "Save" writes the - * committable `ui-schema.json` manifest via the dev API instead of applying - * directly to the database. Opt-in (flag, default off) so the existing - * DB-apply dev flow is unchanged until a project adopts the JSON workflow. + * The builder always dual-writes — every create/edit/delete applies to the dev + * DB and mirrors to the committable `ui-schema.json` — so there is no longer a + * mode flag. This module just owns the canonical field-type set the picker and + * the manifest mappers share with the package zod schema. * * @module lib/builder/ui-schema-mode * @since v0.0.3-alpha (Plan D4) @@ -40,11 +40,3 @@ export const UI_SCHEMA_FIELD_TYPES = [ ] as const; export type UiSchemaFieldType = (typeof UI_SCHEMA_FIELD_TYPES)[number]; - -/** True when builder saves should write ui-schema.json (dev + opt-in flag). */ -export function isUiSchemaWriteMode(): boolean { - return ( - process.env.NODE_ENV === "development" && - process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE === "1" - ); -} diff --git a/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx b/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx index 8de398c..d77b2c3 100644 --- a/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx +++ b/packages/admin/src/pages/dashboard/collections/builder/[slug].tsx @@ -61,7 +61,6 @@ import { nextDuplicateName } from "@admin/lib/builder/duplicate-field-name"; import { isInsideRepeatingAncestor } from "@admin/lib/builder/is-inside-repeating-ancestor"; import { packIntoRows, parseWidth } from "@admin/lib/builder/reflow"; import { collectionToManifestEntity } from "@admin/lib/builder/to-manifest-entity"; -import { isUiSchemaWriteMode } from "@admin/lib/builder/ui-schema-mode"; import { COLLECTION_BUILDER_CONFIG } from "@admin/pages/dashboard/collections/builder/builder-config"; import { schemaApi, @@ -278,33 +277,6 @@ export default function CollectionBuilderEditPage({ ) => { if (!slug) return; - // D4: file-write mode (dev + opt-in flag). Persist to ui-schema.json via - // the dev API instead of mutating the DB. No DB change → no restart; the - // change applies later via `nextly migrate:create` + `migrate`. - if (isUiSchemaWriteMode()) { - try { - const entity = collectionToManifestEntity({ - slug, - settings: { - singularName: settings?.singularName, - pluralName: settings?.pluralName, - }, - fields: fieldDefinitions, - }); - await schemaFileApi.writeCollection(entity); - const collectionLabel = settings?.singularName?.trim() || slug; - toast.success(`${collectionLabel} written to ui-schema.json`); - setShowSchemaDialog(false); - setPreviewData(null); - setOriginalFields(builder.fields.filter(f => !f.isSystem)); - if (settings) setOriginalSettings(settings); - } catch (err) { - const errorObj = err as { message?: string }; - toast.error(errorObj?.message || "Failed to write ui-schema.json"); - } - return; - } - setIsApplyingSchema(true); if (typeof window !== "undefined") window.__nextlySchemaApplying = true; startRestart(); diff --git a/packages/admin/src/pages/dashboard/collections/builder/index.tsx b/packages/admin/src/pages/dashboard/collections/builder/index.tsx index b82b732..25e9357 100644 --- a/packages/admin/src/pages/dashboard/collections/builder/index.tsx +++ b/packages/admin/src/pages/dashboard/collections/builder/index.tsx @@ -24,7 +24,9 @@ import { toast } from "@admin/components/ui"; import { ROUTES, buildRoute } from "@admin/constants/routes"; import { useCreateCollection } from "@admin/hooks/queries"; import { toSnakeName } from "@admin/lib/builder"; +import { collectionToManifestEntity } from "@admin/lib/builder/to-manifest-entity"; import { navigateTo } from "@admin/lib/navigation"; +import { schemaFileApi } from "@admin/services/schemaFileApi"; import { COLLECTION_BUILDER_CONFIG } from "./builder-config"; @@ -64,11 +66,36 @@ export default function CollectionBuilderPage(): React.ReactElement | null { }, { onSuccess: () => { - toast.success("Collection created"); - // Navigate to the edit page where the user can add fields. - // We use the slug we computed because the API doesn't return - // the created entity in this response shape. - navigateTo(buildRoute(ROUTES.BUILDER_COLLECTIONS_EDIT, { slug })); + // Mirror the new (field-less) collection to ui-schema.json so the + // committable manifest stays in sync with the dev DB. Best-effort: + // a file-write failure warns but never blocks navigation (the DB + // create already succeeded). Wrapped in a void IIFE so the mutation + // callback stays synchronous (void return). + void (async () => { + try { + await schemaFileApi.writeCollection( + collectionToManifestEntity({ + slug, + settings: { + singularName: singular, + pluralName: plural, + status: values.status === true, + }, + fields: [], + }) + ); + } catch (err) { + const m = (err as { message?: string })?.message; + toast.warning( + `Collection created, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.` + ); + } + toast.success("Collection created"); + // Navigate to the edit page where the user can add fields. + // We use the slug we computed because the API doesn't return + // the created entity in this response shape. + navigateTo(buildRoute(ROUTES.BUILDER_COLLECTIONS_EDIT, { slug })); + })(); }, onError: err => { const error = err as { message?: string }; diff --git a/packages/admin/src/pages/dashboard/component/builder/index.tsx b/packages/admin/src/pages/dashboard/component/builder/index.tsx index e688aa3..bf47cc0 100644 --- a/packages/admin/src/pages/dashboard/component/builder/index.tsx +++ b/packages/admin/src/pages/dashboard/component/builder/index.tsx @@ -23,7 +23,9 @@ import { toast } from "@admin/components/ui"; import { ROUTES, buildRoute } from "@admin/constants/routes"; import { useCreateComponent } from "@admin/hooks/queries/useComponents"; import { toSnakeName } from "@admin/lib/builder"; +import { componentToManifestEntity } from "@admin/lib/builder/to-manifest-entity-component"; import { navigateTo } from "@admin/lib/navigation"; +import { schemaFileApi } from "@admin/services/schemaFileApi"; import { COMPONENT_BUILDER_CONFIG } from "./builder-config"; @@ -55,8 +57,27 @@ export default function ComponentBuilderPage(): React.ReactElement | null { }, { onSuccess: () => { - toast.success("Component created"); - navigateTo(buildRoute(ROUTES.BUILDER_COMPONENTS_EDIT, { slug })); + // Mirror the new (field-less) component to ui-schema.json (best-effort; + // a file-write failure warns but never blocks navigation). Wrapped in + // a void IIFE so the mutation callback stays synchronous. + void (async () => { + try { + await schemaFileApi.writeComponent( + componentToManifestEntity({ + slug, + settings: { singularName: singular }, + fields: [], + }) + ); + } catch (err) { + const m = (err as { message?: string })?.message; + toast.warning( + `Component created, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.` + ); + } + toast.success("Component created"); + navigateTo(buildRoute(ROUTES.BUILDER_COMPONENTS_EDIT, { slug })); + })(); }, onError: err => { const error = err as { message?: string }; diff --git a/packages/admin/src/pages/dashboard/singles/builder/index.tsx b/packages/admin/src/pages/dashboard/singles/builder/index.tsx index 8b64b00..f571842 100644 --- a/packages/admin/src/pages/dashboard/singles/builder/index.tsx +++ b/packages/admin/src/pages/dashboard/singles/builder/index.tsx @@ -20,7 +20,9 @@ import { toast } from "@admin/components/ui"; import { ROUTES, buildRoute } from "@admin/constants/routes"; import { useCreateSingle } from "@admin/hooks/queries"; import { toSnakeName } from "@admin/lib/builder"; +import { singleToManifestEntity } from "@admin/lib/builder/to-manifest-entity-single"; import { navigateTo } from "@admin/lib/navigation"; +import { schemaFileApi } from "@admin/services/schemaFileApi"; import { SINGLE_BUILDER_CONFIG } from "./builder-config"; @@ -60,8 +62,30 @@ export default function SingleBuilderPage(): React.ReactElement | null { }, { onSuccess: () => { - toast.success("Single created"); - navigateTo(buildRoute(ROUTES.BUILDER_SINGLES_EDIT, { slug })); + // Mirror the new (field-less) single to ui-schema.json (best-effort; + // a file-write failure warns but never blocks navigation). Wrapped in + // a void IIFE so the mutation callback stays synchronous. + void (async () => { + try { + await schemaFileApi.writeSingle( + singleToManifestEntity({ + slug, + settings: { + singularName: singular, + status: values.status === true, + }, + fields: [], + }) + ); + } catch (err) { + const m = (err as { message?: string })?.message; + toast.warning( + `Single created, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.` + ); + } + toast.success("Single created"); + navigateTo(buildRoute(ROUTES.BUILDER_SINGLES_EDIT, { slug })); + })(); }, onError: err => { const error = err as { message?: string }; diff --git a/packages/admin/src/services/schemaFileApi.ts b/packages/admin/src/services/schemaFileApi.ts index c408260..0bf0931 100644 --- a/packages/admin/src/services/schemaFileApi.ts +++ b/packages/admin/src/services/schemaFileApi.ts @@ -1,7 +1,8 @@ /** * Dev-only client for writing `ui-schema.json` via the package's * `/admin/api/_dev/schema/*` endpoints (spec §4.12.3). Used by the schema - * builder when `isUiSchemaWriteMode()` is active. + * builder on every create/edit/delete (dual-write), keeping `ui-schema.json` + * in sync with the dev DB. * * @module services/schemaFileApi * @since v0.0.3-alpha (Plan D4) From bb05505778fb928970825f6806576bf507d2d10a Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Thu, 4 Jun 2026 13:07:30 +0500 Subject: [PATCH 2/3] fix(playground): dev:reset auto-detects dialect from DATABASE_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev:reset defaulted to DB_DIALECT ?? "sqlite", so a bare `pnpm dev:reset` against a Postgres/Neon DATABASE_URL silently wiped a nonexistent SQLite file and left the real DB untouched (stale dynamic_collections + extra tables). Extract resolveDialect(dbDialect, databaseUrl): an explicit DB_DIALECT wins, otherwise detect from the URL scheme — matching how the migrate CLI already auto-detects. --- .../scripts/__tests__/reset.test.ts | 27 ++++++++++++- apps/playground/scripts/reset.ts | 38 +++++++++++++++---- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/apps/playground/scripts/__tests__/reset.test.ts b/apps/playground/scripts/__tests__/reset.test.ts index 6b0c8e2..578629a 100644 --- a/apps/playground/scripts/__tests__/reset.test.ts +++ b/apps/playground/scripts/__tests__/reset.test.ts @@ -7,7 +7,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { wipeDbSqlite, wipeFileState } from "../reset"; +import { resolveDialect, wipeDbSqlite, wipeFileState } from "../reset"; const TMP = path.join("/tmp", "playground-reset-test"); @@ -47,6 +47,31 @@ describe("wipeFileState", () => { }); }); +describe("resolveDialect", () => { + it("detects postgresql from a postgres:// URL when DB_DIALECT is unset", () => { + expect(resolveDialect(undefined, "postgresql://u:p@host/db")).toBe( + "postgresql" + ); + expect(resolveDialect(undefined, "postgres://u:p@host/db")).toBe( + "postgresql" + ); + }); + + it("detects mysql from a mysql:// URL", () => { + expect(resolveDialect(undefined, "mysql://u:p@host/db")).toBe("mysql"); + }); + + it("falls back to sqlite for a file: URL or no URL", () => { + expect(resolveDialect(undefined, "file:./data/x.db")).toBe("sqlite"); + expect(resolveDialect(undefined, undefined)).toBe("sqlite"); + }); + + it("honors an explicit DB_DIALECT over the URL (and normalizes 'postgres')", () => { + expect(resolveDialect("postgres", "file:./x.db")).toBe("postgresql"); + expect(resolveDialect("sqlite", "postgresql://u:p@host/db")).toBe("sqlite"); + }); +}); + describe("wipeDbSqlite", () => { beforeEach(() => fs.rm(TMP, { recursive: true, force: true })); afterEach(() => fs.rm(TMP, { recursive: true, force: true })); diff --git a/apps/playground/scripts/reset.ts b/apps/playground/scripts/reset.ts index 52881df..438be2a 100644 --- a/apps/playground/scripts/reset.ts +++ b/apps/playground/scripts/reset.ts @@ -2,9 +2,10 @@ * Playground reset. * * Wipes the local DB, uploaded media, Next.js / Turbo caches, generated - * types and Drizzle migrations, then re-runs the seed. Branches on - * DB_DIALECT: SQLite deletes the file, Postgres drops/recreates the - * `public` schema, MySQL drops/recreates the database. + * types and Drizzle migrations, then re-runs the seed. The dialect is taken + * from `DB_DIALECT` when set, otherwise auto-detected from `DATABASE_URL` + * (see `resolveDialect`): SQLite deletes the file, Postgres drops/recreates + * the `public` schema, MySQL drops/recreates the database. * * Does NOT spawn `next dev`. Contributor runs `pnpm dev:app` after. * @@ -12,8 +13,8 @@ * to other tools and survive a reset. * * Usage: - * pnpm dev:reset # SQLite by default - * DB_DIALECT=postgresql pnpm dev:reset + * pnpm dev:reset # dialect auto-detected from DATABASE_URL + * DB_DIALECT=postgresql pnpm dev:reset # explicit override * DB_DIALECT=mysql pnpm dev:reset */ import * as fs from "node:fs/promises"; @@ -101,9 +102,32 @@ function resolveSqlitePath(databaseUrl: string | undefined): string { return path.isAbsolute(raw) ? raw : path.resolve(PLAYGROUND_DIR, raw); } +/** + * Resolve the DB dialect to reset. An explicit `DB_DIALECT` wins; otherwise + * detect from the `DATABASE_URL` scheme (matching how the `nextly` migrate CLI + * auto-detects). Falling back to "sqlite" only when there's no Postgres/MySQL + * URL avoids the footgun where a bare `pnpm dev:reset` against a Postgres + * `DATABASE_URL` silently wiped a nonexistent SQLite file and left the real DB + * untouched. + */ +export function resolveDialect( + dbDialect: string | undefined, + databaseUrl: string | undefined +): "sqlite" | "postgresql" | "mysql" { + if (dbDialect === "postgresql" || dbDialect === "postgres") + return "postgresql"; + if (dbDialect === "mysql") return "mysql"; + if (dbDialect === "sqlite") return "sqlite"; + + const url = databaseUrl ?? ""; + if (/^postgres(ql)?:\/\//i.test(url)) return "postgresql"; + if (/^mysql:\/\//i.test(url)) return "mysql"; + return "sqlite"; // file: URL or unset +} + export async function runReset(): Promise { - const dialect = process.env.DB_DIALECT ?? "sqlite"; const databaseUrl = process.env.DATABASE_URL; + const dialect = resolveDialect(process.env.DB_DIALECT, databaseUrl); console.log("[nextly] Wiping file state..."); await wipeFileState(PLAYGROUND_DIR); @@ -111,7 +135,7 @@ export async function runReset(): Promise { console.log(`[nextly] Wiping ${dialect} database...`); if (dialect === "sqlite") { await wipeDbSqlite(resolveSqlitePath(databaseUrl)); - } else if (dialect === "postgresql" || dialect === "postgres") { + } else if (dialect === "postgresql") { if (!databaseUrl) { throw new Error("DATABASE_URL is required for postgres reset"); } From 26b5e01606579ea514d58edf66417aa5b65742a5 Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Thu, 4 Jun 2026 13:11:31 +0500 Subject: [PATCH 3/3] test(playground): update doctor checkEnvFile tests to match auto-copy behavior checkEnvFile now copies .env.example to .env when present (and asks to create manually when both are missing); the old test still expected the removed 'cp .env.example .env' hint and failed. Cover both real branches instead. --- .../scripts/__tests__/doctor.test.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/playground/scripts/__tests__/doctor.test.ts b/apps/playground/scripts/__tests__/doctor.test.ts index 08c9d38..2cd5c0f 100644 --- a/apps/playground/scripts/__tests__/doctor.test.ts +++ b/apps/playground/scripts/__tests__/doctor.test.ts @@ -42,12 +42,24 @@ describe("doctor.checkEnvFile", () => { await fs.rm(tmp, { recursive: true }); }); - it("returns failure with copy command when .env is missing", async () => { - // Use a path that ends in .env so the doctor's path-derivation regex - // (replace(/\.env$/, ".env.example")) produces a real "cp .env.example .env" hint. - const result = await checkEnvFile("/tmp/doctor-missing-env-test/.env"); + it("auto-creates .env by copying .env.example when present", async () => { + const tmp = `/tmp/doctor-env-copy-${Date.now()}`; + await fs.mkdir(tmp, { recursive: true }); + await fs.writeFile(`${tmp}/.env.example`, "DB_DIALECT=sqlite\n"); + const result = await checkEnvFile(`${tmp}/.env`); + expect(result.ok).toBe(true); + // The .env was created from the example. + await expect(fs.access(`${tmp}/.env`)).resolves.toBeUndefined(); + await fs.rm(tmp, { recursive: true }); + }); + + it("fails with a manual-create hint when both .env and .env.example are missing", async () => { + const tmp = `/tmp/doctor-env-none-${Date.now()}`; + await fs.mkdir(tmp, { recursive: true }); + const result = await checkEnvFile(`${tmp}/.env`); expect(result.ok).toBe(false); - expect(result.fix).toMatch(/cp .*\.env\.example .*\.env/); + expect(result.fix).toMatch(/create .*manually/i); + await fs.rm(tmp, { recursive: true }); }); });