From bf46d29ec65bb58d9f499f03b4176a6e1d12b3e9 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 02:29:57 -0700 Subject: [PATCH 1/9] feat: add create-vinext-app scaffolding CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/migrate-to-vinext/SKILL.md | 10 + .../references/compatibility.md | 2 +- .github/workflows/ci.yml | 50 ++++ .github/workflows/publish.yml | 14 +- .oxfmtrc.json | 6 +- .oxlintrc.json | 7 +- AGENTS.md | 44 +-- CONTRIBUTING.md | 17 ++ README.md | 17 +- package.json | 2 +- packages/create-vinext-app/package.json | 38 +++ packages/create-vinext-app/src/index.ts | 199 ++++++++++++++ packages/create-vinext-app/src/install.ts | 25 ++ packages/create-vinext-app/src/prompts.ts | 50 ++++ packages/create-vinext-app/src/scaffold.ts | 113 ++++++++ packages/create-vinext-app/src/validate.ts | 113 ++++++++ .../templates/app-router/_gitignore | 4 + .../app-router/app/api/hello/route.ts | 5 + .../templates/app-router/app/layout.tsx | 12 + .../templates/app-router/app/page.tsx | 17 ++ .../templates/app-router/package.json.tmpl | 20 ++ .../templates/app-router/tsconfig.json | 13 + .../templates/app-router/vite.config.ts | 15 ++ .../templates/app-router/wrangler.jsonc.tmpl | 11 + .../templates/pages-router/_gitignore | 4 + .../templates/pages-router/next-shims.d.ts | 25 ++ .../templates/pages-router/package.json.tmpl | 18 ++ .../templates/pages-router/pages/about.tsx | 11 + .../templates/pages-router/pages/api/hello.ts | 7 + .../templates/pages-router/pages/index.tsx | 21 ++ .../templates/pages-router/tsconfig.json | 13 + .../templates/pages-router/vite.config.ts | 10 + .../templates/pages-router/worker/index.ts | 242 +++++++++++++++++ .../pages-router/wrangler.jsonc.tmpl | 10 + packages/create-vinext-app/tests/cli.test.ts | 253 ++++++++++++++++++ packages/create-vinext-app/tests/e2e.test.ts | 97 +++++++ packages/create-vinext-app/tests/helpers.ts | 33 +++ .../create-vinext-app/tests/install.test.ts | 62 +++++ .../create-vinext-app/tests/scaffold.test.ts | 205 ++++++++++++++ .../create-vinext-app/tests/templates.test.ts | 126 +++++++++ .../create-vinext-app/tests/validate.test.ts | 111 ++++++++ packages/create-vinext-app/tsconfig.json | 16 ++ packages/create-vinext-app/vitest.config.ts | 7 + pnpm-lock.yaml | 35 +++ tsconfig.json | 10 +- 45 files changed, 2092 insertions(+), 28 deletions(-) create mode 100644 packages/create-vinext-app/package.json create mode 100644 packages/create-vinext-app/src/index.ts create mode 100644 packages/create-vinext-app/src/install.ts create mode 100644 packages/create-vinext-app/src/prompts.ts create mode 100644 packages/create-vinext-app/src/scaffold.ts create mode 100644 packages/create-vinext-app/src/validate.ts create mode 100644 packages/create-vinext-app/templates/app-router/_gitignore create mode 100644 packages/create-vinext-app/templates/app-router/app/api/hello/route.ts create mode 100644 packages/create-vinext-app/templates/app-router/app/layout.tsx create mode 100644 packages/create-vinext-app/templates/app-router/app/page.tsx create mode 100644 packages/create-vinext-app/templates/app-router/package.json.tmpl create mode 100644 packages/create-vinext-app/templates/app-router/tsconfig.json create mode 100644 packages/create-vinext-app/templates/app-router/vite.config.ts create mode 100644 packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl create mode 100644 packages/create-vinext-app/templates/pages-router/_gitignore create mode 100644 packages/create-vinext-app/templates/pages-router/next-shims.d.ts create mode 100644 packages/create-vinext-app/templates/pages-router/package.json.tmpl create mode 100644 packages/create-vinext-app/templates/pages-router/pages/about.tsx create mode 100644 packages/create-vinext-app/templates/pages-router/pages/api/hello.ts create mode 100644 packages/create-vinext-app/templates/pages-router/pages/index.tsx create mode 100644 packages/create-vinext-app/templates/pages-router/tsconfig.json create mode 100644 packages/create-vinext-app/templates/pages-router/vite.config.ts create mode 100644 packages/create-vinext-app/templates/pages-router/worker/index.ts create mode 100644 packages/create-vinext-app/templates/pages-router/wrangler.jsonc.tmpl create mode 100644 packages/create-vinext-app/tests/cli.test.ts create mode 100644 packages/create-vinext-app/tests/e2e.test.ts create mode 100644 packages/create-vinext-app/tests/helpers.ts create mode 100644 packages/create-vinext-app/tests/install.test.ts create mode 100644 packages/create-vinext-app/tests/scaffold.test.ts create mode 100644 packages/create-vinext-app/tests/templates.test.ts create mode 100644 packages/create-vinext-app/tests/validate.test.ts create mode 100644 packages/create-vinext-app/tsconfig.json create mode 100644 packages/create-vinext-app/vitest.config.ts diff --git a/.agents/skills/migrate-to-vinext/SKILL.md b/.agents/skills/migrate-to-vinext/SKILL.md index 82a91b95..a03a81a5 100644 --- a/.agents/skills/migrate-to-vinext/SKILL.md +++ b/.agents/skills/migrate-to-vinext/SKILL.md @@ -7,6 +7,16 @@ description: Migrates Next.js projects to vinext (Vite-based Next.js reimplement vinext reimplements the Next.js API surface on Vite. Existing `app/`, `pages/`, and `next.config.js` work as-is — migration is a package swap, config generation, and ESM conversion. No changes to application code required. +## Starting a New Project? + +If you're starting from scratch (not migrating an existing Next.js app), use `create-vinext-app` instead: + +```bash +npm create vinext-app@latest +``` + +This scaffolds a complete vinext project with Cloudflare Workers support. The rest of this skill is for migrating existing Next.js projects. + ## FIRST: Verify Next.js Project Confirm `next` is in `dependencies` or `devDependencies` in `package.json`. If not found, STOP — this skill does not apply. diff --git a/.agents/skills/migrate-to-vinext/references/compatibility.md b/.agents/skills/migrate-to-vinext/references/compatibility.md index 18ffa7be..54d0dbcd 100644 --- a/.agents/skills/migrate-to-vinext/references/compatibility.md +++ b/.agents/skills/migrate-to-vinext/references/compatibility.md @@ -110,6 +110,6 @@ These features are intentionally excluded: - `next export` (legacy — use `output: 'export'`) - Turbopack/webpack configuration - `next/jest` (use Vitest) -- `create-next-app` scaffolding +- `create-next-app` scaffolding — Replaced by `create-vinext-app` (`npm create vinext-app@latest`) - Bug-for-bug parity with undocumented Next.js behavior - Native Node modules in Workers (sharp, resvg, satori — auto-stubbed in production) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73d5e044..dd94c420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,56 @@ jobs: kill "$SERVER_PID" 2>/dev/null || true exit 1 + create-vinext-app: + name: create-vinext-app (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node-pnpm + - name: Build packages + run: pnpm run build + + - name: Pack vinext for local install + run: npm pack --pack-destination "${{ runner.temp }}" + 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" --yes --skip-install + + - name: Install vinext from local tarball + working-directory: ${{ runner.temp }}/cva-test + shell: bash + run: npm install "${{ runner.temp }}"/vinext-*.tgz + + - name: Install remaining deps + working-directory: ${{ runner.temp }}/cva-test + run: npm install + + - name: Start dev server and verify HTTP 200 + working-directory: ${{ runner.temp }}/cva-test + shell: bash + run: | + npx vite dev --port 3098 & + SERVER_PID=$! + + for i in $(seq 1 30); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3098/ || true) + if [ "$STATUS" = "200" ]; then + echo "Server responded with HTTP 200 (attempt $i)" + kill "$SERVER_PID" 2>/dev/null || true + exit 0 + fi + sleep 1 + done + + echo "Server did not respond with HTTP 200 within 30 seconds" + kill "$SERVER_PID" 2>/dev/null || true + exit 1 + e2e: name: E2E (${{ matrix.project }}) runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 07813251..301ac926 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -71,10 +71,20 @@ jobs: echo "Publishing vinext@${VERSION} (was ${LATEST})" echo "previous=${LATEST}" >> "$GITHUB_OUTPUT" - - name: Publish (OIDC trusted publishing) + - name: Bump create-vinext-app version + working-directory: packages/create-vinext-app + run: | + npm version "${{ steps.version.outputs.version }}" --no-git-tag-version + echo "Publishing create-vinext-app@${{ steps.version.outputs.version }}" + + - name: Publish vinext (OIDC trusted publishing) working-directory: packages/vinext run: npm publish --access public --provenance + - name: Publish create-vinext-app (OIDC trusted publishing) + working-directory: packages/create-vinext-app + run: npm publish --access public --provenance + - name: Tag release run: | git tag "v${{ steps.version.outputs.version }}" @@ -166,7 +176,7 @@ jobs: PAYLOAD=$(jq -n \ --arg version "$VERSION" \ --arg summary "$SUMMARY" \ - '{"text": ("*vinext v" + $version + " published to npm*\n\n" + $summary + "\n\nnpm: https://www.npmjs.com/package/vinext/v/" + $version)}') + '{"text": ("*vinext v" + $version + " + create-vinext-app published to npm*\n\n" + $summary + "\n\nnpm: https://www.npmjs.com/package/vinext/v/" + $version)}') echo "::group::Webhook payload" echo "$PAYLOAD" diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 68a15004..e4b0e164 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,5 +6,9 @@ "semi": true, "singleQuote": false, "trailingComma": "all", - "ignorePatterns": ["tests/fixtures/ecosystem/**", "examples/**"] + "ignorePatterns": [ + "tests/fixtures/ecosystem/**", + "examples/**", + "packages/create-vinext-app/templates/**" + ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 1834ee8c..dc4994b8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,3 +1,8 @@ { - "ignorePatterns": ["fixtures/ecosystem/**", "tests/fixtures/ecosystem/**", "examples/**"] + "ignorePatterns": [ + "fixtures/ecosystem/**", + "tests/fixtures/ecosystem/**", + "examples/**", + "packages/create-vinext-app/templates/**" + ] } diff --git a/AGENTS.md b/AGENTS.md index 6ff6ace5..d39de3bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ pnpm run lint # oxlint pnpm run fmt # oxfmt (format) pnpm run fmt:check # oxfmt (check only, no writes) pnpm run build # Build the vinext package +pnpm --filter create-vinext-app test # create-vinext-app tests +pnpm --filter create-vinext-app build # Build the CLI ``` ### Project Structure @@ -39,6 +41,11 @@ packages/vinext/src/ server/ # SSR handlers, ISR, middleware cloudflare/ # KV cache handler +packages/create-vinext-app/ + src/ # CLI source + templates/ # App Router & Pages Router scaffolding templates + tests/ # Unit, integration, e2e tests + tests/ *.test.ts # Vitest tests fixtures/ # Test apps (pages-basic, app-basic, etc.) @@ -49,14 +56,16 @@ examples/ # User-facing demo apps ### Key Files -| File | Purpose | -| -------------------------- | ------------------------------------------------------------------ | -| `index.ts` | Vite plugin — resolves `next/*` imports, generates virtual modules | -| `shims/*.ts` | Reimplementations of `next/link`, `next/navigation`, etc. | -| `server/dev-server.ts` | Pages Router SSR handler | -| `entries/app-rsc-entry.ts` | App Router RSC entry generator | -| `routing/pages-router.ts` | Scans `pages/` directory | -| `routing/app-router.ts` | Scans `app/` directory | +| File | Purpose | +| -------------------------------------------- | ------------------------------------------------------------------ | +| `index.ts` | Vite plugin — resolves `next/*` imports, generates virtual modules | +| `shims/*.ts` | Reimplementations of `next/link`, `next/navigation`, etc. | +| `server/dev-server.ts` | Pages Router SSR handler | +| `entries/app-rsc-entry.ts` | App Router RSC entry generator | +| `routing/pages-router.ts` | Scans `pages/` directory | +| `routing/app-router.ts` | Scans `app/` directory | +| `packages/create-vinext-app/src/index.ts` | CLI entry point for `npm create vinext-app` | +| `packages/create-vinext-app/src/scaffold.ts` | Template copying and variable substitution | --- @@ -119,15 +128,16 @@ pnpm test -t "middleware" **Which test files to run** depends on what you changed: -| If you changed... | Run these tests | -| ---------------------------------------------- | ---------------------------------------------------------------------------------------- | -| A shim (`shims/*.ts`) | `tests/shims.test.ts` + the specific shim test (e.g., `tests/link.test.ts`) | -| Routing (`routing/*.ts`) | `tests/routing.test.ts`, `tests/route-sorting.test.ts` | -| App Router server (`entries/app-rsc-entry.ts`) | `tests/app-router.test.ts`, `tests/features.test.ts` | -| Pages Router server (`server/dev-server.ts`) | `tests/pages-router.test.ts` | -| Caching/ISR | `tests/isr-cache.test.ts`, `tests/fetch-cache.test.ts`, `tests/kv-cache-handler.test.ts` | -| Build/deploy | `tests/deploy.test.ts`, `tests/build-optimization.test.ts` | -| Next.js compat features | `tests/nextjs-compat/` (the relevant file) | +| If you changed... | Run these tests | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| A shim (`shims/*.ts`) | `tests/shims.test.ts` + the specific shim test (e.g., `tests/link.test.ts`) | +| Routing (`routing/*.ts`) | `tests/routing.test.ts`, `tests/route-sorting.test.ts` | +| App Router server (`entries/app-rsc-entry.ts`) | `tests/app-router.test.ts`, `tests/features.test.ts` | +| Pages Router server (`server/dev-server.ts`) | `tests/pages-router.test.ts` | +| Caching/ISR | `tests/isr-cache.test.ts`, `tests/fetch-cache.test.ts`, `tests/kv-cache-handler.test.ts` | +| Build/deploy | `tests/deploy.test.ts`, `tests/build-optimization.test.ts` | +| Next.js compat features | `tests/nextjs-compat/` (the relevant file) | +| create-vinext-app (`packages/create-vinext-app/`) | `pnpm --filter create-vinext-app test` | **Let CI run the full suite.** The full `pnpm test` and all 5 Playwright E2E projects run in CI on every PR. You do not need to run the full suite locally before pushing. CI will catch any cross-cutting regressions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dba811ed..ad57bb92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,23 @@ For browser-level debugging (verifying rendered output, client-side navigation, Check the [open issues](https://github.com/cloudflare/vinext/issues). If you're looking to contribute, those are a good place to start. +### Working on create-vinext-app + +The `packages/create-vinext-app/` package is a standalone CLI for scaffolding new vinext projects. + +```bash +# Run tests +pnpm --filter create-vinext-app test + +# Build +pnpm --filter create-vinext-app build + +# Test manually +node packages/create-vinext-app/dist/index.js test-app --yes --skip-install +``` + +Templates live in `packages/create-vinext-app/templates/`. When updating dependency versions in templates, update both `app-router/package.json.tmpl` and `pages-router/package.json.tmpl`. + ## Project structure See `AGENTS.md` for the full project structure, key files, architecture patterns, and development workflow. diff --git a/README.md b/README.md index 7b17f4ff..ee86be58 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,20 @@ Options: `-p / --port `, `-H / --hostname `, `--turbopack` (accepted `vinext init` options: `--port ` (default: 3001), `--skip-check`, `--force`. -### Starting a new vinext project +### New Project -Run `npm create next-app@latest` to create a new Next.js project, and then follow these instructions to migrate it to vinext. +```bash +npm create vinext-app@latest +``` + +This scaffolds a new vinext project with Cloudflare Workers support. You'll be prompted for a project name and router type (App Router or Pages Router). + +Options: -In the future, we will have a proper `npm create vinext` new project workflow. +- `--template ` — Router template (default: app) +- `--yes` / `-y` — Skip prompts +- `--skip-install` — Skip dependency installation +- `--no-git` — Skip git init ### Migrating an existing Next.js project @@ -528,7 +537,7 @@ These are intentional exclusions: - **`next export` (legacy)** — Use `output: 'export'` in config instead. - **Turbopack/webpack configuration** — This runs on Vite. Use Vite plugins instead of webpack loaders/plugins. - **`next/jest`** — Use Vitest. -- **`create-next-app` scaffolding** — Not a goal. +- **`create-next-app` scaffolding** — Replaced by `create-vinext-app` (`npm create vinext-app@latest`). - **Bug-for-bug parity with undocumented behavior** — If it's not in the Next.js docs, we probably don't replicate it. ## Known limitations diff --git a/package.json b/package.json index d13a9571..b211bb64 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vinext-monorepo", "private": true, "scripts": { - "build": "pnpm --filter vinext run build", + "build": "pnpm --filter vinext --filter create-vinext-app run build", "test": "vitest run", "test:watch": "vitest", "generate:google-fonts": "node scripts/generate-google-fonts.js", diff --git a/packages/create-vinext-app/package.json b/packages/create-vinext-app/package.json new file mode 100644 index 00000000..904423f7 --- /dev/null +++ b/packages/create-vinext-app/package.json @@ -0,0 +1,38 @@ +{ + "name": "create-vinext-app", + "version": "0.0.1", + "description": "Scaffold a new vinext project targeting Cloudflare Workers", + "keywords": [ + "cloudflare", + "create", + "next", + "scaffold", + "vinext", + "vite", + "workers" + ], + "license": "MIT", + "bin": { + "create-vinext-app": "dist/index.js" + }, + "files": [ + "dist", + "templates" + ], + "type": "module", + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@clack/prompts": "^0.10.0" + }, + "devDependencies": { + "typescript": "^5.8.2", + "vitest": "^3.2.1" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts new file mode 100644 index 00000000..9e068016 --- /dev/null +++ b/packages/create-vinext-app/src/index.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { validateProjectName, resolveProjectPath, isDirectoryEmpty } from "./validate.js"; +import { detectPackageManager } from "./install.js"; +import { scaffold } from "./scaffold.js"; + +// Read version from package.json +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "..", "package.json"), "utf-8")); +const VERSION: string = pkg.version; + +interface CliOptions { + projectName?: string; + template?: "app" | "pages"; + yes: boolean; + skipInstall: boolean; + noGit: boolean; + help: boolean; + version: boolean; +} + +export function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + yes: false, + skipInstall: false, + noGit: false, + help: false, + version: false, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case "--help": + case "-h": + opts.help = true; + break; + case "--version": + case "-v": + opts.version = true; + break; + case "--yes": + case "-y": + opts.yes = true; + break; + case "--skip-install": + opts.skipInstall = true; + break; + case "--no-git": + opts.noGit = true; + break; + case "--template": { + i++; + const tmpl = argv[i]; + if (tmpl !== "app" && tmpl !== "pages") { + console.error(`Invalid template: "${tmpl}". Must be "app" or "pages".`); + process.exit(1); + } + opts.template = tmpl; + break; + } + default: + if (arg.startsWith("-")) { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + if (!opts.projectName) { + opts.projectName = arg; + } + break; + } + } + + return opts; +} + +function printHelp(): void { + console.log(` +create-vinext-app v${VERSION} + +Scaffold a new vinext project targeting Cloudflare Workers. + +Usage: + create-vinext-app [project-name] [options] + +Options: + --template Router template (default: app) + --yes, -y Skip prompts, use defaults + --skip-install Skip dependency installation + --no-git Skip git init + --help, -h Show help + --version, -v Show version + +Examples: + npm create vinext-app@latest + npm create vinext-app@latest my-app + npm create vinext-app@latest my-app --template pages + npm create vinext-app@latest my-app --yes --skip-install +`); +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const opts = parseArgs(argv); + + if (opts.help) { + printHelp(); + return; + } + + if (opts.version) { + console.log(VERSION); + return; + } + + let projectName: string; + let template: "app" | "pages"; + + // If --yes or not a TTY, skip prompts and use defaults + if (opts.yes || !process.stdin.isTTY) { + projectName = opts.projectName ?? "my-vinext-app"; + template = opts.template ?? "app"; + } else { + // Run interactive prompts + const { runPrompts } = await import("./prompts.js"); + const answers = await runPrompts({ + projectName: opts.projectName, + template: opts.template, + }); + if (!answers) return; // cancelled + projectName = answers.projectName; + template = answers.template; + } + + // Validate project name + const validation = validateProjectName(projectName); + if (!validation.valid) { + console.error(`Invalid project name: ${validation.message}`); + process.exit(1); + } + + // Use normalized name if available + const normalizedName = + validation.valid && validation.normalized ? validation.normalized : projectName; + + // Resolve project path + const projectPath = resolveProjectPath(normalizedName, process.cwd()); + + // Check if directory is empty + if (!isDirectoryEmpty(projectPath)) { + console.error(`Directory "${projectPath}" is not empty.`); + process.exit(1); + } + + // Detect package manager + const pm = detectPackageManager(); + + // Scaffold + scaffold({ + projectPath, + projectName: normalizedName, + template, + install: !opts.skipInstall, + git: !opts.noGit, + pm, + }); + + // Success message + const relativePath = path.relative(process.cwd(), projectPath); + console.log(""); + console.log(`Success! Created ${normalizedName} at ${projectPath}`); + console.log(""); + console.log("Next steps:"); + if (relativePath && relativePath !== ".") { + console.log(` cd ${relativePath}`); + } + if (opts.skipInstall) { + const pmCmd = pm === "yarn" ? "yarn" : `${pm} install`; + console.log(` ${pmCmd}`); + } + console.log(` ${pm === "npm" ? "npm run" : pm} dev`); + console.log(""); +} + +// Auto-run when this is the entry point +const thisFile = path.resolve(fileURLToPath(import.meta.url)); +const entryFile = process.argv[1]; +if (entryFile) { + const resolvedEntry = path.resolve(entryFile); + // Handle both .ts (dev) and .js (built) entry + if (resolvedEntry === thisFile || resolvedEntry === thisFile.replace(/\.ts$/, ".js")) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); + } +} diff --git a/packages/create-vinext-app/src/install.ts b/packages/create-vinext-app/src/install.ts new file mode 100644 index 00000000..4f45b63f --- /dev/null +++ b/packages/create-vinext-app/src/install.ts @@ -0,0 +1,25 @@ +export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; + +export function detectPackageManager( + env: Record = process.env, +): PackageManager { + const ua = env.npm_config_user_agent; + if (!ua) return "npm"; + + const name = ua.trim().toLowerCase().split(" ")[0]?.split("/")[0]; + if (name === "pnpm" || name === "yarn" || name === "bun") return name; + return "npm"; +} + +export function buildInstallCommand(pm: PackageManager): string[] { + switch (pm) { + case "bun": + return ["bun", "install"]; + case "pnpm": + return ["pnpm", "install"]; + case "yarn": + return ["yarn"]; + case "npm": + return ["npm", "install"]; + } +} diff --git a/packages/create-vinext-app/src/prompts.ts b/packages/create-vinext-app/src/prompts.ts new file mode 100644 index 00000000..a6203574 --- /dev/null +++ b/packages/create-vinext-app/src/prompts.ts @@ -0,0 +1,50 @@ +import * as p from "@clack/prompts"; + +export interface PromptAnswers { + projectName: string; + template: "app" | "pages"; +} + +/** + * Run interactive prompts. Returns null if the user cancels (Ctrl+C). + */ +export async function runPrompts(defaults: { + projectName?: string; + template?: "app" | "pages"; +}): Promise { + p.intro("create-vinext-app"); + + const answers = await p.group( + { + projectName: () => + defaults.projectName + ? Promise.resolve(defaults.projectName) + : p.text({ + message: "Project name:", + placeholder: "my-vinext-app", + validate: (value) => { + if (!value.trim()) return "Project name is required"; + // Basic validation inline — full validation happens after prompts + }, + }), + template: () => + defaults.template + ? Promise.resolve(defaults.template) + : p.select({ + message: "Which router?", + options: [ + { value: "app" as const, label: "App Router", hint: "recommended" }, + { value: "pages" as const, label: "Pages Router" }, + ], + }), + }, + { + onCancel: () => { + p.cancel("Cancelled."); + process.exit(0); + }, + }, + ); + + return answers as PromptAnswers; +} diff --git a/packages/create-vinext-app/src/scaffold.ts b/packages/create-vinext-app/src/scaffold.ts new file mode 100644 index 00000000..5d2993ed --- /dev/null +++ b/packages/create-vinext-app/src/scaffold.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { buildInstallCommand, type PackageManager } from "./install.js"; + +export interface ScaffoldOptions { + projectPath: string; + projectName: string; + template: "app" | "pages"; + install: boolean; + git: boolean; + pm: PackageManager; + /** Injectable exec for testing */ + _exec?: (cmd: string, args: string[], opts: { cwd: string }) => void; +} + +function defaultExec(cmd: string, args: string[], opts: { cwd: string }): void { + execFileSync(cmd, args, { cwd: opts.cwd, stdio: "pipe" }); +} + +/** + * Sanitize project name into a valid Cloudflare Worker name. + * Worker names: lowercase, alphanumeric, hyphens only. No leading/trailing hyphens. + */ +export function sanitizeWorkerName(name: string): string { + // Strip scope (@org/) + const unscoped = name.startsWith("@") ? name.split("/").pop()! : name; + return unscoped + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); +} + +/** + * Recursively copy a directory, preserving structure. + */ +function copyDir(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Walk the project directory, find .tmpl files, substitute variables, + * and rename (strip .tmpl extension). + */ +function processTmplFiles(dir: string, vars: Record): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + processTmplFiles(fullPath, vars); + } else if (entry.name.endsWith(".tmpl")) { + let content = fs.readFileSync(fullPath, "utf-8"); + for (const [key, value] of Object.entries(vars)) { + content = content.replaceAll(key, value); + } + const newPath = fullPath.slice(0, -5); // strip .tmpl + fs.writeFileSync(newPath, content, "utf-8"); + fs.unlinkSync(fullPath); + } + } +} + +export function scaffold(options: ScaffoldOptions): void { + const { projectPath, projectName, template, install, git, pm } = options; + const exec = options._exec ?? defaultExec; + + // Create project directory + fs.mkdirSync(projectPath, { recursive: true }); + + // Copy template files + const templateDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "templates", + template === "app" ? "app-router" : "pages-router", + ); + copyDir(templateDir, projectPath); + + // Substitute .tmpl variables + const workerName = sanitizeWorkerName(projectName); + processTmplFiles(projectPath, { + "{{PROJECT_NAME}}": projectName, + "{{WORKER_NAME}}": workerName, + }); + + // Rename _gitignore -> .gitignore + const gitignoreSrc = path.join(projectPath, "_gitignore"); + const gitignoreDst = path.join(projectPath, ".gitignore"); + if (fs.existsSync(gitignoreSrc)) { + fs.renameSync(gitignoreSrc, gitignoreDst); + } + + // Git init + if (git) { + exec("git", ["init"], { cwd: projectPath }); + } + + // Install deps + if (install) { + const [cmd, ...args] = buildInstallCommand(pm); + exec(cmd, args, { cwd: projectPath }); + } +} diff --git a/packages/create-vinext-app/src/validate.ts b/packages/create-vinext-app/src/validate.ts new file mode 100644 index 00000000..916a0eb4 --- /dev/null +++ b/packages/create-vinext-app/src/validate.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import path from "node:path"; + +type ValidationResult = + | { valid: true; normalized?: string; useCwd?: boolean } + | { valid: false; message: string }; + +const RESERVED_NAMES = new Set([ + "node_modules", + "favicon.ico", + "package.json", + "package-lock.json", +]); + +const IGNORABLE_FILES = new Set([".git", ".DS_Store", ".gitkeep", "Thumbs.db"]); + +/** + * Validates a project name against npm naming conventions and additional + * constraints for project directory creation. + */ +export function validateProjectName(name: string): ValidationResult { + if (name === "") { + return { valid: false, message: "Project name is required" }; + } + + if (name === ".") { + return { valid: true, useCwd: true }; + } + + if (RESERVED_NAMES.has(name.toLowerCase())) { + return { + valid: false, + message: `"${name}" is a reserved name and cannot be used as a project name`, + }; + } + + if (name.length > 214) { + return { + valid: false, + message: "Project name must be 214 characters or fewer", + }; + } + + if (/\s/.test(name)) { + return { + valid: false, + message: "Project name cannot contain spaces", + }; + } + + // Scoped package names: @scope/name + const nameToValidate = name.startsWith("@") ? name : name; + + if (!/^[a-zA-Z0-9\-._@/]+$/.test(nameToValidate)) { + return { + valid: false, + message: + "Project name can only contain lowercase letters, numbers, hyphens, dots, underscores, and scoped package prefixes (@org/)", + }; + } + + // Determine the "bare" name for start-char validation. + // For scoped names like @org/app, validate the part after the slash. + const bareName = name.startsWith("@") ? (name.split("/").pop() ?? name) : name; + + if (/^\d/.test(bareName)) { + return { + valid: false, + message: "Project name cannot start with a number", + }; + } + + if (bareName.startsWith("-")) { + return { + valid: false, + message: "Project name cannot start with a hyphen", + }; + } + + if (name !== name.toLowerCase()) { + return { valid: true, normalized: name.toLowerCase() }; + } + + return { valid: true }; +} + +/** + * Resolves a project name to an absolute directory path. + */ +export function resolveProjectPath(name: string, cwd: string): string { + if (name === ".") { + return cwd; + } + + if (path.isAbsolute(name)) { + return name; + } + + return path.resolve(cwd, name); +} + +/** + * Checks whether a directory is empty or contains only ignorable dotfiles. + * Returns true if the directory does not exist. + */ +export function isDirectoryEmpty(dir: string): boolean { + if (!fs.existsSync(dir)) { + return true; + } + + const entries = fs.readdirSync(dir); + return entries.every((entry) => IGNORABLE_FILES.has(entry)); +} diff --git a/packages/create-vinext-app/templates/app-router/_gitignore b/packages/create-vinext-app/templates/app-router/_gitignore new file mode 100644 index 00000000..d841baf0 --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/_gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.wrangler/ +.vite/ diff --git a/packages/create-vinext-app/templates/app-router/app/api/hello/route.ts b/packages/create-vinext-app/templates/app-router/app/api/hello/route.ts new file mode 100644 index 00000000..6c08debf --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/app/api/hello/route.ts @@ -0,0 +1,5 @@ +export async function GET() { + return Response.json({ + message: "Hello from vinext on Cloudflare Workers!", + }); +} diff --git a/packages/create-vinext-app/templates/app-router/app/layout.tsx b/packages/create-vinext-app/templates/app-router/app/layout.tsx new file mode 100644 index 00000000..d99d5eb3 --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/app/layout.tsx @@ -0,0 +1,12 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + My vinext App + + {children} + + ); +} diff --git a/packages/create-vinext-app/templates/app-router/app/page.tsx b/packages/create-vinext-app/templates/app-router/app/page.tsx new file mode 100644 index 00000000..9d5169fa --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/app/page.tsx @@ -0,0 +1,17 @@ +export default function HomePage() { + return ( +
+

Welcome to vinext

+

+ Edit app/page.tsx to get started. +

+ +
+ ); +} diff --git a/packages/create-vinext-app/templates/app-router/package.json.tmpl b/packages/create-vinext-app/templates/app-router/package.json.tmpl new file mode 100644 index 00000000..62173aea --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/package.json.tmpl @@ -0,0 +1,20 @@ +{ + "name": "{{PROJECT_NAME}}", + "type": "module", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "vite": "^7.3.1", + "vinext": "latest", + "@vitejs/plugin-rsc": "^0.5.19", + "react-server-dom-webpack": "^19.2.4", + "@cloudflare/vite-plugin": "^1.25.0", + "wrangler": "^4.65.0" + } +} diff --git a/packages/create-vinext-app/templates/app-router/tsconfig.json b/packages/create-vinext-app/templates/app-router/tsconfig.json new file mode 100644 index 00000000..9921387a --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["app", "*.ts"] +} diff --git a/packages/create-vinext-app/templates/app-router/vite.config.ts b/packages/create-vinext-app/templates/app-router/vite.config.ts new file mode 100644 index 00000000..947e5989 --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import vinext from "vinext"; +import { cloudflare } from "@cloudflare/vite-plugin"; + +export default defineConfig({ + plugins: [ + vinext(), + cloudflare({ + viteEnvironment: { + name: "rsc", + childEnvironments: ["ssr"], + }, + }), + ], +}); diff --git a/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl b/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl new file mode 100644 index 00000000..034af188 --- /dev/null +++ b/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "{{WORKER_NAME}}", + "compatibility_date": "2026-02-12", + "compatibility_flags": ["nodejs_compat"], + "main": "./worker/index.ts", + "assets": { + "not_found_handling": "none", + "binding": "ASSETS" + } +} diff --git a/packages/create-vinext-app/templates/pages-router/_gitignore b/packages/create-vinext-app/templates/pages-router/_gitignore new file mode 100644 index 00000000..d841baf0 --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/_gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.wrangler/ +.vite/ diff --git a/packages/create-vinext-app/templates/pages-router/next-shims.d.ts b/packages/create-vinext-app/templates/pages-router/next-shims.d.ts new file mode 100644 index 00000000..22645ed5 --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/next-shims.d.ts @@ -0,0 +1,25 @@ +// Type declarations for vinext shim modules +declare module "next/head" { + export default function Head(props: { children?: React.ReactNode }): React.ReactElement; + export function resetSSRHead(): void; + export function getSSRHeadHTML(): string; +} +declare module "next/link" { + import type { AnchorHTMLAttributes } from "react"; + export interface LinkProps extends AnchorHTMLAttributes { + href: string; + as?: string; + replace?: boolean; + scroll?: boolean; + shallow?: boolean; + passHref?: boolean; + prefetch?: boolean; + locale?: string | false; + legacyBehavior?: boolean; + } + export default function Link(props: LinkProps): React.ReactElement; +} +declare module "next/router" { + export function useRouter(): any; + export function setSSRContext(ctx: any): void; +} diff --git a/packages/create-vinext-app/templates/pages-router/package.json.tmpl b/packages/create-vinext-app/templates/pages-router/package.json.tmpl new file mode 100644 index 00000000..f78134bb --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/package.json.tmpl @@ -0,0 +1,18 @@ +{ + "name": "{{PROJECT_NAME}}", + "type": "module", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "vite": "^7.3.1", + "vinext": "latest", + "@cloudflare/vite-plugin": "^1.25.0", + "wrangler": "^4.65.0" + } +} diff --git a/packages/create-vinext-app/templates/pages-router/pages/about.tsx b/packages/create-vinext-app/templates/pages-router/pages/about.tsx new file mode 100644 index 00000000..7af2852f --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/pages/about.tsx @@ -0,0 +1,11 @@ +import Link from "next/link"; + +export default function About() { + return ( + <> +

About

+

This is the about page running on Cloudflare Workers.

+ Home + + ); +} diff --git a/packages/create-vinext-app/templates/pages-router/pages/api/hello.ts b/packages/create-vinext-app/templates/pages-router/pages/api/hello.ts new file mode 100644 index 00000000..674b7204 --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/pages/api/hello.ts @@ -0,0 +1,7 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + res.json({ + message: "Hello from vinext on Cloudflare Workers!", + }); +} diff --git a/packages/create-vinext-app/templates/pages-router/pages/index.tsx b/packages/create-vinext-app/templates/pages-router/pages/index.tsx new file mode 100644 index 00000000..d59ea6fe --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/pages/index.tsx @@ -0,0 +1,21 @@ +import Head from "next/head"; +import Link from "next/link"; + +export default function Home() { + return ( + <> + + My vinext App + +

Welcome to vinext

+

+ Edit pages/index.tsx to get started. +

+ + + ); +} diff --git a/packages/create-vinext-app/templates/pages-router/tsconfig.json b/packages/create-vinext-app/templates/pages-router/tsconfig.json new file mode 100644 index 00000000..7fc1667a --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["next-shims.d.ts", "pages", "worker"] +} diff --git a/packages/create-vinext-app/templates/pages-router/vite.config.ts b/packages/create-vinext-app/templates/pages-router/vite.config.ts new file mode 100644 index 00000000..999697a9 --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import vinext from "vinext"; +import { cloudflare } from "@cloudflare/vite-plugin"; + +export default defineConfig({ + plugins: [ + vinext(), + cloudflare(), + ], +}); diff --git a/packages/create-vinext-app/templates/pages-router/worker/index.ts b/packages/create-vinext-app/templates/pages-router/worker/index.ts new file mode 100644 index 00000000..5634655a --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/worker/index.ts @@ -0,0 +1,242 @@ +/** + * Cloudflare Worker entry point for vinext Pages Router. + * + * The built server entry (virtual:vinext-server-entry) exports: + * - renderPage(request, url, manifest) -> Response + * - handleApiRoute(request, url) -> Response + * - runMiddleware(request) -> middleware result + * - vinextConfig -> embedded next.config.js settings + * + * Both use Web-standard Request/Response APIs, making them + * directly usable in a Worker fetch handler. + */ +import { + matchRedirect, + matchRewrite, + matchHeaders, + requestContextFromRequest, + isExternalUrl, + proxyExternalRequest, +} from "vinext/config/config-matchers"; +import { mergeHeaders } from "vinext/server/worker-utils"; + +// @ts-expect-error -- virtual module resolved by vinext at build time +import { renderPage, handleApiRoute, runMiddleware, vinextConfig } from "virtual:vinext-server-entry"; + +// Extract config values (embedded at build time in the server entry) +const basePath: string = vinextConfig?.basePath ?? ""; +const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false; +const configRedirects = vinextConfig?.redirects ?? []; +const configRewrites = vinextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; +const configHeaders = vinextConfig?.headers ?? []; + +export default { + async fetch(request: Request): Promise { + try { + const url = new URL(request.url); + let pathname = url.pathname; + let urlWithQuery = pathname + url.search; + + // Block protocol-relative URL open redirects (//evil.com/ or /\evil.com/). + // Normalize backslashes: browsers treat /\ as // in URL context. + if (pathname.replaceAll("\\", "/").startsWith("//")) { + return new Response("404 Not Found", { status: 404 }); + } + + // Strip basePath + if (basePath && pathname.startsWith(basePath)) { + const stripped = pathname.slice(basePath.length) || "/"; + urlWithQuery = stripped + url.search; + pathname = stripped; + } + + // Trailing slash normalization + if (pathname !== "/" && !pathname.startsWith("/api")) { + const hasTrailing = pathname.endsWith("/"); + if (trailingSlash && !hasTrailing) { + return new Response(null, { + status: 308, + headers: { Location: basePath + pathname + "/" + url.search }, + }); + } else if (!trailingSlash && hasTrailing) { + return new Response(null, { + status: 308, + headers: { Location: basePath + pathname.replace(/\/+$/, "") + url.search }, + }); + } + } + + // Build request with basePath-stripped URL for middleware + if (basePath) { + const strippedUrl = new URL(request.url); + strippedUrl.pathname = pathname; + request = new Request(strippedUrl, request); + } + + // Build request context for has/missing condition matching + const reqCtx = requestContextFromRequest(request); + + // Run middleware + let resolvedUrl = urlWithQuery; + const middlewareHeaders: Record = {}; + let middlewareRewriteStatus: number | undefined; + if (typeof runMiddleware === "function") { + const result = await runMiddleware(request); + + if (!result.continue) { + if (result.redirectUrl) { + return new Response(null, { + status: result.redirectStatus ?? 307, + headers: { Location: result.redirectUrl }, + }); + } + if (result.response) { + return result.response; + } + } + + // Collect middleware response headers to merge into final response. + // Use an array for Set-Cookie to preserve multiple values. + if (result.responseHeaders) { + for (const [key, value] of result.responseHeaders) { + if (key === "set-cookie") { + const existing = middlewareHeaders[key]; + if (Array.isArray(existing)) { + existing.push(value); + } else if (existing) { + middlewareHeaders[key] = [existing as string, value]; + } else { + middlewareHeaders[key] = [value]; + } + } else { + middlewareHeaders[key] = value; + } + } + } + if (result.rewriteUrl) { + resolvedUrl = result.rewriteUrl; + } + middlewareRewriteStatus = result.rewriteStatus; + } + + // Unpack x-middleware-request-* headers + const mwReqPrefix = "x-middleware-request-"; + const mwReqHeaders: Record = {}; + for (const key of Object.keys(middlewareHeaders)) { + if (key.startsWith(mwReqPrefix)) { + mwReqHeaders[key.slice(mwReqPrefix.length)] = middlewareHeaders[key] as string; + delete middlewareHeaders[key]; + } + } + if (Object.keys(mwReqHeaders).length > 0) { + const newHeaders = new Headers(request.headers); + for (const [k, v] of Object.entries(mwReqHeaders)) { + newHeaders.set(k, v); + } + request = new Request(request.url, { + method: request.method, + headers: newHeaders, + body: request.body, + // @ts-expect-error -- duplex needed for streaming request bodies + duplex: request.body ? "half" : undefined, + }); + } + + let resolvedPathname = resolvedUrl.split("?")[0]; + + // Apply custom headers from next.config.js + if (configHeaders.length) { + const matched = matchHeaders(resolvedPathname, configHeaders); + for (const h of matched) { + const lk = h.key.toLowerCase(); + if (lk === "set-cookie") { + const existing = middlewareHeaders[lk]; + if (Array.isArray(existing)) { + existing.push(h.value); + } else if (existing) { + middlewareHeaders[lk] = [existing as string, h.value]; + } else { + middlewareHeaders[lk] = [h.value]; + } + } else if (lk === "vary" && middlewareHeaders[lk]) { + middlewareHeaders[lk] += ", " + h.value; + } else { + middlewareHeaders[lk] = h.value; + } + } + } + + // Apply redirects from next.config.js + if (configRedirects.length) { + const redirect = matchRedirect(resolvedPathname, configRedirects, reqCtx); + if (redirect) { + const dest = basePath && !redirect.destination.startsWith(basePath) + ? basePath + redirect.destination + : redirect.destination; + return new Response(null, { + status: redirect.permanent ? 308 : 307, + headers: { Location: dest }, + }); + } + } + + // Apply beforeFiles rewrites from next.config.js + if (configRewrites.beforeFiles?.length) { + const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, reqCtx); + if (rewritten) { + if (isExternalUrl(rewritten)) { + return proxyExternalRequest(request, rewritten); + } + resolvedUrl = rewritten; + resolvedPathname = rewritten.split("?")[0]; + } + } + + // API routes + if (resolvedPathname.startsWith("/api/") || resolvedPathname === "/api") { + const response = typeof handleApiRoute === "function" + ? await handleApiRoute(request, resolvedUrl) + : new Response("404 - API route not found", { status: 404 }); + return mergeHeaders(response, middlewareHeaders, middlewareRewriteStatus); + } + + // Apply afterFiles rewrites + if (configRewrites.afterFiles?.length) { + const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, reqCtx); + if (rewritten) { + if (isExternalUrl(rewritten)) { + return proxyExternalRequest(request, rewritten); + } + resolvedUrl = rewritten; + resolvedPathname = rewritten.split("?")[0]; + } + } + + // Page routes + let response: Response | undefined; + if (typeof renderPage === "function") { + response = await renderPage(request, resolvedUrl, null); + + // Fallback rewrites (if SSR returned 404) + if (response && response.status === 404 && configRewrites.fallback?.length) { + const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, reqCtx); + if (fallbackRewrite) { + if (isExternalUrl(fallbackRewrite)) { + return proxyExternalRequest(request, fallbackRewrite); + } + response = await renderPage(request, fallbackRewrite, null); + } + } + } + + if (!response) { + return new Response("404 - Not found", { status: 404 }); + } + + return mergeHeaders(response, middlewareHeaders, middlewareRewriteStatus); + } catch (error) { + console.error("[vinext] Worker error:", error); + return new Response("Internal Server Error", { status: 500 }); + } + }, +}; diff --git a/packages/create-vinext-app/templates/pages-router/wrangler.jsonc.tmpl b/packages/create-vinext-app/templates/pages-router/wrangler.jsonc.tmpl new file mode 100644 index 00000000..6a72e92a --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/wrangler.jsonc.tmpl @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "{{WORKER_NAME}}", + "compatibility_date": "2026-02-12", + "compatibility_flags": ["nodejs_compat"], + "main": "./worker/index.ts", + "assets": { + "not_found_handling": "none" + } +} diff --git a/packages/create-vinext-app/tests/cli.test.ts b/packages/create-vinext-app/tests/cli.test.ts new file mode 100644 index 00000000..7b8a4873 --- /dev/null +++ b/packages/create-vinext-app/tests/cli.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock scaffold module before any imports that reference it +vi.mock("../src/scaffold.js", () => ({ + scaffold: vi.fn(), +})); + +// Mock prompts module +vi.mock("../src/prompts.js", () => ({ + runPrompts: vi.fn(), +})); + +import { main, parseArgs } from "../src/index.js"; +import { scaffold } from "../src/scaffold.js"; + +const mockedScaffold = vi.mocked(scaffold); + +let consoleLogs: string[]; +let consoleErrors: string[]; +let exitCode: number | undefined; + +beforeEach(() => { + consoleLogs = []; + consoleErrors = []; + exitCode = undefined; + + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + consoleLogs.push(args.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.join(" ")); + }); + vi.spyOn(process, "exit").mockImplementation((code?: string | number | null | undefined) => { + exitCode = (code ?? 0) as number; + throw new Error(`process.exit(${code})`); + }); + + // Ensure stdin.isTTY is false so prompts are skipped by default in tests + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + + mockedScaffold.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// parseArgs (pure function) +// --------------------------------------------------------------------------- + +describe("parseArgs", () => { + it("parses positional project name", () => { + const opts = parseArgs(["my-app"]); + expect(opts.projectName).toBe("my-app"); + }); + + it("parses --template app", () => { + const opts = parseArgs(["--template", "app"]); + expect(opts.template).toBe("app"); + }); + + it("parses --template pages", () => { + const opts = parseArgs(["--template", "pages"]); + expect(opts.template).toBe("pages"); + }); + + it("parses --yes / -y", () => { + expect(parseArgs(["--yes"]).yes).toBe(true); + expect(parseArgs(["-y"]).yes).toBe(true); + }); + + it("parses --skip-install", () => { + expect(parseArgs(["--skip-install"]).skipInstall).toBe(true); + }); + + it("parses --no-git", () => { + expect(parseArgs(["--no-git"]).noGit).toBe(true); + }); + + it("parses --help / -h", () => { + expect(parseArgs(["--help"]).help).toBe(true); + expect(parseArgs(["-h"]).help).toBe(true); + }); + + it("parses --version / -v", () => { + expect(parseArgs(["--version"]).version).toBe(true); + expect(parseArgs(["-v"]).version).toBe(true); + }); + + it("combines multiple flags", () => { + const opts = parseArgs(["my-app", "--template", "pages", "-y", "--skip-install", "--no-git"]); + expect(opts.projectName).toBe("my-app"); + expect(opts.template).toBe("pages"); + expect(opts.yes).toBe(true); + expect(opts.skipInstall).toBe(true); + expect(opts.noGit).toBe(true); + }); + + it("defaults all flags to false/undefined", () => { + const opts = parseArgs([]); + expect(opts.projectName).toBeUndefined(); + expect(opts.template).toBeUndefined(); + expect(opts.yes).toBe(false); + expect(opts.skipInstall).toBe(false); + expect(opts.noGit).toBe(false); + expect(opts.help).toBe(false); + expect(opts.version).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// main() integration +// --------------------------------------------------------------------------- + +describe("main", () => { + it("--help prints usage and returns", async () => { + await main(["--help"]); + const output = consoleLogs.join("\n"); + expect(output).toContain("create-vinext-app"); + expect(output).toContain("Usage:"); + expect(output).toContain("--template"); + expect(exitCode).toBeUndefined(); + }); + + it("-h also prints help", async () => { + await main(["-h"]); + const output = consoleLogs.join("\n"); + expect(output).toContain("create-vinext-app"); + }); + + it("--version prints version string", async () => { + await main(["--version"]); + expect(consoleLogs).toHaveLength(1); + // Version should match semver pattern + expect(consoleLogs[0]).toMatch(/^\d+\.\d+\.\d+/); + }); + + it("-v also prints version", async () => { + await main(["-v"]); + expect(consoleLogs).toHaveLength(1); + expect(consoleLogs[0]).toMatch(/^\d+\.\d+\.\d+/); + }); + + it("-y skips prompts and uses defaults", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + expect(opts.projectName).toBe("my-app"); + expect(opts.template).toBe("app"); + }); + + it("--template app sets app router", async () => { + await main(["my-app", "--template", "app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].template).toBe("app"); + }); + + it("--template pages sets pages router", async () => { + await main(["my-app", "--template", "pages", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].template).toBe("pages"); + }); + + it("invalid template exits with error", async () => { + await expect(main(["my-app", "--template", "invalid"])).rejects.toThrow("process.exit(1)"); + expect(exitCode).toBe(1); + expect(consoleErrors.join(" ")).toContain("Invalid template"); + }); + + it("unknown option exits with error", async () => { + await expect(main(["--unknown-flag"])).rejects.toThrow("process.exit(1)"); + expect(exitCode).toBe(1); + expect(consoleErrors.join(" ")).toContain("Unknown option"); + }); + + it("positional arg becomes project name", async () => { + await main(["cool-project", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].projectName).toBe("cool-project"); + }); + + it("--skip-install passes install: false to scaffold", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].install).toBe(false); + }); + + it("--no-git passes git: false to scaffold", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].git).toBe(false); + }); + + it("without --skip-install passes install: true", async () => { + await main(["my-app", "-y", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].install).toBe(true); + }); + + it("without --no-git passes git: true", async () => { + await main(["my-app", "-y", "--skip-install"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].git).toBe(true); + }); + + it("no TTY + no --yes uses defaults", async () => { + // isTTY is already set to false in beforeEach + await main(["--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + expect(opts.projectName).toBe("my-vinext-app"); + expect(opts.template).toBe("app"); + }); + + it("invalid project name exits with error", async () => { + await expect(main(["node_modules", "-y"])).rejects.toThrow("process.exit(1)"); + expect(exitCode).toBe(1); + expect(consoleErrors.join(" ")).toContain("Invalid project name"); + }); + + it("normalizes uppercase project name", async () => { + await main(["My-App", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + expect(mockedScaffold.mock.calls[0][0].projectName).toBe("my-app"); + }); + + it("prints success message after scaffolding", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + const output = consoleLogs.join("\n"); + expect(output).toContain("Success!"); + expect(output).toContain("my-app"); + expect(output).toContain("Next steps:"); + }); + + it("success message includes cd when project is in subdirectory", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + const output = consoleLogs.join("\n"); + expect(output).toContain("cd my-app"); + }); + + it("success message includes install hint when --skip-install", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + const output = consoleLogs.join("\n"); + expect(output).toContain("install"); + }); + + it("success message includes dev command", async () => { + await main(["my-app", "-y", "--skip-install", "--no-git"]); + const output = consoleLogs.join("\n"); + expect(output).toContain("dev"); + }); +}); diff --git a/packages/create-vinext-app/tests/e2e.test.ts b/packages/create-vinext-app/tests/e2e.test.ts new file mode 100644 index 00000000..ae6debf0 --- /dev/null +++ b/packages/create-vinext-app/tests/e2e.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const CLI_PATH = path.resolve(import.meta.dirname, "../dist/index.js"); + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-vinext-app-e2e-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function run(args: string[]): { stdout: string; exitCode: number } { + try { + const stdout = execFileSync("node", [CLI_PATH, ...args], { + cwd: tmpDir, + encoding: "utf-8", + timeout: 10_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout, exitCode: 0 }; + } catch (error: unknown) { + const err = error as { stdout?: string; stderr?: string; status?: number }; + return { + stdout: (err.stdout ?? "") + (err.stderr ?? ""), + exitCode: err.status ?? 1, + }; + } +} + +describe("e2e", () => { + it("--help exits 0 and prints usage", () => { + const { stdout, exitCode } = run(["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("create-vinext-app"); + expect(stdout).toContain("--template"); + }); + + it("--version exits 0 and prints semver", () => { + const { stdout, exitCode } = run(["--version"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("scaffolds app-router project", () => { + const { exitCode } = run(["my-app", "-y", "--skip-install", "--no-git"]); + expect(exitCode).toBe(0); + + const projectDir = path.join(tmpDir, "my-app"); + expect(fs.existsSync(path.join(projectDir, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, "app/page.tsx"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, "app/layout.tsx"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, "vite.config.ts"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, "wrangler.jsonc"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, ".gitignore"))).toBe(true); + + // Verify template variables were substituted + const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8")); + expect(pkg.name).toBe("my-app"); + + // No .tmpl files should remain + expect(fs.existsSync(path.join(projectDir, "package.json.tmpl"))).toBe(false); + expect(fs.existsSync(path.join(projectDir, "wrangler.jsonc.tmpl"))).toBe(false); + }); + + it("scaffolds pages-router project", () => { + const { exitCode } = run([ + "my-pages-app", + "-y", + "--template", + "pages", + "--skip-install", + "--no-git", + ]); + expect(exitCode).toBe(0); + + const projectDir = path.join(tmpDir, "my-pages-app"); + expect(fs.existsSync(path.join(projectDir, "pages/index.tsx"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, "worker/index.ts"))).toBe(true); + expect(fs.existsSync(path.join(projectDir, "next-shims.d.ts"))).toBe(true); + + // Should NOT have app/ directory + expect(fs.existsSync(path.join(projectDir, "app"))).toBe(false); + }); + + it("invalid name exits 1", () => { + const { stdout, exitCode } = run(["node_modules", "-y"]); + expect(exitCode).toBe(1); + expect(stdout.toLowerCase()).toContain("invalid"); + }); +}); diff --git a/packages/create-vinext-app/tests/helpers.ts b/packages/create-vinext-app/tests/helpers.ts new file mode 100644 index 00000000..28779e14 --- /dev/null +++ b/packages/create-vinext-app/tests/helpers.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +export function createTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "create-vinext-app-test-")); +} + +export function readFile(dir: string, relativePath: string): string { + return fs.readFileSync(path.join(dir, relativePath), "utf-8"); +} + +export function fileExists(dir: string, relativePath: string): boolean { + return fs.existsSync(path.join(dir, relativePath)); +} + +export function readJson(dir: string, relativePath: string): Record { + return JSON.parse(readFile(dir, relativePath)); +} + +/** No-op exec for tests -- records calls for assertions */ +export function noopExec(): { + exec: (cmd: string, args: string[], opts: { cwd: string }) => void; + calls: Array<{ cmd: string; args: string[]; cwd: string }>; +} { + const calls: Array<{ cmd: string; args: string[]; cwd: string }> = []; + return { + exec: (cmd: string, args: string[], opts: { cwd: string }) => { + calls.push({ cmd, args, cwd: opts.cwd }); + }, + calls, + }; +} diff --git a/packages/create-vinext-app/tests/install.test.ts b/packages/create-vinext-app/tests/install.test.ts new file mode 100644 index 00000000..6bc9881b --- /dev/null +++ b/packages/create-vinext-app/tests/install.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { detectPackageManager, buildInstallCommand } from "../src/install.js"; + +describe("detectPackageManager", () => { + it("detects bun from user agent", () => { + expect( + detectPackageManager({ + npm_config_user_agent: "bun/1.2.0 npm/? node/v22", + }), + ).toBe("bun"); + }); + + it("detects pnpm from user agent", () => { + expect( + detectPackageManager({ + npm_config_user_agent: "pnpm/9.0.0 npm/? node/v22", + }), + ).toBe("pnpm"); + }); + + it("detects yarn from user agent", () => { + expect( + detectPackageManager({ + npm_config_user_agent: "yarn/4.0.0 npm/? node/v22", + }), + ).toBe("yarn"); + }); + + it("detects npm from user agent", () => { + expect( + detectPackageManager({ + npm_config_user_agent: "npm/10.0.0 node/v22", + }), + ).toBe("npm"); + }); + + it("falls back to npm when no env vars", () => { + expect(detectPackageManager({})).toBe("npm"); + }); + + it("falls back to npm when user agent is undefined", () => { + expect(detectPackageManager({ npm_config_user_agent: undefined })).toBe("npm"); + }); +}); + +describe("buildInstallCommand", () => { + it("returns correct command for npm", () => { + expect(buildInstallCommand("npm")).toEqual(["npm", "install"]); + }); + + it("returns correct command for bun", () => { + expect(buildInstallCommand("bun")).toEqual(["bun", "install"]); + }); + + it("returns correct command for pnpm", () => { + expect(buildInstallCommand("pnpm")).toEqual(["pnpm", "install"]); + }); + + it("returns correct command for yarn", () => { + expect(buildInstallCommand("yarn")).toEqual(["yarn"]); + }); +}); diff --git a/packages/create-vinext-app/tests/scaffold.test.ts b/packages/create-vinext-app/tests/scaffold.test.ts new file mode 100644 index 00000000..f2f21dd4 --- /dev/null +++ b/packages/create-vinext-app/tests/scaffold.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { scaffold, sanitizeWorkerName, type ScaffoldOptions } from "../src/scaffold.js"; +import { createTmpDir, readFile, fileExists, readJson, noopExec } from "./helpers.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = createTmpDir(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function defaultOpts(overrides: Partial = {}): ScaffoldOptions { + const { exec } = noopExec(); + return { + projectPath: path.join(tmpDir, "my-app"), + projectName: "my-app", + template: "app", + install: true, + git: true, + pm: "npm", + _exec: exec, + ...overrides, + }; +} + +/** Recursively collect all file paths relative to a root directory */ +function walkDir(dir: string, root?: string): string[] { + root ??= dir; + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDir(fullPath, root)); + } else { + results.push(path.relative(root, fullPath)); + } + } + return results; +} + +describe("scaffold", () => { + it("creates dir when not exists", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath })); + expect(fs.existsSync(projectPath)).toBe(true); + expect(fs.statSync(projectPath).isDirectory()).toBe(true); + }); + + it("works in empty existing dir", () => { + const projectPath = path.join(tmpDir, "my-app"); + fs.mkdirSync(projectPath, { recursive: true }); + scaffold(defaultOpts({ projectPath })); + // Should have files from template + expect(fileExists(projectPath, "vite.config.ts")).toBe(true); + expect(fileExists(projectPath, "tsconfig.json")).toBe(true); + expect(fileExists(projectPath, "package.json")).toBe(true); + }); + + it("app-router generates correct structure", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath, template: "app" })); + // App Router has app/ directory with layout and page + expect(fileExists(projectPath, "app/page.tsx")).toBe(true); + expect(fileExists(projectPath, "app/layout.tsx")).toBe(true); + expect(fileExists(projectPath, "app/api/hello/route.ts")).toBe(true); + // App Router does NOT have pages/ or worker/ + expect(fileExists(projectPath, "pages")).toBe(false); + expect(fileExists(projectPath, "worker")).toBe(false); + }); + + it("pages-router generates correct structure", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath, template: "pages" })); + // Pages Router has pages/ directory and worker entry + expect(fileExists(projectPath, "pages/index.tsx")).toBe(true); + expect(fileExists(projectPath, "pages/about.tsx")).toBe(true); + expect(fileExists(projectPath, "pages/api/hello.ts")).toBe(true); + expect(fileExists(projectPath, "worker/index.ts")).toBe(true); + // Pages Router does NOT have app/ + expect(fileExists(projectPath, "app")).toBe(false); + }); + + it("substitutes {{PROJECT_NAME}} in package.json", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath, projectName: "cool-project" })); + const pkg = readJson(projectPath, "package.json"); + expect(pkg.name).toBe("cool-project"); + }); + + it("substitutes {{WORKER_NAME}} in wrangler.jsonc", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath, projectName: "cool-project" })); + const content = readFile(projectPath, "wrangler.jsonc"); + expect(content).toContain('"name": "cool-project"'); + expect(content).not.toContain("{{WORKER_NAME}}"); + }); + + it("renames _gitignore to .gitignore", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath })); + expect(fileExists(projectPath, ".gitignore")).toBe(true); + expect(fileExists(projectPath, "_gitignore")).toBe(false); + }); + + it("calls git init when git is true", () => { + const projectPath = path.join(tmpDir, "my-app"); + const { exec, calls } = noopExec(); + scaffold(defaultOpts({ projectPath, git: true, _exec: exec })); + const gitCall = calls.find((c) => c.cmd === "git"); + expect(gitCall).toBeDefined(); + expect(gitCall!.args).toEqual(["init"]); + expect(gitCall!.cwd).toBe(projectPath); + }); + + it("calls install command when install is true", () => { + const projectPath = path.join(tmpDir, "my-app"); + const { exec, calls } = noopExec(); + scaffold(defaultOpts({ projectPath, install: true, pm: "bun", _exec: exec })); + const installCall = calls.find((c) => c.cmd === "bun"); + expect(installCall).toBeDefined(); + expect(installCall!.args).toEqual(["install"]); + expect(installCall!.cwd).toBe(projectPath); + }); + + it("skips install when flagged", () => { + const projectPath = path.join(tmpDir, "my-app"); + const { exec, calls } = noopExec(); + scaffold(defaultOpts({ projectPath, install: false, _exec: exec })); + const installCall = calls.find( + (c) => c.cmd === "npm" || c.cmd === "bun" || c.cmd === "pnpm" || c.cmd === "yarn", + ); + expect(installCall).toBeUndefined(); + }); + + it("skips git when flagged", () => { + const projectPath = path.join(tmpDir, "my-app"); + const { exec, calls } = noopExec(); + scaffold(defaultOpts({ projectPath, git: false, _exec: exec })); + const gitCall = calls.find((c) => c.cmd === "git"); + expect(gitCall).toBeUndefined(); + }); + + it("handles nested paths", () => { + const projectPath = path.join(tmpDir, "a", "b", "c", "my-app"); + scaffold(defaultOpts({ projectPath })); + expect(fs.existsSync(projectPath)).toBe(true); + expect(fileExists(projectPath, "package.json")).toBe(true); + expect(fileExists(projectPath, "vite.config.ts")).toBe(true); + }); + + it("handles absolute paths", () => { + const projectPath = path.join(tmpDir, "absolute-test"); + expect(path.isAbsolute(projectPath)).toBe(true); + scaffold(defaultOpts({ projectPath })); + expect(fs.existsSync(projectPath)).toBe(true); + expect(fileExists(projectPath, "package.json")).toBe(true); + }); + + it("sanitizes scoped name for worker", () => { + const projectPath = path.join(tmpDir, "scoped-app"); + scaffold(defaultOpts({ projectPath, projectName: "@org/my-app" })); + const content = readFile(projectPath, "wrangler.jsonc"); + expect(content).toContain('"name": "my-app"'); + expect(content).not.toContain("@org"); + }); + + it("no .tmpl files remain after scaffold", () => { + const projectPath = path.join(tmpDir, "my-app"); + scaffold(defaultOpts({ projectPath })); + const allFiles = walkDir(projectPath); + const tmplFiles = allFiles.filter((f) => f.endsWith(".tmpl")); + expect(tmplFiles).toEqual([]); + }); +}); + +describe("sanitizeWorkerName", () => { + it("strips scope prefix", () => { + expect(sanitizeWorkerName("@org/my-app")).toBe("my-app"); + }); + + it("lowercases", () => { + expect(sanitizeWorkerName("My-App")).toBe("my-app"); + }); + + it("replaces invalid chars with hyphens", () => { + expect(sanitizeWorkerName("my_cool.app")).toBe("my-cool-app"); + }); + + it("collapses consecutive hyphens", () => { + expect(sanitizeWorkerName("my--app")).toBe("my-app"); + }); + + it("strips leading and trailing hyphens", () => { + expect(sanitizeWorkerName("-my-app-")).toBe("my-app"); + }); + + it("handles scoped name with special chars", () => { + expect(sanitizeWorkerName("@my-org/My_Cool.App")).toBe("my-cool-app"); + }); +}); diff --git a/packages/create-vinext-app/tests/templates.test.ts b/packages/create-vinext-app/tests/templates.test.ts new file mode 100644 index 00000000..6e5f0c32 --- /dev/null +++ b/packages/create-vinext-app/tests/templates.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const TEMPLATES_DIR = path.resolve(import.meta.dirname, "../templates"); + +describe("app-router template", () => { + const dir = path.join(TEMPLATES_DIR, "app-router"); + + it("has required files", () => { + const required = [ + "app/layout.tsx", + "app/page.tsx", + "app/api/hello/route.ts", + "vite.config.ts", + "tsconfig.json", + "wrangler.jsonc.tmpl", + "package.json.tmpl", + "_gitignore", + ]; + for (const file of required) { + expect(fs.existsSync(path.join(dir, file)), `missing: ${file}`).toBe(true); + } + }); + + it("package.json.tmpl has RSC deps", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8")); + expect(pkg.dependencies["@vitejs/plugin-rsc"]).toBeDefined(); + expect(pkg.dependencies["react-server-dom-webpack"]).toBeDefined(); + }); + + it("package.json.tmpl has type module", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8")); + expect(pkg.type).toBe("module"); + }); + + it("package.json.tmpl has correct scripts", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8")); + expect(pkg.scripts.dev).toBe("vite dev"); + expect(pkg.scripts.build).toBe("vite build"); + }); + + it("vite.config.ts has cloudflare and RSC env config", () => { + const config = fs.readFileSync(path.join(dir, "vite.config.ts"), "utf-8"); + expect(config).toContain("cloudflare"); + expect(config).toContain("rsc"); + expect(config).toContain("childEnvironments"); + }); + + it("wrangler.jsonc.tmpl has {{WORKER_NAME}} placeholder", () => { + const content = fs.readFileSync(path.join(dir, "wrangler.jsonc.tmpl"), "utf-8"); + expect(content).toContain("{{WORKER_NAME}}"); + }); + + it("_gitignore exists (not .gitignore)", () => { + expect(fs.existsSync(path.join(dir, "_gitignore"))).toBe(true); + expect(fs.existsSync(path.join(dir, ".gitignore"))).toBe(false); + }); + + it("package.json.tmpl has {{PROJECT_NAME}} placeholder", () => { + const content = fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8"); + expect(content).toContain("{{PROJECT_NAME}}"); + }); +}); + +describe("pages-router template", () => { + const dir = path.join(TEMPLATES_DIR, "pages-router"); + + it("has required files", () => { + const required = [ + "pages/index.tsx", + "pages/about.tsx", + "pages/api/hello.ts", + "worker/index.ts", + "vite.config.ts", + "tsconfig.json", + "next-shims.d.ts", + "wrangler.jsonc.tmpl", + "package.json.tmpl", + "_gitignore", + ]; + for (const file of required) { + expect(fs.existsSync(path.join(dir, file)), `missing: ${file}`).toBe(true); + } + }); + + it("package.json.tmpl lacks RSC deps", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8")); + expect(pkg.dependencies["@vitejs/plugin-rsc"]).toBeUndefined(); + expect(pkg.dependencies["react-server-dom-webpack"]).toBeUndefined(); + }); + + it("package.json.tmpl has type module", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8")); + expect(pkg.type).toBe("module"); + }); + + it("package.json.tmpl has correct scripts", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8")); + expect(pkg.scripts.dev).toBe("vite dev"); + expect(pkg.scripts.build).toBe("vite build"); + }); + + it("vite.config.ts has cloudflare plugin without RSC", () => { + const config = fs.readFileSync(path.join(dir, "vite.config.ts"), "utf-8"); + expect(config).toContain("cloudflare"); + expect(config).not.toContain("rsc"); + expect(config).not.toContain("childEnvironments"); + }); + + it("wrangler.jsonc.tmpl has {{WORKER_NAME}} placeholder", () => { + const content = fs.readFileSync(path.join(dir, "wrangler.jsonc.tmpl"), "utf-8"); + expect(content).toContain("{{WORKER_NAME}}"); + }); + + it("_gitignore exists (not .gitignore)", () => { + expect(fs.existsSync(path.join(dir, "_gitignore"))).toBe(true); + expect(fs.existsSync(path.join(dir, ".gitignore"))).toBe(false); + }); + + it("has worker entry for Pages Router", () => { + const worker = fs.readFileSync(path.join(dir, "worker/index.ts"), "utf-8"); + expect(worker).toContain("virtual:vinext-server-entry"); + expect(worker).toContain("fetch"); + }); +}); diff --git a/packages/create-vinext-app/tests/validate.test.ts b/packages/create-vinext-app/tests/validate.test.ts new file mode 100644 index 00000000..c9913077 --- /dev/null +++ b/packages/create-vinext-app/tests/validate.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { validateProjectName, resolveProjectPath, isDirectoryEmpty } from "../src/validate.js"; + +describe("validateProjectName", () => { + it("rejects empty string", () => { + const result = validateProjectName(""); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toMatch(/required/i); + } + }); + + it("accepts valid name 'my-app'", () => { + const result = validateProjectName("my-app"); + expect(result).toEqual({ valid: true }); + }); + + it("rejects names with spaces", () => { + const result = validateProjectName("my app"); + expect(result.valid).toBe(false); + }); + + it("rejects special characters", () => { + const result = validateProjectName("my@app!"); + expect(result.valid).toBe(false); + }); + + it("normalizes uppercase to lowercase", () => { + const result = validateProjectName("MyApp"); + expect(result).toEqual({ valid: true, normalized: "myapp" }); + }); + + it("rejects names starting with a number", () => { + const result = validateProjectName("123app"); + expect(result.valid).toBe(false); + }); + + it("rejects names starting with a hyphen", () => { + const result = validateProjectName("-app"); + expect(result.valid).toBe(false); + }); + + it("rejects reserved npm names", () => { + const result = validateProjectName("node_modules"); + expect(result.valid).toBe(false); + }); + + it("accepts scoped names like '@org/app'", () => { + const result = validateProjectName("@org/app"); + expect(result).toEqual({ valid: true }); + }); + + it("rejects names longer than 214 characters", () => { + const result = validateProjectName("a".repeat(215)); + expect(result.valid).toBe(false); + }); + + it("accepts dot '.' for current working directory", () => { + const result = validateProjectName("."); + expect(result).toEqual({ valid: true, useCwd: true }); + }); +}); + +describe("resolveProjectPath", () => { + it("resolves relative path against cwd", () => { + expect(resolveProjectPath("my-app", "/home/user")).toBe("/home/user/my-app"); + }); + + it("returns absolute path as-is", () => { + expect(resolveProjectPath("/abs/path", "/home")).toBe("/abs/path"); + }); + + it("resolves dot to cwd", () => { + expect(resolveProjectPath(".", "/home/user")).toBe("/home/user"); + }); +}); + +describe("isDirectoryEmpty", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-validate-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns true for an empty directory", () => { + expect(isDirectoryEmpty(tmpDir)).toBe(true); + }); + + it("returns false for a non-empty directory", () => { + fs.writeFileSync(path.join(tmpDir, "index.ts"), ""); + expect(isDirectoryEmpty(tmpDir)).toBe(false); + }); + + it("returns true for directory with only ignorable dotfiles", () => { + fs.mkdirSync(path.join(tmpDir, ".git")); + fs.writeFileSync(path.join(tmpDir, ".DS_Store"), ""); + expect(isDirectoryEmpty(tmpDir)).toBe(true); + }); + + it("returns true for a non-existent directory", () => { + const nonExistent = path.join(tmpDir, "does-not-exist"); + expect(isDirectoryEmpty(nonExistent)).toBe(true); + }); +}); diff --git a/packages/create-vinext-app/tsconfig.json b/packages/create-vinext-app/tsconfig.json new file mode 100644 index 00000000..74835f19 --- /dev/null +++ b/packages/create-vinext-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests", "templates"] +} diff --git a/packages/create-vinext-app/vitest.config.ts b/packages/create-vinext-app/vitest.config.ts new file mode 100644 index 00000000..19384e80 --- /dev/null +++ b/packages/create-vinext-app/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 113905ae..d5d9c58e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,19 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/create-vinext-app: + dependencies: + '@clack/prompts': + specifier: ^0.10.0 + version: 0.10.1 + devDependencies: + typescript: + specifier: ^5.8.2 + version: 5.9.3 + vitest: + specifier: ^3.2.1 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) + packages/vinext: dependencies: '@unpic/react': @@ -830,6 +843,12 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@clack/core@0.4.2': + resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} + + '@clack/prompts@0.10.1': + resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} + '@cloudflare/kumo@1.6.0': resolution: {integrity: sha512-1Sy8kgfHNkze+NEfu/6cNzwOb0hemGm1mUNGU9GVmAnHemLOaXixosslM/o38TbUTEEs48yTRrDy0WFGgSTFWg==} hasBin: true @@ -3985,6 +4004,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4633,6 +4655,17 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@clack/core@0.4.2': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.10.1': + dependencies: + '@clack/core': 0.4.2 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cloudflare/kumo@1.6.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7565,6 +7598,8 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} source-map@0.7.6: {} diff --git a/tsconfig.json b/tsconfig.json index 0f1807e3..554a159f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,13 @@ "inlineSources": true, "outDir": "dist" }, - "exclude": ["node_modules", "dist", "fixtures", "tests/fixtures", "benchmarks", "examples"] + "exclude": [ + "node_modules", + "dist", + "fixtures", + "tests/fixtures", + "benchmarks", + "examples", + "packages/create-vinext-app/templates" + ] } From 4b73bc36c492c37fe7b25d3cc9bceb7f58b34053 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 02:39:24 -0700 Subject: [PATCH 2/9] fix(create-vinext-app): use vinext entry instead of missing worker/index.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. --- .../templates/app-router/wrangler.jsonc.tmpl | 2 +- packages/create-vinext-app/tests/templates.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl b/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl index 034af188..fc096590 100644 --- a/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl +++ b/packages/create-vinext-app/templates/app-router/wrangler.jsonc.tmpl @@ -3,7 +3,7 @@ "name": "{{WORKER_NAME}}", "compatibility_date": "2026-02-12", "compatibility_flags": ["nodejs_compat"], - "main": "./worker/index.ts", + "main": "vinext/server/app-router-entry", "assets": { "not_found_handling": "none", "binding": "ASSETS" diff --git a/packages/create-vinext-app/tests/templates.test.ts b/packages/create-vinext-app/tests/templates.test.ts index 6e5f0c32..e72bc9ef 100644 --- a/packages/create-vinext-app/tests/templates.test.ts +++ b/packages/create-vinext-app/tests/templates.test.ts @@ -61,6 +61,12 @@ describe("app-router template", () => { const content = fs.readFileSync(path.join(dir, "package.json.tmpl"), "utf-8"); expect(content).toContain("{{PROJECT_NAME}}"); }); + + it("wrangler.jsonc.tmpl uses vinext entry (no custom worker)", () => { + const content = fs.readFileSync(path.join(dir, "wrangler.jsonc.tmpl"), "utf-8"); + expect(content).toContain("vinext/server/app-router-entry"); + expect(content).not.toContain("./worker/index.ts"); + }); }); describe("pages-router template", () => { From 9b700ced11dc963389ced01cd4f148f8ba657e6f Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 02:42:54 -0700 Subject: [PATCH 3/9] fix(create-vinext-app): extract basename from absolute paths on Windows 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. --- packages/create-vinext-app/src/index.ts | 10 +++++++++- packages/create-vinext-app/tests/cli.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 9e068016..6f46594e 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -134,6 +134,14 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { expect(mockedScaffold.mock.calls[0][0].projectName).toBe("my-app"); }); + it("extracts basename when given an absolute path", async () => { + await main(["/tmp/some-dir/my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + expect(opts.projectName).toBe("my-app"); + expect(opts.projectPath).toBe("/tmp/some-dir/my-app"); + }); + it("prints success message after scaffolding", async () => { await main(["my-app", "-y", "--skip-install", "--no-git"]); const output = consoleLogs.join("\n"); From 82f4cfdd5b8be6134054c1d1ea051d47d00af3f0 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 02:52:24 -0700 Subject: [PATCH 4/9] fix(vinext): pre-bundle React CJS packages for Cloudflare worker environments 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. --- packages/vinext/src/index.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f9ca5b37..e4681afe 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1314,6 +1314,31 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, + // 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", From 6a54993675db8590fe45987c1f1db3304fc2a383 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 02:56:23 -0700 Subject: [PATCH 5/9] ci(create-vinext-app): add pages-router template to integration test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd94c420..8cfc1ee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,12 +100,13 @@ jobs: exit 1 create-vinext-app: - name: create-vinext-app (${{ matrix.os }}) + name: create-vinext-app (${{ matrix.os }}, ${{ matrix.template }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] + template: [app, pages] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-node-pnpm @@ -117,7 +118,7 @@ jobs: 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" --yes --skip-install + run: node packages/create-vinext-app/dist/index.js "${{ runner.temp }}/cva-test" --template ${{ matrix.template }} --yes --skip-install - name: Install vinext from local tarball working-directory: ${{ runner.temp }}/cva-test From aed831eae9ede8fa1516638aff7874294dad881a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 03:03:12 -0700 Subject: [PATCH 6/9] fix(create-vinext-app): handle "." project name and clean up validation - 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 --- packages/create-vinext-app/package.json | 1 + packages/create-vinext-app/src/index.ts | 6 ++++++ packages/create-vinext-app/src/validate.ts | 7 ++----- packages/create-vinext-app/tests/cli.test.ts | 22 ++++++++++++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/create-vinext-app/package.json b/packages/create-vinext-app/package.json index 904423f7..fc7fd3df 100644 --- a/packages/create-vinext-app/package.json +++ b/packages/create-vinext-app/package.json @@ -22,6 +22,7 @@ "type": "module", "scripts": { "build": "tsc", + "pretest": "tsc", "test": "vitest run", "test:watch": "vitest" }, diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 6f46594e..fda82e46 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -149,6 +149,12 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise ({ @@ -219,6 +222,25 @@ describe("main", () => { expect(consoleErrors.join(" ")).toContain("Invalid project name"); }); + it("'.' uses cwd basename as project name", async () => { + // Create an empty temp dir to act as cwd so isDirectoryEmpty passes + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-dot-test-")); + const originalCwd = process.cwd; + process.cwd = () => tmpDir; + try { + await main([".", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + // projectName should be the basename of the temp dir, not "." + expect(opts.projectName).not.toBe("."); + expect(opts.projectName).toBe(path.basename(tmpDir)); + expect(opts.projectPath).toBe(tmpDir); + } finally { + process.cwd = originalCwd; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("normalizes uppercase project name", async () => { await main(["My-App", "-y", "--skip-install", "--no-git"]); expect(mockedScaffold).toHaveBeenCalledOnce(); From e3d3255220c428bfad17592679c4ee97f52ba019 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 08:58:27 -0700 Subject: [PATCH 7/9] fix(create-vinext-app): address PR review feedback - 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 --- .github/workflows/ci.yml | 6 ++ packages/create-vinext-app/package.json | 2 +- packages/create-vinext-app/src/index.ts | 31 +++++++--- packages/create-vinext-app/src/prompts.ts | 59 +++++++++---------- packages/create-vinext-app/src/scaffold.ts | 3 +- packages/create-vinext-app/src/validate.ts | 10 ++++ .../templates/pages-router/worker/index.ts | 3 + packages/create-vinext-app/tests/cli.test.ts | 21 +++++++ .../create-vinext-app/tests/validate.test.ts | 32 ++++++++++ packages/vinext/src/index.ts | 25 -------- 10 files changed, 126 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cfc1ee4..9a2256c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,12 @@ jobs: - 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 + - name: Scaffold with plain project name + working-directory: ${{ runner.temp }} + run: | + node "${{ github.workspace }}/packages/create-vinext-app/dist/index.js" cva-plain-test --template ${{ matrix.template }} --yes --skip-install + test -f cva-plain-test/package.json + - name: Install vinext from local tarball working-directory: ${{ runner.temp }}/cva-test shell: bash diff --git a/packages/create-vinext-app/package.json b/packages/create-vinext-app/package.json index fc7fd3df..f2ffe68b 100644 --- a/packages/create-vinext-app/package.json +++ b/packages/create-vinext-app/package.json @@ -34,6 +34,6 @@ "vitest": "^3.2.1" }, "engines": { - "node": ">=22" + "node": ">=18" } } diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index fda82e46..9d1e6a87 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import path from "node:path"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { validateProjectName, resolveProjectPath, isDirectoryEmpty } from "./validate.js"; import { detectPackageManager } from "./install.js"; @@ -134,12 +134,19 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { + try { + return realpathSync(path.resolve(p)); + } catch { + return path.resolve(p); + } + }; + const resolvedEntry = resolveReal(entryFile); + const resolvedThis = resolveReal(thisFile); // Handle both .ts (dev) and .js (built) entry - if (resolvedEntry === thisFile || resolvedEntry === thisFile.replace(/\.ts$/, ".js")) { + if (resolvedEntry === resolvedThis || resolvedEntry === resolvedThis.replace(/\.ts$/, ".js")) { main().catch((err) => { console.error(err); process.exit(1); diff --git a/packages/create-vinext-app/src/prompts.ts b/packages/create-vinext-app/src/prompts.ts index a6203574..5337b95d 100644 --- a/packages/create-vinext-app/src/prompts.ts +++ b/packages/create-vinext-app/src/prompts.ts @@ -14,37 +14,34 @@ export async function runPrompts(defaults: { }): Promise { p.intro("create-vinext-app"); - const answers = await p.group( - { - projectName: () => - defaults.projectName - ? Promise.resolve(defaults.projectName) - : p.text({ - message: "Project name:", - placeholder: "my-vinext-app", - validate: (value) => { - if (!value.trim()) return "Project name is required"; - // Basic validation inline — full validation happens after prompts - }, - }), - template: () => - defaults.template - ? Promise.resolve(defaults.template) - : p.select({ - message: "Which router?", - options: [ - { value: "app" as const, label: "App Router", hint: "recommended" }, - { value: "pages" as const, label: "Pages Router" }, - ], - }), - }, - { - onCancel: () => { - p.cancel("Cancelled."); - process.exit(0); - }, - }, - ); + const answers = await p.group({ + projectName: () => + defaults.projectName + ? Promise.resolve(defaults.projectName) + : p.text({ + message: "Project name:", + placeholder: "my-vinext-app", + validate: (value) => { + if (!value.trim()) return "Project name is required"; + // Basic validation inline — full validation happens after prompts + }, + }), + template: () => + defaults.template + ? Promise.resolve(defaults.template) + : p.select({ + message: "Which router?", + options: [ + { value: "app" as const, label: "App Router", hint: "recommended" }, + { value: "pages" as const, label: "Pages Router" }, + ], + }), + }); + + if (p.isCancel(answers)) { + p.cancel("Cancelled."); + return null; + } return answers as PromptAnswers; } diff --git a/packages/create-vinext-app/src/scaffold.ts b/packages/create-vinext-app/src/scaffold.ts index 5d2993ed..36575a61 100644 --- a/packages/create-vinext-app/src/scaffold.ts +++ b/packages/create-vinext-app/src/scaffold.ts @@ -16,7 +16,7 @@ export interface ScaffoldOptions { } function defaultExec(cmd: string, args: string[], opts: { cwd: string }): void { - execFileSync(cmd, args, { cwd: opts.cwd, stdio: "pipe" }); + execFileSync(cmd, args, { cwd: opts.cwd, stdio: "inherit" }); } /** @@ -87,6 +87,7 @@ export function scaffold(options: ScaffoldOptions): void { copyDir(templateDir, projectPath); // Substitute .tmpl variables + // Template uses "vinext": "latest" in package.json. Pin to caret range once vinext reaches 1.0. const workerName = sanitizeWorkerName(projectName); processTmplFiles(projectPath, { "{{PROJECT_NAME}}": projectName, diff --git a/packages/create-vinext-app/src/validate.ts b/packages/create-vinext-app/src/validate.ts index ad589b25..d2fe1a6a 100644 --- a/packages/create-vinext-app/src/validate.ts +++ b/packages/create-vinext-app/src/validate.ts @@ -56,6 +56,16 @@ export function validateProjectName(name: string): ValidationResult { }; } + // Validate scoped package name structure: @scope/name + if (name.startsWith("@")) { + if (!/^@[a-z0-9-]+\/[a-z0-9][a-z0-9._-]*$/i.test(name)) { + return { + valid: false, + message: 'Scoped package name must be in the format "@scope/name"', + }; + } + } + // Determine the "bare" name for start-char validation. // For scoped names like @org/app, validate the part after the slash. const bareName = name.startsWith("@") ? (name.split("/").pop() ?? name) : name; diff --git a/packages/create-vinext-app/templates/pages-router/worker/index.ts b/packages/create-vinext-app/templates/pages-router/worker/index.ts index 5634655a..0f755410 100644 --- a/packages/create-vinext-app/templates/pages-router/worker/index.ts +++ b/packages/create-vinext-app/templates/pages-router/worker/index.ts @@ -1,3 +1,6 @@ +// TODO: Extract to vinext/server/pages-router-entry (like app-router-entry) +// to avoid duplicating this logic in scaffolded projects. + /** * Cloudflare Worker entry point for vinext Pages Router. * diff --git a/packages/create-vinext-app/tests/cli.test.ts b/packages/create-vinext-app/tests/cli.test.ts index a819401d..2e02a935 100644 --- a/packages/create-vinext-app/tests/cli.test.ts +++ b/packages/create-vinext-app/tests/cli.test.ts @@ -255,6 +255,27 @@ describe("main", () => { expect(opts.projectPath).toBe("/tmp/some-dir/my-app"); }); + it("extracts basename when given a relative path with ./", async () => { + await main(["./my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + expect(opts.projectName).toBe("my-app"); + }); + + it("extracts basename when given a relative path with ../", async () => { + await main(["../my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + expect(opts.projectName).toBe("my-app"); + }); + + it("extracts basename from nested relative path", async () => { + await main(["./nested/deep/my-app", "-y", "--skip-install", "--no-git"]); + expect(mockedScaffold).toHaveBeenCalledOnce(); + const opts = mockedScaffold.mock.calls[0][0]; + expect(opts.projectName).toBe("my-app"); + }); + it("prints success message after scaffolding", async () => { await main(["my-app", "-y", "--skip-install", "--no-git"]); const output = consoleLogs.join("\n"); diff --git a/packages/create-vinext-app/tests/validate.test.ts b/packages/create-vinext-app/tests/validate.test.ts index c9913077..d30e2d6a 100644 --- a/packages/create-vinext-app/tests/validate.test.ts +++ b/packages/create-vinext-app/tests/validate.test.ts @@ -53,6 +53,38 @@ describe("validateProjectName", () => { expect(result).toEqual({ valid: true }); }); + it("rejects malformed scoped name '@org/'", () => { + const result = validateProjectName("@org/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toMatch(/scoped/i); + } + }); + + it("rejects malformed scoped name '@/name'", () => { + const result = validateProjectName("@/name"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toMatch(/scoped/i); + } + }); + + it("rejects bare '@' as scoped name", () => { + const result = validateProjectName("@"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toMatch(/scoped/i); + } + }); + + it("rejects double-scoped '@org/foo/bar'", () => { + const result = validateProjectName("@org/foo/bar"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toMatch(/scoped/i); + } + }); + it("rejects names longer than 214 characters", () => { const result = validateProjectName("a".repeat(215)); expect(result.valid).toBe(false); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index e4681afe..f9ca5b37 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1314,31 +1314,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, - // 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", From 12e6bbfb4a110fbeb454abda2f4539b96de780e6 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 09:16:03 -0700 Subject: [PATCH 8/9] fix(vinext): restore React CJS pre-bundling for Cloudflare worker environments 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. --- packages/vinext/src/index.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f9ca5b37..f434d0a9 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1314,6 +1314,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, + configEnvironment(_name, env) { + if (!hasCloudflarePlugin) return; + 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", From 2a0664ca8cf6cffd9be24f0447f2ec8b54673748 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 09:20:13 -0700 Subject: [PATCH 9/9] style(vinext): apply oxfmt formatting --- packages/vinext/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f434d0a9..348d6972 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1329,9 +1329,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { env.optimizeDeps ??= {}; env.optimizeDeps.include = [ ...(env.optimizeDeps.include ?? []), - ...reactDeps.filter( - (d) => !(env.optimizeDeps!.include ?? []).includes(d), - ), + ...reactDeps.filter((d) => !(env.optimizeDeps!.include ?? []).includes(d)), ]; },