Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 });
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workspace>/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 <workspace>/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
Expand All @@ -84,21 +133,6 @@ resolve via the registered create.templates entry
Generating project…

Running: node <workspace>/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...

Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/create/__tests__/discovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/create/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/templates/generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ From monorepo root:
vp create
```

For automation, provide the directory and every required template option:

```bash
vp create <generator-name> --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
Expand Down
59 changes: 54 additions & 5 deletions packages/cli/templates/generator/bin/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading