Skip to content

feat: add create-vinext-app scaffolding CLI#406

Open
Divkix wants to merge 9 commits intocloudflare:mainfrom
Divkix:feat/create-vinext-app
Open

feat: add create-vinext-app scaffolding CLI#406
Divkix wants to merge 9 commits intocloudflare:mainfrom
Divkix:feat/create-vinext-app

Conversation

@Divkix
Copy link
Contributor

@Divkix Divkix commented Mar 10, 2026

Summary

Closes #407

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.

Previously, starting a new vinext project required npm create next-app@latestvinext init → manually creating wrangler.jsonc, worker/index.ts, and installing extra deps (8-12 manual steps). This reduces it to one command.

  • Two templates: App Router (with RSC + @vitejs/plugin-rsc) and Pages Router (with worker entry)
  • Interactive prompts via @clack/prompts with --yes flag for CI/non-interactive usage
  • Package manager auto-detection from npm_config_user_agent (works with npm/pnpm/yarn/bun)
  • .tmpl variable substitution for {{PROJECT_NAME}} and {{WORKER_NAME}}
  • 102 tests across 6 files: unit (validate, install), integration (templates, scaffold, cli), e2e
  • CI job to verify scaffolded projects start with HTTP 200 (ubuntu + windows matrix)
  • Publish workflow updated to version bump and publish alongside vinext
  • Docs updated: README, AGENTS.md, CONTRIBUTING.md, migration skill

Before merging: npm setup required

create-vinext-app is a brand new package. Before the publish workflow can ship it, a maintainer needs to:

  1. Register the package name on npm — either do a manual first publish (npm publish --access public from the built package) or create a placeholder via npm init create-vinext-app on npmjs.com
  2. Configure OIDC trusted publishing — link the cloudflare/vinext GitHub repo as a trusted publisher for create-vinext-app on npmjs.com (Settings → Publishing access → Add trusted publisher), same as already done for vinext

Without this, the npm publish --provenance step in publish.yml (line 86) will fail on first run with a 403.

After that one-time setup, future publishes are fully automated — the workflow bumps both packages to the same version and publishes them together.

Test plan

  • pnpm --filter create-vinext-app test — 102/102 tests pass
  • pnpm run build — both packages build
  • pnpm run lint && pnpm run fmt:check && pnpm run typecheck — all clean
  • Manual: scaffold app-router project, verify dev server starts
  • Manual: scaffold pages-router project, verify dev server starts
  • CI: create-vinext-app job passes on ubuntu + windows

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
Copilot AI review requested due to automatic review settings March 10, 2026 09:31
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@406

commit: 2e21aa5

Divkix added 2 commits March 10, 2026 02:39
…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.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Divkix added 3 commits March 10, 2026 02:52
…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
@james-elicx
Copy link
Collaborator

Will do some manual passes + testing over the diff later as well.

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Bug (blocking): normalizedName is 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" — the package.json name field will get set to cva-test which happens to be fine for basename, but the targetDir extraction only handles path.isAbsolute(), not relative paths like ../foo/bar.

  2. Bug: prompts.ts onCancel calls process.exit(0) despite the JSDoc claiming it returns null. Makes the function untestable for cancellation and contradicts main()'s if (!answers) return guard.

  3. Correctness: validate.ts regex 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 but bareName extraction with .split("/").pop() could return empty string for @org/ (trailing slash, no package name).

  4. DX: stdio: "pipe" on git init and npm install hides all output including errors from the user.

  5. Template: The pages-router worker/index.ts is 242 lines of production-grade request handling code duplicated from the vinext core. This will drift.

  6. Unrelated change: The configEnvironment hook addition in packages/vinext/src/index.ts should be in a separate PR.

Details inline.

if (path.isAbsolute(projectName)) {
targetDir = projectName;
projectName = path.basename(projectName);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
}
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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" });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 @@
/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 1317 to 1344
// 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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

Review submitted as "Changes Requested" with 10 inline comments covering:

  1. Bug: Relative paths with directory separators not handled in index.ts — only path.isAbsolute() is checked, so ../my-app breaks
  2. Bug: prompts.ts onCancel calls process.exit(0) despite the documented "returns null" contract
  3. DX: stdio: "pipe" in scaffold.ts silently swallows all subprocess output from git init and npm install
  4. Edge case: validate.ts accepts malformed scoped names like @org/ (empty package part)
  5. Maintainability: The pages-router worker/index.ts template is 242 lines duplicated from vinext core that will drift
  6. Fragile entry point detection in the auto-run logic at bottom of index.ts
  7. High Node.js minimum (>=22) with no apparent need for Node 22-specific APIs
  8. CI coverage gap — only the absolute-path code path is tested, not the typical user flow
  9. Unrelated change — the configEnvironment React CJS fix belongs in a separate PR
  10. "vinext": "latest" in templates is risky for reproducible builds

github run

Divkix added 3 commits March 10, 2026 08:58
- 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.
@Divkix
Copy link
Contributor Author

Divkix commented Mar 10, 2026

Tests pass and addressed all review issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add create-vinext-app scaffolding CLI

3 participants