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; +}