From de0c191eff341c3bb63d7c8e31a13209aac6fcc2 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Sat, 12 Sep 2026 13:28:04 +0800 Subject: [PATCH] fix(create): honor non-interactive mode in scaffolded generators Pass the interaction mode to local Bingo generators and validate starter arguments before invoking the programmatic API. Preserve the interactive CLI path and document the update needed for existing generators. --- .../assert_noninteractive.mjs | 54 ++++++++++++++++ .../create_generator_monorepo/snapshots.toml | 4 ++ .../snapshots/create_generator_monorepo.md | 64 ++++++++++++++----- .../src/create/__tests__/discovery.spec.ts | 11 ++++ packages/cli/src/create/discovery.ts | 2 + packages/cli/templates/generator/README.md | 14 ++++ packages/cli/templates/generator/bin/index.ts | 59 +++++++++++++++-- 7 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/assert_noninteractive.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/assert_noninteractive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/assert_noninteractive.mjs new file mode 100644 index 0000000000..cdb1e5acae --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/assert_noninteractive.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const generator = path.resolve('tools/my-generator/bin/index.ts'); +const directory = fs.mkdtempSync(path.join(process.cwd(), 'generator-assert-')); +const invoke = (args, interactive = '0') => { + const result = spawnSync(process.execPath, [generator, ...args, '--skip-requests'], { + cwd: directory, + env: { ...process.env, VP_CREATE_INTERACTIVE: interactive }, + encoding: 'utf8', + timeout: 15_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.ifError(result.error); + return { status: result.status, output: result.stdout + result.stderr }; +}; + +try { + for (const args of [[], ['--name', 'demo'], ['--directory', 'missing-name']]) { + const result = invoke(args); + assert.equal(result.status, 1, result.output); + assert.doesNotMatch(result.output, /What will|unsettled top-level await/); + assert.deepEqual(fs.readdirSync(directory), []); + } + fs.mkdirSync(path.join(directory, 'existing')); + fs.writeFileSync(path.join(directory, 'existing', 'keep.txt'), 'keep'); + const existing = invoke(['--directory', 'existing', '--name', 'demo']); + assert.equal(existing.status, 1, existing.output); + assert.match(existing.output, /Directory already exists/); + assert.equal(fs.readFileSync(path.join(directory, 'existing', 'keep.txt'), 'utf8'), 'keep'); + assert.deepEqual(fs.readdirSync(path.join(directory, 'existing')), ['keep.txt']); + + const result = invoke(['--directory', 'generated', '--name', '@demo/button', '--offline']); + assert.equal(result.status, 0, result.output); + const pkg = JSON.parse( + fs.readFileSync(path.join(directory, 'generated', 'package.json'), 'utf8'), + ); + assert.equal(pkg.name, '@demo/button'); + assert.match( + fs.readFileSync(path.join(directory, 'generated', 'src', 'index.ts'), 'utf8'), + /@demo\/button/, + ); + const interactive = invoke( + ['--directory', 'interactive', '--name', 'interactive', '--offline'], + '1', + ); + assert.equal(interactive.status, 0, interactive.output); + assert.match(interactive.output, /Running with mode --setup/); + console.log('Generator non-interactive assertions passed'); +} finally { + fs.rmSync(directory, { recursive: true, force: true }); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots.toml index 066db4e987..390d0ef982 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots.toml @@ -14,6 +14,10 @@ steps = [ { argv = ["vpt", "print-file", "vite.config.ts"], comment = "create.templates entry appended, existing defaultTemplate preserved", continue-on-failure = true }, { argv = ["vpt", "print-file", "tools/my-generator/package.json"], comment = "generator package (bingo dependency is the run hint; no marker keyword)", continue-on-failure = true }, { argv = ["vp", "install"], comment = "install workspace deps so the generator's bin can import bingo", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "exec", "node", "assert_noninteractive.mjs"], snapshot = false, comment = "assert missing arguments fail without prompts, existing files survive, and both modes generate files" }, + { argv = ["vp", "create", "my-generator", "--no-interactive", "--", "--name", "demo-pkg"], comment = "missing directory fails without entering Bingo prompts", continue-on-failure = true }, + { argv = ["vp", "create", "my-generator", "--no-interactive", "--", "--directory", "missing-name"], comment = "missing required template option fails before creating its directory", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "tools/missing-name", "--assert", "missing"], continue-on-failure = true }, { argv = ["vp", "create", "my-generator", "--no-interactive", "--", "--name", "demo-pkg", "--directory", "demo-pkg", "--offline"], comment = "resolve via the registered create.templates entry", continue-on-failure = true }, { argv = ["vpt", "print-file", "tools/demo-pkg/package.json"], comment = "generated next to the generator under tools/, not the apps/ parent", continue-on-failure = true }, { argv = ["vpt", "print-file", "tools/demo-pkg/src/index.ts"], continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots/create_generator_monorepo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots/create_generator_monorepo.md index 1fb5bc37bb..40eac9616d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots/create_generator_monorepo.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_generator_monorepo/snapshots/create_generator_monorepo.md @@ -75,6 +75,55 @@ generator package (bingo dependency is the run hint; no marker keyword) install workspace deps so the generator's bin can import bingo +## `vp exec node assert_noninteractive.mjs` + +assert missing arguments fail without prompts, existing files survive, and both modes generate files + + +## `vp create my-generator --no-interactive -- --name demo-pkg` + +missing directory fails without entering Bingo prompts + +**Exit code:** 1 + +``` + +Generating project… + +Running: node /tools/my-generator/bin/index.ts --name demo-pkg --skip-requests +Missing --directory. Pass generator options after -- in vp create. +``` + +## `vp create my-generator --no-interactive -- --directory missing-name` + +missing required template option fails before creating its directory + +**Exit code:** 1 + +``` + +Generating project… + +Running: node /tools/my-generator/bin/index.ts --directory missing-name --skip-requests +[ + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": [ + "name" + ], + "message": "Required" + } +] +``` + +## `vpt stat-file tools/missing-name --assert missing` + +``` +tools/missing-name: missing +``` + ## `vp create my-generator --no-interactive -- --name demo-pkg --directory demo-pkg --offline` resolve via the registered create.templates entry @@ -84,21 +133,6 @@ resolve via the registered create.templates entry Generating project… Running: node /tools/my-generator/bin/index.ts --name demo-pkg --directory demo-pkg --offline --skip-requests -┌ my-generator@0.0.0 │ -◇ Running with mode --setup -│ -│ --offline enabled. You'll need to git push any changes manually. -│ -◇ Inferred default options from system -│ -◇ Ran the my-generator template -│ -◇ Prepared local Git repository -│ -● Run npx index.ts --remote in ./demo-pkg -│ to create and sync a remote repository on GitHub. -│ -└ Thanks for using my-generator! 💝 Monorepo integration... diff --git a/packages/cli/src/create/__tests__/discovery.spec.ts b/packages/cli/src/create/__tests__/discovery.spec.ts index a429766f7e..559faf2cc5 100644 --- a/packages/cli/src/create/__tests__/discovery.spec.ts +++ b/packages/cli/src/create/__tests__/discovery.spec.ts @@ -54,6 +54,17 @@ describe('discoverTemplate', () => { expect(templateInfo.command).toBe('node'); expect(templateInfo.type).toBe('bingo'); expect(templateInfo.args).toContain('--skip-requests'); + expect(templateInfo.envs.VP_CREATE_INTERACTIVE).toBe('1'); + const nonInteractive = discoverTemplate( + 'my-template', + [], + workspaceInfo, + false, + undefined, + undefined, + true, + ); + expect(nonInteractive.envs.VP_CREATE_INTERACTIVE).toBe('0'); }); it('runs a local template referenced by a relative path', () => { diff --git a/packages/cli/src/create/discovery.ts b/packages/cli/src/create/discovery.ts index 0f2de3082f..ca3348676e 100644 --- a/packages/cli/src/create/discovery.ts +++ b/packages/cli/src/create/discovery.ts @@ -187,6 +187,8 @@ export function discoverTemplate( type = TemplateType.bingo; // add `--skip-requests` by default for bingo templates args.push('--skip-requests'); + // Scaffolded generators use this to bypass Bingo's interactive CLI. + envs.VP_CREATE_INTERACTIVE = interactive === false ? '0' : '1'; } return { command: 'node', diff --git a/packages/cli/templates/generator/README.md b/packages/cli/templates/generator/README.md index d435661a61..7efb5b5aa4 100644 --- a/packages/cli/templates/generator/README.md +++ b/packages/cli/templates/generator/README.md @@ -11,6 +11,20 @@ From monorepo root: vp create ``` +For automation, provide the directory and every required template option: + +```bash +vp create --no-interactive -- --directory new-package --name new-package +``` + +Vite+ sets `VP_CREATE_INTERACTIVE=0` for non-interactive local Bingo generators. +This starter then validates the arguments and runs Bingo's programmatic API. +Missing options and existing directories fail before any files are generated. +Existing generators are copied project files and are not updated by upgrading Vite+. +To adopt this behavior, update their entrypoint to match this starter. +Direct invocation uses the interactive CLI unless this variable is set to `0`. +When adding template options, also add their CLI types in `bin/index.ts`. + ## Development ```bash diff --git a/packages/cli/templates/generator/bin/index.ts b/packages/cli/templates/generator/bin/index.ts index 224ea43817..d9cc9e11fe 100755 --- a/packages/cli/templates/generator/bin/index.ts +++ b/packages/cli/templates/generator/bin/index.ts @@ -1,10 +1,59 @@ #!/usr/bin/env node -import { runTemplateCLI, type Template } from 'bingo'; +import fs from 'node:fs'; +import { parseArgs } from 'node:util'; + +import { runTemplate, runTemplateCLI, type Template } from 'bingo'; +import { z } from 'zod'; import template from '../src/template.ts'; -// runTemplateCLI accepts the base `Template` type, which is wider than the -// strongly typed template returned by createTemplate(). Cast through `unknown` -// to bridge the two. -process.exitCode = await runTemplateCLI(template as unknown as Template); +async function main() { + if ( + process.env.VP_CREATE_INTERACTIVE !== '0' || + process.argv.includes('--help') || + process.argv.includes('--version') + ) { + // runTemplateCLI accepts a wider type than createTemplate returns. + return await runTemplateCLI(template as unknown as Template); + } + + // Add CLI entries here when adding options to src/template.ts. + const { values } = parseArgs({ + options: { + directory: { type: 'string' }, + name: { type: 'string' }, + offline: { type: 'boolean' }, + 'skip-requests': { type: 'boolean' }, + 'skip-files': { type: 'boolean' }, + 'skip-scripts': { type: 'boolean' }, + }, + }); + if (!values.directory?.trim()) { + throw new Error('Missing --directory. Pass generator options after -- in vp create.'); + } + const options = z.object(template.options).parse(values); + if (fs.existsSync(values.directory)) { + throw new Error(`Directory already exists: ${values.directory}`); + } + + await runTemplate(template, { + directory: values.directory, + mode: 'setup', + options, + offline: values.offline, + skips: { + requests: values['skip-requests'], + files: values['skip-files'], + scripts: values['skip-scripts'], + }, + }); + return 0; +} + +try { + process.exitCode = await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}