From 05c98a86ee292fa5c1c0adbde5d7629eb8c6e972 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 27 Oct 2026 14:31:41 +0800 Subject: [PATCH] fix(init): keep an existing schema when offering the demo In an existing project that had only a hand-written db/schema.ts, answering yes to "Scaffold demo code?" in `void init` replaced that schema with the demo posts table. src/demo.ts was overwritten the same way. Init now skips the demo, before asking, when the project already has a Drizzle schema or when any demo file (db/schema.ts, routes/api/posts.ts, src/demo.ts) already exists, and logs why. The demo is written whole or not at all. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/cli/init.ts | 23 ++++++++++++-- packages/void/test/unit/init.test.ts | 46 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index a1a1c9341..b6f43fa91 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -2613,22 +2613,33 @@ export const posts = sqliteTable("posts", { `; } +function demoFiles(root: string): { schema: string; route: string; client: string } { + const paths = readProjectPaths(root); + return { + schema: paths.schemaFile, + route: join(paths.routesDir, 'api', 'posts.ts'), + client: join(paths.ssrDir, 'demo.ts'), + }; +} + /** * Scaffolds the "SQL → types → handler → fetch" demo into an existing project. * `setupExistingProject` has already declared `void` in `package.json`. + * The caller skips the demo when any of its files exists, so these writes never replace user code. */ function scaffoldDemo(root: string, dialect: DatabaseDialect): void { const devCommand = getDevCommand(root); const paths = readProjectPaths(root); + const files = demoFiles(root); mkdirSync(paths.dbDir, { recursive: true }); - writeFileSync(paths.schemaFile, demoSchemaSource(dialect)); + writeFileSync(files.schema, demoSchemaSource(dialect)); log.step('Created db/schema.ts'); // API route mkdirSync(join(paths.routesDir, 'api'), { recursive: true }); writeFileSync( - join(paths.routesDir, 'api', 'posts.ts'), + files.route, `import { defineHandler } from "void"; import { db } from "void/db"; import { posts } from "@schema"; @@ -2645,7 +2656,7 @@ export const GET = defineHandler(async () => { // Frontend demo mkdirSync(paths.ssrDir, { recursive: true }); writeFileSync( - join(paths.ssrDir, 'demo.ts'), + files.client, `import { fetch } from "void/client"; // Fully typed: response type is inferred from the GET handler's return type, @@ -2895,8 +2906,14 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise // Demo code only in the full flow, and not for meta-frameworks. if (!targeted && !framework) { + const existingDemoFiles = Object.values(demoFiles(root)).filter((file) => existsSync(file)); if (didScaffoldStarter || state.hasRoutes || state.hasMigrations || state.hasPages) { log.info('Project already has pages, routes, or migrations, skipping demo'); + } else if (hasDrizzleSchema(root)) { + log.info('Project already has a database schema, skipping demo'); + } else if (existingDemoFiles.length > 0) { + const names = existingDemoFiles.map((file) => relative(root, file)).join(', '); + log.info(`Project already has ${names}, skipping demo`); } else { const scaffold = await confirm({ message: 'Scaffold demo code? (migration + API route + frontend fetch)', diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index c6e12f5f3..c7ab8394d 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -3574,6 +3574,52 @@ export default config; }, ); + it.each([ + ['db/schema.ts', 'Project already has a database schema, skipping demo'], + ['src/demo.ts', 'Project already has src/demo.ts, skipping demo'], + ])('keeps an existing %s and skips the whole demo', async (existingFile, message) => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeExistingViteApp(root, { void: '^0.0.1' }); + const authored = 'export const authored = "keep me";\n'; + mkdirSync(join(root, existingFile, '..'), { recursive: true }); + writeFileSync(join(root, existingFile), authored); + mockConfirm.mockImplementation((options: { message?: string }) => + String(options.message).startsWith('Scaffold demo code?'), + ); + + await runInitCommand(root); + + expect(readFileSync(join(root, existingFile), 'utf-8')).toBe(authored); + expect(mockLogInfo).toHaveBeenCalledWith(message); + const prompts = mockConfirm.mock.calls.map(([options]) => String(options?.message)); + expect(prompts.some((prompt) => prompt.startsWith('Scaffold demo code?'))).toBe(false); + for (const demoFile of ['db/schema.ts', 'routes/api/posts.ts', 'src/demo.ts']) { + if (demoFile !== existingFile) { + expect(existsSync(join(root, demoFile))).toBe(false); + } + } + expect(drizzleGenerateCalls()).toHaveLength(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('scaffolds every demo file in a project without a schema', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeExistingViteApp(root, { void: '^0.0.1' }); + + await runInitWithDemo(root); + + for (const demoFile of ['db/schema.ts', 'routes/api/posts.ts', 'src/demo.ts']) { + expect(existsSync(join(root, demoFile))).toBe(true); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('runs starter migration with injected PnP NODE_OPTIONS in a Yarn Berry PnP project', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try {