feat: add create-vinext-app CLI for scaffolding new projects#1032
feat: add create-vinext-app CLI for scaffolding new projects#1032Divkix wants to merge 22 commits intocloudflare:mainfrom
Conversation
…d scaffold wiring
…es Router templates
commit: |
Remove the fragile 'vp add -g' global install with tgz glob pattern that fails with 'Not a directory (os error 20)'. Run the built CLI directly via node instead of requiring a global install. Fix formatting across template files and add @ts-nocheck to template files that reference modules unavailable in the monorepo.
Use 'vp pack' instead of 'vp run build' to ensure the package-local build script runs instead of the root workspace build. Fix scaffold step working-directory so 'node dist/index.js' resolves correctly. Add create-vinext-app workspace config to knip with proper entry points, template file ignores, and tsx binary ignore. Re-export unused types from index.ts so knip recognizes them as consumed.
vp pack outputs .mjs files but package.json bin and main fields pointed to dist/index.js which doesn't exist. Update both to dist/index.mjs and fix the CI scaffold step to match.
…xt-app Remove auto-detected entry and redundant ignoreFiles/ignoreWorkspaces entries. Add vitest to per-workspace ignoreDependencies since it is imported through vite-plus/test and invisible to knip.
|
/bigbonk review this PR please |
There was a problem hiding this comment.
Review: create-vinext-app CLI
The overall structure is solid — good separation of concerns across install.ts, validate.ts, catalog.ts, scaffold.ts, prompts.ts, and index.ts. Tests are well-organized. A few issues need addressing before merge:
Issues
-
Dead code:
worker/index.tsin app-router template is unused. The app-routerwrangler.jsonc.tmplsets"main": "vinext/server/app-router-entry", meaning wrangler never loadsworker/index.ts. Either remove the file, or change wrangler.jsonc to point at"main": "./worker/index.ts"(like the existingexamples/app-router-cloudflare/does). The latter is better since it gives users a visible entry point they can extend (e.g., image optimization). -
globals.cssis never imported. The app-router template shipsapp/globals.cssbutapp/layout.tsxnever imports it, so it has zero effect. Either add the import or remove the file. -
Duplicated install command logic.
install.tsexportsbuildInstallCommand()andscaffold.tshas a privategetInstallCommand()that does the same thing with a slightly different return shape.scaffold.tsnever imports frominstall.ts. Use one function. -
catalog.tsversion resolution is fragile at publish time. The comment says versions are baked "at build time" butgetTemplateVersions()actually runs at scaffold time — it readspnpm-workspace.yamlrelative toimport.meta.url. When the published package runs from a user's global npm cache, the monorepo file won't exist, sogetTemplateVersions()returns{}. This means all template variables like{{REACT_VERSION}}will be left as-is in the scaffolded output. TheprocessTmplFilesfunction only substitutes keys present in thevarsmap — absent keys remain literal{{...}}strings inpackage.json. This is a critical bug for published usage. -
Pages Router worker template is 246 lines of complex production server logic. This is a full reimplementation of middleware, redirects, rewrites, basePath handling, etc. It will immediately fall out of sync with
packages/vinext/src/server/prod-server.ts. When bugs get fixed in the main server, they won't be reflected here. This should import from vinext instead of duplicating logic. -
The plan/spec docs shouldn't be committed.
docs/superpowers/plans/anddocs/superpowers/specs/add 2,575 lines of AI-generated implementation plan that will never be maintained. These are implementation artifacts, not user-facing docs.
Minor issues
-
package.jsonlists"vitest": "catalog:"in devDependencies — per AGENTS.md, vitest should not be installed directly when using vite-plus. -
The
parseArgstests monkey-patchprocess.exitwithout type safety (process.exit = (code?: number) => { throw ... }). Consider extracting the error-and-exit logic behind a seam instead. -
layout.tsxtemplate has{{PROJECT_NAME}}in the metadata title — this works but means a name like@org/my-appbecomes the page title, which is awkward for scoped packages. -
Missing
assetsconfig in wrangler templates — the existing examples include"assets": { "not_found_handling": "none" }which is important for static file serving to work correctly.
| "name": "{{WORKER_NAME}}", | ||
| "compatibility_date": "2026-05-02", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| "main": "vinext/server/app-router-entry", |
There was a problem hiding this comment.
This points at vinext/server/app-router-entry but the template also ships worker/index.ts which is never referenced. The existing examples/app-router-cloudflare/ uses "main": "./worker/index.ts" instead.
Either:
- Remove
templates/app-router/worker/index.tsentirely (simplest, andvinext/server/app-router-entryis the documented simple path), or - Change this to
"./worker/index.ts"to match the established pattern and give users an extensible entry point
| "main": "vinext/server/app-router-entry", | |
| "main": "./worker/index.ts", |
| @@ -0,0 +1,14 @@ | |||
| import type { ReactNode } from "react"; | |||
There was a problem hiding this comment.
app/globals.css exists in the template but is never imported here — it won't be loaded.
| import type { ReactNode } from "react"; | |
| import type { ReactNode } from "react"; | |
| import "./globals.css"; |
| const installCmd = getInstallCommand(options.pm); | ||
| exec(installCmd.cmd, installCmd.args, { cwd: projectPath }); | ||
| } | ||
| } | ||
|
|
||
| function getInstallCommand(pm: "npm" | "pnpm" | "yarn" | "bun"): { cmd: string; args: string[] } { |
There was a problem hiding this comment.
This duplicates buildInstallCommand() from install.ts with a slightly different return shape ({ cmd, args } vs string[]). scaffold.ts doesn't import install.ts at all. Use buildInstallCommand from install.ts instead:
| const installCmd = getInstallCommand(options.pm); | |
| exec(installCmd.cmd, installCmd.args, { cwd: projectPath }); | |
| } | |
| } | |
| function getInstallCommand(pm: "npm" | "pnpm" | "yarn" | "bun"): { cmd: string; args: string[] } { | |
| const [cmd, ...args] = buildInstallCommand(options.pm); | |
| exec(cmd, args, { cwd: projectPath }); | |
| } | |
| } |
And add import { buildInstallCommand } from "./install.js"; at the top, removing the private getInstallCommand function.
| try { | ||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| // Walk up from dist/ (packages/create-vinext-app/dist/) to monorepo root | ||
| const workspaceYaml = resolve(__dirname, "..", "..", "..", "pnpm-workspace.yaml"); |
There was a problem hiding this comment.
The design doc says versions are "baked at build time" but this runs at scaffold time. When the published package is installed globally by a user, pnpm-workspace.yaml won't exist 3 directories up from the installed dist/ folder, so getTemplateVersions() returns {}.
With empty version vars, processTmplFiles will leave {{REACT_VERSION}}, {{VITE_VERSION}}, etc. as literal strings in the scaffolded package.json. The user gets a broken project.
This needs to either:
- Actually bake versions at build time (e.g., a build script that writes a
versions.jsonintodist/), or - Have
scaffold.tscheck for un-substituted{{...}}vars and error, or - Have fallback versions hardcoded in
catalog.tswhen the workspace file isn't found
| @@ -0,0 +1,246 @@ | |||
| // @ts-nocheck -- template file, modules resolved in scaffolded project | |||
There was a problem hiding this comment.
This is 246 lines duplicating the production server logic from packages/vinext/src/server/prod-server.ts — middleware dispatch, redirect/rewrite matching, basePath handling, trailing slash normalization, config header merging, etc.
Per AGENTS.md: "When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as 'follow-ups' — fix them in the same PR."
This template will immediately diverge from the main server. Every bug fix in prod-server.ts would need to be manually ported here. The app-router template avoids this by delegating to vinext/server/app-router-entry. The pages-router template should do something similar — either:
- Point wrangler.jsonc
mainat the vinext pages router entry directly, or - Import and delegate to
vinext/server/prod-serveror similar, or - At minimum, add a comment explaining why this duplication is intentional and how to keep it in sync
| "preview": "vp preview" | ||
| }, | ||
| "dependencies": { | ||
| "vinext": "^{{VINEXT_VERSION}}", |
There was a problem hiding this comment.
Same double-caret issue as app-router template — index.ts already adds ^ prefix to the version.
| "vinext": "^{{VINEXT_VERSION}}", | |
| "vinext": "{{VINEXT_VERSION}}", |
| @@ -0,0 +1,2262 @@ | |||
| # create-vinext-app Implementation Plan | |||
There was a problem hiding this comment.
This 2,262-line implementation plan is an AI scaffolding artifact, not documentation. It won't be maintained and adds noise to the repo. Remove this file from the PR.
| @@ -0,0 +1,313 @@ | |||
| # Spec: create-vinext-app Scaffolding CLI | |||
There was a problem hiding this comment.
Same as the plan doc — this is an implementation artifact, not user-facing documentation. Remove from the PR.
| install: !opts.skipInstall, | ||
| git: !opts.noGit, | ||
| pm, | ||
| vinextVersion: `^${VERSION}`, |
There was a problem hiding this comment.
This adds a ^ prefix to the version, but the template package.json.tmpl already has "vinext": "^{{VINEXT_VERSION}}". The result is "vinext": "^^0.0.5". Fix this here or in the template (see template comments).
Also: the catalog.ts getTemplateVersions() already populates {{VINEXT_VERSION}} with the raw version from package.json. So there are two competing sources setting {{VINEXT_VERSION}} — catalog.ts (line 50) and the scaffold() call here which passes vinextVersion separately. The scaffold.ts processTmplFiles call puts {{VINEXT_VERSION}}: vinextVersion after the spread of versionVars, so it overwrites the catalog version. This works but is confusing. Consider having one canonical source for the vinext version.
| test("exits on invalid template", () => { | ||
| const prevExit = process.exit; | ||
| process.exit = (code?: number) => { |
There was a problem hiding this comment.
Monkey-patching process.exit is fragile (TypeScript complains, teardown issues on test failure). Consider refactoring parseArgs to throw or return an error result instead of calling process.exit directly — this would make it both more testable and safer to use as a library.
|
Review posted. Here's a summary of the key findings: Blocking issues
Minor issues
|
…dup, template fixes - Fix double-caret version bug (remove ^ from code, keep in template) - Bake catalog versions at build time via scripts/bake-versions.mjs - Create vinext/server/pages-router-entry, simplify pages-router worker - Point wrangler.jsonc at ./worker/index.ts, add assets config - Import globals.css in app layout template - Deduplicate install command logic - Remove vitest as direct dependency - Use DISPLAY_NAME for scoped package metadata titles - Refactor parseArgs to return errors instead of calling process.exit - Update tests for new worker delegation and error handling
|
Heads up! |
|
Codex Review: Something went wrong. Try again later by commenting “@codex review”. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@codex review please |
|
let's see if it works this time, great PR bro! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 126abadcd6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| projectNameStr.includes(path.sep) || | ||
| projectNameStr.includes("/"); |
There was a problem hiding this comment.
Preserve scoped names instead of treating them as paths
The path detection treats any project name containing / as a filesystem path, so a valid scoped package like @org/my-app is rewritten into a nested directory path and loses its scope in package.json (it becomes just my-app). This conflicts with the scoped-name validation logic and breaks expected CLI behavior for users who intentionally provide npm-scoped names.
Useful? React with 👍 / 👎.
|
|
||
| export default { | ||
| async fetch(request: Request): Promise<Response> { | ||
| return handler.fetch(request); |
There was a problem hiding this comment.
Forward env and ctx to the app-router worker handler
The scaffolded App Router worker calls handler.fetch(request) without passing Cloudflare env and ctx. That prevents the entry from accessing env.ASSETS for static-file resolution and from using request execution context for background work, so scaffolded apps can return the internal static-file signal response instead of actual public/ assets and lose waitUntil-backed behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@james-elicx GPT's ability to catch bugs are so superior...
anomalyco/opencode#24431 is fixed so I think we can update to new opencode now without getting ProviderInIt.
anomalyco/opencode#24432 is just getting assigned tho, I'll let u know when they fixed it. Do u want to try 5.5 one more time?
There was a problem hiding this comment.
Alternatively, Fred's new agents framework look like there's a lot more room for us to customise
| const middlewareHeaders: Record<string, string | string[]> = {}; | ||
| let middlewareRewriteStatus: number | undefined; | ||
| if (typeof runMiddleware === "function") { | ||
| const result = await runMiddleware(request); |
There was a problem hiding this comment.
Pass a basePath-stripped request into middleware
After stripping pathname for routing, middleware is still executed with the original request.url. In basePath deployments, middleware matcher evaluation runs against the unstripped path (for example /docs/about instead of /about), causing matcher-based middleware to be skipped in Workers while the Node production path strips before middleware.
Useful? React with 👍 / 👎.
…eware - Exclude scoped npm package names (@org/pkg) from path detection in create-vinext-app, preserving the scope prefix in package.json. - Forward env and ctx to handler.fetch() in both app-router and pages-router worker templates so static file resolution and ctx.waitUntil() work in scaffolded apps. - Strip basePath from the request URL before passing it to middleware in pages-router-entry.ts. Without this, middleware matchers evaluate against basePath-prefixed paths (e.g. /docs/about instead of /about) and silently skip middleware. deploy.ts and prod-server.ts already handle this correctly.
Summary
This PR introduces
create-vinext-app, a CLI tool for scaffolding new vinext projects targeting Cloudflare Workers.What's Included
Core CLI Features
--yes,--template <app|pages>,--skip-install,--no-gitpnpm-workspace.yamlto inject real dependency versionsTemplates
Testing & CI
create-vinext-apptests both templatesUsage
Files Added
Checklist
Closes the gap for users wanting to start fresh vinext projects without migrating from Next.js.
Ref: #407