feat: add create-vinext-app scaffolding CLI#406
feat: add create-vinext-app scaffolding CLI#406Divkix wants to merge 9 commits intocloudflare:mainfrom
Conversation
Adds `packages/create-vinext-app/` — a standalone CLI for `npm create vinext-app@latest` that scaffolds new vinext projects targeting Cloudflare Workers with a single command. - Two templates: App Router (with RSC) and Pages Router - Interactive prompts via @clack/prompts, --yes for CI usage - Package manager auto-detection from npm_config_user_agent - .tmpl variable substitution for project/worker names - 101 tests across 6 files (unit, integration, e2e) - CI job to verify scaffolded projects start (ubuntu + windows) - Publish workflow updated to release alongside vinext - README, AGENTS.md, CONTRIBUTING.md, migration skill updated - Root build/lint/format configs updated to include new package
commit: |
…dex.ts in app-router template The app-router template's wrangler.jsonc.tmpl referenced ./worker/index.ts, but no such file exists in the template. Only pages-router has a custom worker entry. App-router should use vinext/server/app-router-entry directly, as documented in examples/app-router-cloudflare/worker/index.ts. Adds a test to prevent regression.
CI on Windows passes `D:\a\_temp/cva-test` as the project name arg. The validation regex rejects backslashes and colons. When the input is an absolute path, extract path.basename() for the project name and use the full path for the target directory.
There was a problem hiding this comment.
Pull request overview
Adds a new standalone scaffolding CLI (create-vinext-app) to generate ready-to-run vinext + Cloudflare Workers projects via npm create vinext-app@latest, plus supporting templates, tests, CI, and docs/workflows updates.
Changes:
- Introduces
packages/create-vinext-app/CLI with prompts, validation, install detection, and template-based scaffolding. - Adds App Router + Pages Router templates and a comprehensive Vitest suite (unit/integration/e2e).
- Updates monorepo build, CI, publish workflow, and documentation to include the new CLI.
Reviewed changes
Copilot reviewed 42 out of 45 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Excludes scaffold templates from TS compilation. |
| pnpm-lock.yaml | Adds create-vinext-app and @clack/prompts deps. |
| packages/create-vinext-app/vitest.config.ts | Vitest config for the new package. |
| packages/create-vinext-app/tsconfig.json | TS build settings for the CLI package. |
| packages/create-vinext-app/tests/validate.test.ts | Unit tests for name/path/empty-dir validation. |
| packages/create-vinext-app/tests/templates.test.ts | Verifies template file presence and contents. |
| packages/create-vinext-app/tests/scaffold.test.ts | Integration tests for scaffolding behavior. |
| packages/create-vinext-app/tests/install.test.ts | Tests package-manager detection and install commands. |
| packages/create-vinext-app/tests/helpers.ts | Shared test helpers and exec stub. |
| packages/create-vinext-app/tests/e2e.test.ts | End-to-end CLI tests (help/version/scaffold). |
| packages/create-vinext-app/tests/cli.test.ts | CLI arg parsing + main flow tests with mocks. |
| packages/create-vinext-app/templates/pages-router/wrangler.jsonc.tmpl | Pages Router Wrangler template. |
| packages/create-vinext-app/templates/pages-router/worker/index.ts | Pages Router Worker entry template. |
| packages/create-vinext-app/templates/pages-router/vite.config.ts | Pages Router Vite config template. |
| packages/create-vinext-app/templates/pages-router/tsconfig.json | Pages Router TS config template. |
| packages/create-vinext-app/templates/pages-router/pages/index.tsx | Pages Router sample home page. |
| packages/create-vinext-app/templates/pages-router/pages/api/hello.ts | Pages Router sample API route. |
| packages/create-vinext-app/templates/pages-router/pages/about.tsx | Pages Router sample about page. |
| packages/create-vinext-app/templates/pages-router/package.json.tmpl | Pages Router package.json template. |
| packages/create-vinext-app/templates/pages-router/next-shims.d.ts | Type shims for Next-like imports. |
| packages/create-vinext-app/templates/pages-router/_gitignore | Gitignore template (renamed on scaffold). |
| packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl | App Router Wrangler template. |
| packages/create-vinext-app/templates/app-router/vite.config.ts | App Router Vite config template. |
| packages/create-vinext-app/templates/app-router/tsconfig.json | App Router TS config template. |
| packages/create-vinext-app/templates/app-router/package.json.tmpl | App Router package.json template. |
| packages/create-vinext-app/templates/app-router/app/page.tsx | App Router sample page. |
| packages/create-vinext-app/templates/app-router/app/layout.tsx | App Router sample layout. |
| packages/create-vinext-app/templates/app-router/app/api/hello/route.ts | App Router sample route handler. |
| packages/create-vinext-app/templates/app-router/_gitignore | Gitignore template (renamed on scaffold). |
| packages/create-vinext-app/src/validate.ts | Project name/path and directory validation. |
| packages/create-vinext-app/src/scaffold.ts | Copies templates, substitutes vars, runs git/install. |
| packages/create-vinext-app/src/prompts.ts | Interactive prompting via @clack/prompts. |
| packages/create-vinext-app/src/install.ts | Package manager detection + install command builder. |
| packages/create-vinext-app/src/index.ts | CLI entrypoint + arg parsing + orchestration. |
| packages/create-vinext-app/package.json | New package manifest and scripts. |
| package.json | Includes create-vinext-app in build pipeline. |
| README.md | Documents npm create vinext-app@latest. |
| CONTRIBUTING.md | Adds contributor notes for the new package. |
| AGENTS.md | Updates structure/testing guidance for new package. |
| .oxlintrc.json | Ignores scaffold templates for linting. |
| .oxfmtrc.json | Ignores scaffold templates for formatting. |
| .github/workflows/publish.yml | Publishes create-vinext-app alongside vinext. |
| .github/workflows/ci.yml | Adds matrix job to scaffold + boot a fresh app. |
| .agents/skills/migrate-to-vinext/references/compatibility.md | Notes new scaffolding flow replaces create-next-app. |
| .agents/skills/migrate-to-vinext/SKILL.md | Adds “Starting a new project?” guidance. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ronments Pages Router projects on Cloudflare Workers failed with "module is not defined" because react/jsx-runtime (CJS) was discovered lazily by Vite's dep optimizer. The workerd module runner imported the raw CJS source before the optimizer could convert it to ESM. Add a configEnvironment hook that pre-includes React packages in optimizeDeps.include for all non-client Cloudflare environments, ensuring they are pre-bundled before the module runner needs them.
…matrix CI only tested the app-router template in the scaffold → install → dev server → HTTP 200 integration flow. This allowed a pages-router bug (ReferenceError: module is not defined) to slip through undetected. Add `template: [app, pages]` to the matrix so both templates get the full integration test on both OS targets (2×2 = 4 jobs).
- Derive project name from cwd basename when "." is passed, preventing
empty string in package.json/wrangler.jsonc from sanitizeWorkerName(".")
- Remove dead no-op ternary in validateProjectName regex check
- Fix misleading "lowercase letters" error message (uppercase is accepted)
- Add pretest build step so tests work from clean checkout
|
Will do some manual passes + testing over the diff later as well. /bigbonk review |
There was a problem hiding this comment.
Review: create-vinext-app scaffolding CLI
Solid foundation — the architecture is clean, the test coverage is thorough (102 tests across 6 files), and the templates are well-structured. The CI integration and publish workflow updates are thoughtful. I have a few issues that should be addressed before merging, one of which is a correctness bug that will bite CI and path-based invocations.
Summary of issues
-
Bug (blocking):
normalizedNameis used as both the package name and filesystem path, which breaks when a path (absolute or relative with separators) is passed as the positional arg. The CI workflow itself triggers this:node ... "${{ runner.temp }}/cva-test"— thepackage.jsonname field will get set tocva-testwhich happens to be fine for basename, but thetargetDirextraction only handlespath.isAbsolute(), not relative paths like../foo/bar. -
Bug:
prompts.tsonCancelcallsprocess.exit(0)despite the JSDoc claiming it returns null. Makes the function untestable for cancellation and contradictsmain()'sif (!answers) returnguard. -
Correctness:
validate.tsregex allows uppercase but the function then normalizes — this works, but the error message for the character class check says "letters" (ambiguous). More importantly, scoped names like@org/pass the regex butbareNameextraction with.split("/").pop()could return empty string for@org/(trailing slash, no package name). -
DX:
stdio: "pipe"ongit initandnpm installhides all output including errors from the user. -
Template: The pages-router
worker/index.tsis 242 lines of production-grade request handling code duplicated from the vinext core. This will drift. -
Unrelated change: The
configEnvironmenthook addition inpackages/vinext/src/index.tsshould be in a separate PR.
Details inline.
| if (path.isAbsolute(projectName)) { | ||
| targetDir = projectName; | ||
| projectName = path.basename(projectName); | ||
| } |
There was a problem hiding this comment.
Bug: relative paths with directory separators are not handled.
This only handles path.isAbsolute(), but users can pass relative paths like ../my-app or ./projects/my-app. In those cases, projectName stays as ../my-app, which then fails validateProjectName (. and / are valid in the regex but .. isn't a sensible package name).
Also, even for the absolute path case: after projectName = path.basename(projectName), the resulting name is used in both validateProjectName and as the final normalizedName. If the basename itself has uppercase (e.g. /tmp/My-App), validation returns normalized: "my-app", but targetDir is still /tmp/My-App. So the directory is created at the uppercase path but package.json gets the lowercase name — which is correct behavior, but worth a test.
Consider handling relative paths with separators too:
| } | |
| let targetDir: string | undefined; | |
| if (path.isAbsolute(projectName)) { | |
| targetDir = projectName; | |
| projectName = path.basename(projectName); | |
| } else if (projectName.includes(path.sep) || projectName.includes("/")) { | |
| targetDir = path.resolve(process.cwd(), projectName); | |
| projectName = path.basename(projectName); | |
| } |
| { | ||
| onCancel: () => { | ||
| p.cancel("Cancelled."); | ||
| process.exit(0); |
There was a problem hiding this comment.
Bug: process.exit(0) contradicts the documented return type.
The JSDoc says "Returns null if the user cancels (Ctrl+C)" and main() has if (!answers) return to handle this. But onCancel calls process.exit(0), so main() never gets the chance to handle cancellation gracefully.
This also makes the cancellation path untestable without mocking process.exit.
| process.exit(0); | |
| onCancel: () => { | |
| p.cancel("Cancelled."); | |
| return; | |
| }, |
Then after p.group(), check if the result contains a Symbol (which @clack/prompts returns on cancel) and return null:
if (p.isCancel(answers.projectName) || p.isCancel(answers.template)) {
p.cancel("Cancelled.");
return null;
}| } | ||
|
|
||
| function defaultExec(cmd: string, args: string[], opts: { cwd: string }): void { | ||
| execFileSync(cmd, args, { cwd: opts.cwd, stdio: "pipe" }); |
There was a problem hiding this comment.
DX issue: stdio: "pipe" silently swallows all subprocess output.
When npm install fails (network error, resolution conflict, etc.), the user sees nothing — just a thrown error with no diagnostic output. Same for git init failures.
For a scaffolding CLI, stdio: "inherit" is the standard approach — it lets the user see install progress and any error messages.
| execFileSync(cmd, args, { cwd: opts.cwd, stdio: "pipe" }); | |
| function defaultExec(cmd: string, args: string[], opts: { cwd: string }): void { | |
| execFileSync(cmd, args, { cwd: opts.cwd, stdio: "inherit" }); | |
| } |
| }; | ||
| } | ||
|
|
||
| if (!/^[a-zA-Z0-9\-._@/]+$/.test(name)) { |
There was a problem hiding this comment.
Edge case: scoped names with missing package part.
The regex ^[a-zA-Z0-9\-._@/]+$ accepts @org/ (with trailing slash but no package name). Then bareName = name.split("/").pop() returns "", which passes the ^\d and startsWith("-") checks vacuously. The result is that @org/ is accepted as valid.
Consider adding a check that scoped names actually have a non-empty part after the slash:
if (name.startsWith("@") && !name.match(/^@[a-z0-9-]+\/[a-z0-9][a-z0-9._-]*$/i)) {
return { valid: false, message: "Scoped package name must be in the format @scope/name" };
}| @@ -0,0 +1,242 @@ | |||
| /** | |||
There was a problem hiding this comment.
Maintainability concern: 242 lines of request handling code duplicated from vinext core.
This is a copy of packages/vinext/src/server/prod-server.ts (or very close to it). As noted in AGENTS.md:
When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as "follow-ups" — fix them in the same PR.
A scaffolded template that ships a full copy of the production server handler will immediately drift from the core. When bugs are fixed in prod-server.ts, this template won't get the fix.
Consider whether the pages-router template could use a vinext-provided entry point (like the app-router template uses vinext/server/app-router-entry) instead of inlining the full handler. If a pages-router equivalent doesn't exist yet, it might be worth creating one — even a thin wrapper — so templates don't carry a full copy of server logic.
| if (entryFile) { | ||
| const resolvedEntry = path.resolve(entryFile); | ||
| // Handle both .ts (dev) and .js (built) entry | ||
| if (resolvedEntry === thisFile || resolvedEntry === thisFile.replace(/\.ts$/, ".js")) { |
There was a problem hiding this comment.
Fragile entry point detection.
This comparison relies on exact path matching between import.meta.url and process.argv[1]. It handles .ts → .js but not symlinks (common with npm link or global installs), nor does it handle Windows path casing differences.
A simpler and more robust pattern:
const isDirectRun = process.argv[1] &&
path.resolve(process.argv[1]).replace(/\.ts$/, ".js") === thisFile.replace(/\.ts$/, ".js");Or just use the bin field — if the file is invoked via create-vinext-app, process.argv[1] will be a symlink to dist/index.js. Consider using fs.realpathSync on both sides.
| "vitest": "^3.2.1" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22" |
There was a problem hiding this comment.
node >= 22 is a high minimum.
Node 18 is still in maintenance LTS (until April 2025) and Node 20 is the current LTS. Requiring Node 22 will exclude a significant portion of users. Unless there's a specific Node 22 API being used (I don't see one in the source), consider lowering to >=18 or >=20.
| working-directory: packages/vinext | ||
|
|
||
| - name: Scaffold a fresh create-vinext-app project | ||
| run: node packages/create-vinext-app/dist/index.js "${{ runner.temp }}/cva-test" --template ${{ matrix.template }} --yes --skip-install |
There was a problem hiding this comment.
CI passes an absolute path as the positional arg.
This feeds ${{ runner.temp }}/cva-test as the project name. The code at index.ts:140 correctly handles path.isAbsolute() by extracting the basename. However, the resulting package.json will have "name": "cva-test" and the worker name will be "cva-test". That's fine and works, but it's worth noting that this CI step is exercising the absolute-path code path, not the typical user flow of create-vinext-app my-app.
Consider adding a second test that uses a plain name (the typical user flow) to ensure both paths are covered in CI.
packages/vinext/src/index.ts
Outdated
| // Pre-include React CJS packages in every non-client Cloudflare | ||
| // environment so Vite's dep optimizer pre-bundles them before the | ||
| // workerd module runner tries to evaluate raw CJS. Without this, | ||
| // react/jsx-runtime is discovered lazily and the runner imports | ||
| // the CJS source directly, failing with "module is not defined". | ||
| configEnvironment(_name, env) { | ||
| if (!hasCloudflarePlugin) return; | ||
| // Skip the client environment — it runs in the browser, not workerd. | ||
| if (env.consumer === "client") return; | ||
|
|
||
| const reactDeps = [ | ||
| "react", | ||
| "react-dom", | ||
| "react-dom/server.edge", | ||
| "react/jsx-runtime", | ||
| "react/jsx-dev-runtime", | ||
| ]; | ||
|
|
||
| env.optimizeDeps ??= {}; | ||
| env.optimizeDeps.include = [ | ||
| ...(env.optimizeDeps.include ?? []), | ||
| ...reactDeps.filter((d) => !(env.optimizeDeps!.include ?? []).includes(d)), | ||
| ]; | ||
| }, | ||
|
|
||
| resolveId: { | ||
| // Hook filter: only invoke JS for next/* imports and virtual:vinext-* modules. | ||
| // Matches "next/navigation", "next/router.js", "virtual:vinext-rsc-entry", |
There was a problem hiding this comment.
Unrelated change: this configEnvironment hook is not related to create-vinext-app.
This is a React CJS pre-bundling fix for Cloudflare environments. It should be in a separate PR with its own test coverage, not bundled with the scaffolding CLI feature.
| "react": "^19.2.4", | ||
| "react-dom": "^19.2.4", | ||
| "vite": "^7.3.1", | ||
| "vinext": "latest", |
There was a problem hiding this comment.
Pinning to "vinext": "latest" is risky for reproducible builds.
Users who scaffold today and install tomorrow might get a different version. Consider using a caret-pinned version that gets updated when the template is updated, or at minimum "vinext": "^0.x.y" with the current version substituted at scaffold time (you already have the .tmpl machinery for this).
This also applies to the pages-router template.
|
Review submitted as "Changes Requested" with 10 inline comments covering:
|
- Handle relative paths (./my-app, ../my-app) in project name resolution - Remove process.exit(0) from cancel handler; use p.isCancel() instead - Change stdio from "pipe" to "inherit" for subprocess output visibility - Reject malformed scoped package names (@org/, @/name, @, @org/foo/bar) - Add TODO comment for pages-router worker entry extraction - Use realpathSync for symlink-safe auto-run detection - Lower Node engine requirement from >=22 to >=18 - Add CI step to smoke-test plain project name scaffolding - Revert unrelated configEnvironment change from vinext core - Document "latest" versioning decision for pre-1.0 templates
…ironments The configEnvironment hook was accidentally reverted in e3d3255. Without it, React's CJS jsx-runtime.js hits workerd's ESM-only module runner and throws "ReferenceError: module is not defined", breaking pages-router template CI.
|
Tests pass and addressed all review issues |
Summary
Closes #407
Adds
packages/create-vinext-app/— a standalone CLI fornpm create vinext-app@latestthat scaffolds new vinext projects targeting Cloudflare Workers with a single command.Previously, starting a new vinext project required
npm create next-app@latest→vinext init→ manually creatingwrangler.jsonc,worker/index.ts, and installing extra deps (8-12 manual steps). This reduces it to one command.@vitejs/plugin-rsc) and Pages Router (with worker entry)@clack/promptswith--yesflag for CI/non-interactive usagenpm_config_user_agent(works with npm/pnpm/yarn/bun).tmplvariable substitution for{{PROJECT_NAME}}and{{WORKER_NAME}}Before merging: npm setup required
Test plan
pnpm --filter create-vinext-app test— 102/102 tests passpnpm run build— both packages buildpnpm run lint && pnpm run fmt:check && pnpm run typecheck— all cleancreate-vinext-appjob passes on ubuntu + windows