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..9a2256c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,63 @@ jobs: kill "$SERVER_PID" 2>/dev/null || true exit 1 + create-vinext-app: + 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 + - 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" --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 + 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..f2ffe68b --- /dev/null +++ b/packages/create-vinext-app/package.json @@ -0,0 +1,39 @@ +{ + "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", + "pretest": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@clack/prompts": "^0.10.0" + }, + "devDependencies": { + "typescript": "^5.8.2", + "vitest": "^3.2.1" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts new file mode 100644 index 00000000..9d1e6a87 --- /dev/null +++ b/packages/create-vinext-app/src/index.ts @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { readFileSync, realpathSync } 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; + } + + // When a path is provided (absolute, or relative like ./my-app or ../my-app), + // use the resolved path for the directory and the basename as the project name. + let targetDir: string | undefined; + const looksLikePath = + path.isAbsolute(projectName) || + projectName.startsWith("./") || + projectName.startsWith("../") || + projectName.startsWith(".\\") || + projectName.startsWith("..\\"); + + if (looksLikePath) { + targetDir = path.resolve(projectName); + projectName = path.basename(targetDir); + } + + // Validate project name + const validation = validateProjectName(projectName); + if (!validation.valid) { + console.error(`Invalid project name: ${validation.message}`); + process.exit(1); + } + + // When "." is passed, scaffold into cwd and derive the name from the directory + if (validation.valid && "useCwd" in validation && validation.useCwd) { + targetDir = process.cwd(); + projectName = path.basename(process.cwd()); + } + + // Use normalized name if available + const normalizedName = + validation.valid && validation.normalized ? validation.normalized : projectName; + + // Resolve project path + const projectPath = targetDir ?? 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 resolveReal = (p: string) => { + 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 === resolvedThis || resolvedEntry === resolvedThis.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..5337b95d --- /dev/null +++ b/packages/create-vinext-app/src/prompts.ts @@ -0,0 +1,47 @@ +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" }, + ], + }), + }); + + 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 new file mode 100644 index 00000000..36575a61 --- /dev/null +++ b/packages/create-vinext-app/src/scaffold.ts @@ -0,0 +1,114 @@ +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: "inherit" }); +} + +/** + * 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 + // 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, + "{{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..d2fe1a6a --- /dev/null +++ b/packages/create-vinext-app/src/validate.ts @@ -0,0 +1,120 @@ +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", + }; + } + + if (!/^[a-zA-Z0-9\-._@/]+$/.test(name)) { + return { + valid: false, + message: + "Project name can only contain letters, numbers, hyphens, dots, underscores, and scoped package prefixes (@org/)", + }; + } + + // 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; + + 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..fc096590 --- /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": "vinext/server/app-router-entry", + "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..0f755410 --- /dev/null +++ b/packages/create-vinext-app/templates/pages-router/worker/index.ts @@ -0,0 +1,245 @@ +// 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. + * + * 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..2e02a935 --- /dev/null +++ b/packages/create-vinext-app/tests/cli.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +// 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("'.' 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(); + 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("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"); + 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..e72bc9ef --- /dev/null +++ b/packages/create-vinext-app/tests/templates.test.ts @@ -0,0 +1,132 @@ +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}}"); + }); + + 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", () => { + 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..d30e2d6a --- /dev/null +++ b/packages/create-vinext-app/tests/validate.test.ts @@ -0,0 +1,143 @@ +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 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); + }); + + 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/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f9ca5b37..348d6972 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1314,6 +1314,25 @@ 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", 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" + ] }