Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/reference/cli/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>] [--framework <name>] [--starter <name>] [--deploy <target>]
```

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.
Expand Down
21 changes: 21 additions & 0 deletions packages/create-void/src/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type CreateArgs = {
appName: string | undefined;
initArgs: Array<string>;
};

// 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<string>): 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 };
}
11 changes: 9 additions & 2 deletions packages/create-void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ 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();

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({
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/create-void/src/pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export function run(argv: ReadonlyArray<string>, cwd?: string): Promise<void> {
// 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) {
Expand Down
25 changes: 25 additions & 0 deletions packages/create-void/test/unit/args.test.ts
Original file line number Diff line number Diff line change
@@ -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'.",
);
});
});
11 changes: 11 additions & 0 deletions packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,24 @@ 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 <name>', description: 'Scaffold with vite-plus or vite' },
{ command: '--framework <name>', description: 'Scaffold with react, vue, svelte, or solid' },
{
command: '--starter <name>',
description: 'Scaffold the sqlite, postgresql, mysql, or static starter',
},
{ command: '--deploy <target>', description: 'Deploy to cloudflare, void, or none' },
{
command: '--github',
description: 'Create the GitHub Actions deploy workflow and set up coding agents',
},
],
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' },
],
},
Expand Down
96 changes: 88 additions & 8 deletions packages/void/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScaffoldStarterMode> = [
'sqlite',
'postgresql',
'mysql',
'static',
];
const DEPLOYMENT_PLATFORMS: ReadonlyArray<DeploymentPlatform> = ['cloudflare', 'void', 'none'];

function parseChoice<T extends string>(
flag: string,
value: unknown,
choices: ReadonlyArray<T>,
): 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<string>): InitArgs {
let parsed: ReturnType<typeof parseArgs>;
try {
Expand All @@ -83,6 +109,10 @@ export function parseInitArgs(args: Array<string>): 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,
Expand All @@ -96,6 +126,18 @@ export function parseInitArgs(args: Array<string>): 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),
};
}

Expand Down Expand Up @@ -1463,7 +1505,23 @@ function applyDatabaseSetup(root: string, choice: DatabaseSetupChoice): void {
applyDatabaseDialect(root, choice);
}

async function promptStarterMode(): Promise<ScaffoldStarterMode> {
/**
* 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<string>): 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<ScaffoldStarterMode> {
if (starter) {
return starter;
}
assertInteractive('starter', SCAFFOLD_STARTER_MODES);
const choice = await select({
message: 'Choose a Pages starter:',
options: [
Expand Down Expand Up @@ -1498,7 +1556,14 @@ async function promptStarterMode(): Promise<ScaffoldStarterMode> {
return choice as ScaffoldStarterMode;
}

async function promptScaffoldToolchain(): Promise<ScaffoldToolchain> {
async function promptScaffoldToolchain(toolchain?: ScaffoldToolchain): Promise<ScaffoldToolchain> {
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',
Expand All @@ -1513,7 +1578,14 @@ async function promptScaffoldToolchain(): Promise<ScaffoldToolchain> {
return choice as ScaffoldToolchain;
}

async function promptScaffoldFramework(): Promise<ScaffoldFramework> {
async function promptScaffoldFramework(framework?: ScaffoldFramework): Promise<ScaffoldFramework> {
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],
Expand Down Expand Up @@ -1566,7 +1638,15 @@ async function promptDatabaseSetup(root: string): Promise<DatabaseSetupChoice> {
return choice as DatabaseSetupChoice;
}

async function promptDeploymentPlatform(root: string): Promise<DeploymentPlatform> {
async function promptDeploymentPlatform(
root: string,
deploy?: DeploymentPlatform,
): Promise<DeploymentPlatform> {
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?',
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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;

Expand Down
Loading