From f9505822044f09873a291deba17db9efbacd60e7 Mon Sep 17 00:00:00 2001 From: Ashunar0 Date: Mon, 19 Oct 2026 21:34:47 +0900 Subject: [PATCH] feat(init): accept scaffold choices as flags --- docs/reference/cli/setup.md | 17 ++++ packages/create-void/src/args.ts | 21 +++++ packages/create-void/src/index.ts | 11 ++- packages/create-void/src/pm.ts | 3 +- packages/create-void/test/unit/args.test.ts | 25 ++++++ packages/void/src/cli/help.ts | 11 +++ packages/void/src/cli/init.ts | 96 +++++++++++++++++++-- packages/void/test/unit/init.test.ts | 87 +++++++++++++++++++ 8 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 packages/create-void/src/args.ts create mode 100644 packages/create-void/test/unit/args.test.ts diff --git a/docs/reference/cli/setup.md b/docs/reference/cli/setup.md index a491fc6ca..81c974d19 100644 --- a/docs/reference/cli/setup.md +++ b/docs/reference/cli/setup.md @@ -29,10 +29,27 @@ Resources recorded in `void.lock.json` without a matching definition appear sepa ```sh void init [--tsconfig] [--github] [--git | --no-git] + [--toolchain ] [--framework ] [--starter ] [--deploy ] ``` Set up a new or existing Void project. In an empty directory, choose Vite+ or Vite, a framework, and a D1, PostgreSQL, MySQL, or Static Pages starter. +To scaffold without prompts, for example from a script or a coding agent, pass the choices as flags. Without an interactive terminal, init stops and names the flag it needs instead of prompting. + +| Flag | Values | +| ------------- | ----------------------------------------- | +| `--toolchain` | `vite-plus`, `vite` | +| `--framework` | `react`, `vue`, `svelte`, `solid` | +| `--starter` | `sqlite`, `postgresql`, `mysql`, `static` | +| `--deploy` | `cloudflare`, `void`, `none` | + +```sh +void init --toolchain vite-plus --framework react --starter sqlite --deploy none +npm create void@latest my-app -- --toolchain vite-plus --framework react --starter sqlite --deploy none +``` + +Cloudflare and Void platform setup still sign in interactively. Without a terminal, choose `--deploy none` and run `void connect` later. + Vite+ projects install dependencies through `vp install`; no global Vite+ installation is required. Void preserves existing package-manager versions and lockfiles. New Vite+ projects without a package-manager configuration use pnpm without pinning a version. In an existing app, Void adds missing dependencies and scripts and updates the Vite config. If it cannot edit your config, it prints the snippet to add. diff --git a/packages/create-void/src/args.ts b/packages/create-void/src/args.ts new file mode 100644 index 000000000..07e4f404a --- /dev/null +++ b/packages/create-void/src/args.ts @@ -0,0 +1,21 @@ +export type CreateArgs = { + appName: string | undefined; + initArgs: Array; +}; + +// run() spawns through a shell on Windows, so only forward plain flags such as +// `--framework react` or `--deploy=none` to `void init`. +const INIT_ARG_PATTERN = /^[\w=-]+$/; + +/** Split `create-void [name] [void init options]` into the name and the options. */ +export function parseCreateArgs(argv: ReadonlyArray): CreateArgs { + const [first, ...rest] = argv; + const appName = first !== undefined && !first.startsWith('-') ? first : undefined; + const initArgs = appName === undefined ? [...argv] : rest; + for (const arg of initArgs) { + if (!INIT_ARG_PATTERN.test(arg)) { + throw new Error(`create-void: Unsupported argument '${arg}'.`); + } + } + return { appName, initArgs }; +} diff --git a/packages/create-void/src/index.ts b/packages/create-void/src/index.ts index d7d0e9723..8e49a8013 100644 --- a/packages/create-void/src/index.ts +++ b/packages/create-void/src/index.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import * as p from '@voidzero-dev/vite-plus-prompts'; import { resolve } from 'pathslash'; import pc from 'picocolors'; +import { parseCreateArgs } from './args.ts'; import { detectPM, pmExec, pmInstall, run, writePnpmBuildPolicy } from './pm.ts'; const pm = detectPM(); @@ -11,7 +12,13 @@ const pm = detectPM(); async function main() { console.log(); - let appName = process.argv[2]; + const createArgs = parseCreateArgs(process.argv.slice(2)); + let appName = createArgs.appName; + + if (!appName && !process.stdin.isTTY) { + p.log.error('create-void: Pass a project name when running without an interactive terminal.'); + process.exit(1); + } if (!appName) { const name = await p.text({ @@ -68,7 +75,7 @@ async function main() { await run(pmInstall(pm), targetDir); p.log.step('Scaffolding starter...'); - await run([...pmExec(pm), 'void', 'init'], targetDir); + await run([...pmExec(pm), 'void', 'init', ...createArgs.initArgs], targetDir); p.log.step('Installing starter dependencies...'); await run(pmInstall(pm), targetDir); diff --git a/packages/create-void/src/pm.ts b/packages/create-void/src/pm.ts index 76ced6ffa..0e1f2b285 100644 --- a/packages/create-void/src/pm.ts +++ b/packages/create-void/src/pm.ts @@ -60,7 +60,8 @@ export function run(argv: ReadonlyArray, cwd?: string): Promise { // On Windows the package-manager entry points (npm, npx, pnpm, yarn, bunx) // are .cmd/.ps1 shims that spawn() cannot resolve without a shell, so it // throws ENOENT. Enable a shell only on win32; every arg here is a fixed - // literal (no user input reaches argv), so there is no injection surface. + // literal or a plain flag checked by parseCreateArgs, so there is no + // injection surface. const child = spawn(cmd, args, { stdio: 'inherit', cwd, shell: process.platform === 'win32' }); child.on('close', (code) => { if (code === 0) { diff --git a/packages/create-void/test/unit/args.test.ts b/packages/create-void/test/unit/args.test.ts new file mode 100644 index 000000000..86f799c59 --- /dev/null +++ b/packages/create-void/test/unit/args.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseCreateArgs } from '../../src/args.ts'; + +describe('parseCreateArgs', () => { + it('forwards options after the project name to void init', () => { + expect(parseCreateArgs(['my-app', '--framework', 'react', '--deploy=none'])).toEqual({ + appName: 'my-app', + initArgs: ['--framework', 'react', '--deploy=none'], + }); + }); + + it('forwards every argument when no project name is given', () => { + expect(parseCreateArgs(['--framework', 'vue'])).toEqual({ + appName: undefined, + initArgs: ['--framework', 'vue'], + }); + expect(parseCreateArgs([])).toEqual({ appName: undefined, initArgs: [] }); + }); + + it('rejects arguments that are not plain flags or values', () => { + expect(() => parseCreateArgs(['my-app', '--framework', 'react&calc'])).toThrow( + "create-void: Unsupported argument 'react&calc'.", + ); + }); +}); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 94a3562a5..c77185c54 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -129,6 +129,13 @@ const HELP_TREE: HelpTree = { { command: '--tsconfig', description: 'Update tsconfig.json and set up coding agents' }, { command: '--git', description: 'Initialize a Git repository without prompting' }, { command: '--no-git', description: 'Skip Git repository initialization' }, + { command: '--toolchain ', description: 'Scaffold with vite-plus or vite' }, + { command: '--framework ', description: 'Scaffold with react, vue, svelte, or solid' }, + { + command: '--starter ', + description: 'Scaffold the sqlite, postgresql, mysql, or static starter', + }, + { command: '--deploy ', description: 'Deploy to cloudflare, void, or none' }, { command: '--github', description: 'Create the GitHub Actions deploy workflow and set up coding agents', @@ -136,6 +143,10 @@ const HELP_TREE: HelpTree = { ], examples: [ { command: 'void init', description: 'Scaffold or configure the current project' }, + { + command: 'void init --toolchain vite-plus --framework react --starter sqlite --deploy none', + description: 'Scaffold without prompts', + }, { command: 'void init --github', description: 'Scaffold the GitHub Actions deploy workflow' }, ], }, diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index bb90e7df5..50d3bc8a6 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -72,8 +72,34 @@ export type InitArgs = { tsconfig: boolean; github: boolean; git?: boolean; + toolchain?: ScaffoldToolchain; + framework?: ScaffoldFramework; + starter?: ScaffoldStarterMode; + deploy?: DeploymentPlatform; }; +const SCAFFOLD_STARTER_MODES: ReadonlyArray = [ + 'sqlite', + 'postgresql', + 'mysql', + 'static', +]; +const DEPLOYMENT_PLATFORMS: ReadonlyArray = ['cloudflare', 'void', 'none']; + +function parseChoice( + flag: string, + value: unknown, + choices: ReadonlyArray, +): T | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || !choices.includes(value as T)) { + throw new Error(`init: --${flag} must be one of ${choices.join(', ')}.`); + } + return value as T; +} + export function parseInitArgs(args: Array): InitArgs { let parsed: ReturnType; try { @@ -83,6 +109,10 @@ export function parseInitArgs(args: Array): InitArgs { tsconfig: { type: 'boolean' }, github: { type: 'boolean' }, git: { type: 'boolean' }, + toolchain: { type: 'string' }, + framework: { type: 'string' }, + starter: { type: 'string' }, + deploy: { type: 'string' }, }, allowNegative: true, strict: true, @@ -96,6 +126,18 @@ export function parseInitArgs(args: Array): InitArgs { tsconfig: parsed.values.tsconfig === true, github: parsed.values.github === true, git: parsed.values.git as boolean | undefined, + toolchain: parseChoice( + 'toolchain', + parsed.values.toolchain, + SCAFFOLD_TOOLCHAIN_OPTIONS.map((option) => option.value), + ), + framework: parseChoice( + 'framework', + parsed.values.framework, + SCAFFOLD_FRAMEWORK_OPTIONS.map((option) => option.value), + ), + starter: parseChoice('starter', parsed.values.starter, SCAFFOLD_STARTER_MODES), + deploy: parseChoice('deploy', parsed.values.deploy, DEPLOYMENT_PLATFORMS), }; } @@ -1463,7 +1505,23 @@ function applyDatabaseSetup(root: string, choice: DatabaseSetupChoice): void { applyDatabaseDialect(root, choice); } -async function promptStarterMode(): Promise { +/** + * A prompt cannot be answered when stdin is not a terminal, so fail with the + * flag that answers it instead of exiting as if init succeeded. + */ +function assertInteractive(flag: string, choices: ReadonlyArray): void { + if (!process.stdin.isTTY) { + throw new Error( + `init: Pass --${flag} (${choices.join(', ')}) when running without an interactive terminal.`, + ); + } +} + +async function promptStarterMode(starter?: ScaffoldStarterMode): Promise { + if (starter) { + return starter; + } + assertInteractive('starter', SCAFFOLD_STARTER_MODES); const choice = await select({ message: 'Choose a Pages starter:', options: [ @@ -1498,7 +1556,14 @@ async function promptStarterMode(): Promise { return choice as ScaffoldStarterMode; } -async function promptScaffoldToolchain(): Promise { +async function promptScaffoldToolchain(toolchain?: ScaffoldToolchain): Promise { + if (toolchain) { + return toolchain; + } + assertInteractive( + 'toolchain', + SCAFFOLD_TOOLCHAIN_OPTIONS.map((option) => option.value), + ); const choice = await select({ message: 'Choose a Pages scaffold toolchain:', initialValue: 'vite-plus', @@ -1513,7 +1578,14 @@ async function promptScaffoldToolchain(): Promise { return choice as ScaffoldToolchain; } -async function promptScaffoldFramework(): Promise { +async function promptScaffoldFramework(framework?: ScaffoldFramework): Promise { + if (framework) { + return framework; + } + assertInteractive( + 'framework', + SCAFFOLD_FRAMEWORK_OPTIONS.map((option) => option.value), + ); const choice = await select({ message: 'Choose a Pages framework to scaffold:', options: [...SCAFFOLD_FRAMEWORK_OPTIONS], @@ -1566,7 +1638,15 @@ async function promptDatabaseSetup(root: string): Promise { return choice as DatabaseSetupChoice; } -async function promptDeploymentPlatform(root: string): Promise { +async function promptDeploymentPlatform( + root: string, + deploy?: DeploymentPlatform, +): Promise { + if (deploy) { + writeDeploymentPlatform(root, deploy); + return deploy; + } + assertInteractive('deploy', DEPLOYMENT_PLATFORMS); const current = readDeploymentPlatform(root); const choice = await select({ message: 'Where should this project deploy?', @@ -2690,16 +2770,16 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise if (!targeted && !framework && isScaffoldableEmptyProject(root)) { log.info('Empty project detected. Scaffolding a Pages starter...'); - const scaffoldToolchain = await promptScaffoldToolchain(); + const scaffoldToolchain = await promptScaffoldToolchain(initArgs?.toolchain); if (detectedPagesFramework) { scaffoldedPagesFramework = detectedPagesFramework; log.info( `Detected ${SCAFFOLD_FRAMEWORKS[detectedPagesFramework].label} adapter in package.json`, ); } else { - scaffoldedPagesFramework = await promptScaffoldFramework(); + scaffoldedPagesFramework = await promptScaffoldFramework(initArgs?.framework); } - const starterMode = await promptStarterMode(); + const starterMode = await promptStarterMode(initArgs?.starter); const existingPackageJson = readPackageJson(root); const hasPackageManager = hasLocalLockfile(root) || @@ -2842,7 +2922,7 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise // Targeted runs keep the saved deployment preference without prompting. const deploymentPlatform = !targeted - ? await promptDeploymentPlatform(root) + ? await promptDeploymentPlatform(root, initArgs?.deploy) : readDeploymentPlatform(root); let voidPlatformDiscovery: PlatformDiscoveryDocument | null = null; diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index b8ab75787..306582f96 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -25,6 +25,7 @@ import { runInitCommand, } from '../../src/cli/init.ts'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; +import { readDeploymentPlatform } from '../../src/cli/project.ts'; import { LATEST_KNOWN_COMPAT_DATE } from '../../src/cli/wrangler.ts'; import { buildDrizzleKitSpawnEnv } from '../../src/cli/yarn-pnp.ts'; import { readConfig } from '../../src/config.ts'; @@ -133,6 +134,70 @@ describe('init Git arguments', () => { ); }); +describe('init scaffold arguments', () => { + it('parses scaffold choices and rejects unknown values', () => { + expect( + parseInitArgs([ + '--toolchain', + 'vite', + '--framework=vue', + '--starter', + 'static', + '--deploy', + 'none', + ]), + ).toMatchObject({ toolchain: 'vite', framework: 'vue', starter: 'static', deploy: 'none' }); + expect(parseInitArgs([])).toMatchObject({ + toolchain: undefined, + framework: undefined, + starter: undefined, + deploy: undefined, + }); + expect(() => parseInitArgs(['--framework', 'angular'])).toThrow( + 'init: --framework must be one of react, vue, svelte, solid.', + ); + }); + + it('scaffolds from flags without prompting when stdin is not a terminal', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-flags-')); + try { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, get: () => false }); + await runInitCommand( + root, + parseInitArgs([ + '--toolchain', + 'vite', + '--framework', + 'react', + '--starter', + 'static', + '--deploy', + 'none', + ]), + ); + expect(mockSelect).not.toHaveBeenCalled(); + expect(existsSync(join(root, 'pages', 'index.tsx'))).toBe(true); + expect(readDeploymentPlatform(root)).toBe('none'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('fails with the missing flag instead of prompting when stdin is not a terminal', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-flags-missing-')); + try { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, get: () => false }); + await expect(runInitCommand(root, parseInitArgs(['--toolchain', 'vite']))).rejects.toThrow( + 'init: Pass --framework (react, vue, svelte, solid) when running without an interactive terminal.', + ); + expect(mockSelect).not.toHaveBeenCalled(); + expect(readdirSync(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('GitHub Actions deployment workflows', () => { it('requires a selected URL for a Void deployment workflow', () => { expect(() => getWorkflowYaml('/project', 'npm', 'my-app', 'void')).toThrow('void connect'); @@ -448,7 +513,26 @@ function cloudflareOperationType(args: unknown): string | undefined { } } +// Prompts are mocked, so give init a terminal to prompt in. CI stays set, as +// in CI runs, so Git and AGENTS.md setup keep skipping their prompts. +const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +let previousCI: string | undefined; + +function restoreInteractiveTerminal(): void { + if (previousCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = previousCI; + } + if (stdinTtyDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', stdinTtyDescriptor); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } +} + afterEach(() => { + restoreInteractiveTerminal(); delete process.env.VOID_PLATFORM_REGISTRY_DIR; rmSync(platformRegistryDirectory, { recursive: true, force: true }); mockSpawnSync.mockReset(); @@ -469,6 +553,9 @@ afterEach(() => { }); beforeEach(() => { + previousCI = process.env.CI; + process.env.CI = 'true'; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, get: () => true }); platformRegistryDirectory = mkdtempSync(join(tmpdir(), 'void-init-platform-registry-')); process.env.VOID_PLATFORM_REGISTRY_DIR = platformRegistryDirectory; savePlatformConnection({