Skip to content

refactor(cli): spec-driven command framework (dev/build/start/init migrated)#1874

Open
james-elicx wants to merge 4 commits into
mainfrom
refactor/cli-command-framework
Open

refactor(cli): spec-driven command framework (dev/build/start/init migrated)#1874
james-elicx wants to merge 4 commits into
mainfrom
refactor/cli-command-framework

Conversation

@james-elicx

@james-elicx james-elicx commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

Introduces a zero-dependency, spec-driven CLI command framework under packages/vinext/src/cli/ where a single CommandSpec is the source of truth for both argument parsing and help text. This eliminates the long-standing drift between the hand-rolled parser (cli-args.ts), the node:util parser (parseDeployArgs), and the separate inline printHelp templates.

This is the first slice of a stacked refactor (per AGENTS.md "prefer small stacked PRs"). It builds the framework and migrates the dev, build, start, and init commands end-to-end (dev in the first commit as the proof, the other three after review feedback). The remaining commands (lint, check, typegen, deploy) keep using the existing parseArgs/printHelp path and are untouched.

Why

dev's flags were parsed in cli-args.ts but documented in a separate template string in printHelp("dev"). Adding a flag meant editing two disconnected places. The drift was already real (e.g. --experimental-https is parsed but documented nowhere; global flags only appear under build's help).

What's in this slice

File Role
cli/types.ts CommandSpec / ArgSpec + InferValues type inference
cli/parse.ts node:util parseArgs wrapper; typed coercion (port/int/positiveInt/string/bool) + the value-validation guards ported from cli-args.ts (CliUsageError); unknown flags are a hard error by default with a per-command passthroughUnknown opt-in
cli/help.ts Help rendered from the same spec (TTY-aware styling, hidden flags omitted)
cli/command.ts defineCommand + runCommand (handles --help, prints clean usage errors with a help hint)
cli/runtime.ts Extracted shared Vite helpers (loadVite, getViteVersion, buildViteConfig, applyViteConfigCompatibility) so commands no longer depend on cli.ts
cli/commands/{dev,build,start,init}.ts The migrated command specs + run bodies
tests/cli-framework.test.ts Parse + help-generation coverage (49 tests)

cli.ts now routes dev/build/start/init through runCommand(...); the old command bodies and their hand-written help templates are removed.

Design notes

  • Built-in only, matching AGENTS.md's "prefer Node built-ins" rule — wraps node:util parseArgs (already used by deploy). No new dependencies.
  • Strict by default: unknown flags error (Unknown option "--foo". + a help hint, exit 1) instead of being silently ignored, per review feedback. Commands that must tolerate arbitrary flags can opt in via passthroughUnknown. Known next CLI flags that vinext doesn't need (--turbopack on dev/build, --experimental-https on dev) are declared as hidden no-ops so drop-in usage keeps working.
  • Validation parity: the well-tested guards from cli-args.ts are preserved — missing values, empty values, "looks like another flag", strict integer/port/positive-int coercion — with messages that reference the exact form the user typed (-p vs --port).

Verification

  • vp test run tests/cli-framework.test.ts tests/cli-args.test.ts101 passed (legacy cli-args suite still green; cli-args.ts intentionally untouched this slice).
  • vp check + knip → format + lint + types + unused-exports clean.
  • vp run vinext#build → builds successfully.
  • Manual smoke test against the built dist/cli.js:
    • vinext dev --help / -h, vinext build --help, vinext start --help, vinext init --help → generated help (description + Examples; hidden compat flags omitted).
    • vinext --help (root) and legacy-path commands (lint, check, typegen, deploy) → unchanged.
    • Error paths: dev -p abc, dev --port (missing), dev --port 70000 (range), dev --port --hostname x (looks-like-flag), build --bogus (unknown option) → clean message + Run \vinext --help`` hint + exit 1.
    • vinext dev -p 31847 in tests/fixtures/app-basic → boots, prints banner + Local URL.

Next steps (follow-up PRs)

  1. Migrate the remaining commands (lint, check, typegen, deploy) onto the framework, then move root help/version/dispatch into a runCli(registry) and delete cli-args.ts + the printHelp templates.
  2. Add interactive prompting (@clack/prompts) to init/deploy via a TTY-guarded cli/prompt.ts with --yes/non-interactive fallbacks. (Adding that dependency will require a maintainer to run the install, since the lockfile is frozen.)

Introduce a zero-dependency CLI command framework under src/cli/ where a
single CommandSpec is the source of truth for both argument parsing and
help text, eliminating the drift between the hand-rolled parser and the
separate inline help templates.

Migrates the `dev` command onto the framework as the first slice; all
other commands keep using the existing parseArgs/printHelp path for now.

- cli/types.ts: CommandSpec/ArgSpec definitions + InferValues typing
- cli/parse.ts: node:util parseArgs wrapper with typed coercion and the
  value-validation guards ported from cli-args.ts (CliUsageError)
- cli/help.ts: help rendered from the same spec (TTY-aware styling)
- cli/command.ts: defineCommand + runCommand (handles --help, prints
  clean usage errors with a help hint)
- cli/runtime.ts: extracted shared Vite helpers (loadVite, getViteVersion,
  buildViteConfig, applyViteConfigCompatibility) so commands no longer
  depend on cli.ts
- cli/commands/dev.ts: dev command spec + run, with heavy runtime imports
  lazy-loaded so the module stays cheap to import and unit test
- tests/cli-framework.test.ts: parse + help-generation coverage
@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1874
npm i https://pkg.pr.new/vinext@1874

commit: 0664f8c

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

Comment on lines +30 to +33
turbopack: {
type: "boolean",
description: "Accepted for compatibility (no-op, Vite is always used)",
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can we add an option to hide a flag from the help menu so that we can hide this turbopack one

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in a5d3a52. Added a hidden?: boolean option to ArgSpec; --turbopack is now hidden: true (and I added a hidden --experimental-https no-op too). Hidden flags are still parsed/accepted but omitted from --help.

* given an empty value, is followed by something that looks like another
* flag, or fails type coercion (e.g. a non-integer `--port`).
*/
export function parseCommand<A extends Record<string, ArgSpec>>(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can we error for unknown flags

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in a5d3a52. parseCommand now errors on undeclared flags by default (Unknown option "--foo". + a Run \vinext --help`hint, exit 1). For commands that must tolerate arbitrary pass-through flags there is a per-commandpassthroughUnknown?: booleanopt-in (off by default). To keepnext devdrop-in usage working under the stricter policy, I declared the legacy--turbopackand--experimental-httpsflags ondev` as hidden no-ops.

Comment thread packages/vinext/src/cli/commands/dev.ts Outdated
Comment on lines +43 to +50
// Lazy-load the heavy runtime helpers (which pull in the full vinext Vite
// plugin) so importing this command module — e.g. for unit-testing its
// spec and help output — stays cheap and side-effect-free.
const { applyViteConfigCompatibility, buildViteConfig, getViteVersion, loadVite } =
await import("../runtime.js");
const { loadDotenv } = await import("../../config/dotenv.js");
const { formatAlreadyRunningError, tryAcquireLockfile } =
await import("../../server/dev-lockfile.js");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

kinda pointless, we can hoist these up

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in a5d3a52 — hoisted back to static module-level imports.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

/bigbonk review — spec-driven CLI framework (dev slice)

Reviewed the new cli/ framework and the dev migration against the legacy cli-args.ts for parity. The framework is clean, well-documented, and the parse/help single-source-of-truth design is solid. Tests are thorough (86 green incl. legacy). Validation messages match the old parser exactly (-p vs --port spelling, Number()-based coercion, missing/empty/looks-like-a-flag guards), including --port= empty inline values and trailing-token consumption.

Found no correctness regressions for the dev command itself. A few latent issues for the framework that's about to grow as more commands migrate:

1. boolean + multiple silently collapses to the default (latent)

parse.ts:161-164 — the boolean branch runs before the multiple branch and checks raw === true. If a future spec declares a boolean with multiple: true, node:util returns an array ([true, true]), so raw === true is false and the flag resolves to arg.default ?? false — i.e. a repeated boolean flag reads as not set. No current command hits this (dev's only boolean isn't multiple), but since this is foundation code for the stacked migration, either reject boolean + multiple in defineCommand/types or handle the array case (raw === true || (Array.isArray(raw) && raw.length > 0)).

2. boolean default: true can never be turned off (design gap)

parse.ts:162 — a boolean with default: true resolves to true whether absent or present, with no negation (--no-foo) path. The ArgSpec.default doc says "for boolean args the default is always false," and InferValues types booleans as always-present, but nothing enforces default !== true for booleans. Consider constraining the type so a boolean can't declare a truthy default, to avoid a future footgun.

3. Unknown-flag policy is global, not per-command

parse.ts:126 (strict: false) drops all unknown flags for every command. The PR notes this is deliberate for next drop-in friendliness, and a reviewer already flagged wanting errors for unknown flags. As the framework absorbs commands like deploy/init where a typo'd flag silently doing nothing is worse than a hard error, consider making leniency a per-spec opt-in (e.g. passthroughUnknown?: boolean) rather than a hardcoded global. Not blocking for the dev slice.

Nits

  • help.ts:25int/positiveInt fall through to the <n> placeholder; fine, but a valueHint is worth setting on those flags when they land so help reads better than --foo <n>.
  • Parity is preserved for dev, but note --experimental-https (parsed by the legacy cli-args.ts) is now only an ignored passthrough flag under the new dev path. That matches the PR's stated intent (it was undocumented before), just calling it out so it's a conscious drop rather than an accidental one.

None of these block the dev slice. Items 1–2 are worth fixing in this PR since they're in the framework core that the rest of the stack will build on.

@ask-bonk

ask-bonk Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1874. Summary of findings:

Review summary

The spec-driven CLI framework is clean and well-tested. I verified parity with the legacy cli-args.ts parser (validation messages, Number() coercion, missing/empty/looks-like-a-flag guards, --port= empty inline values) and confirmed no correctness regression for the dev command. All 86 tests pass and vp check is clean.

I flagged 3 latent framework issues (since this is foundation code for the stacked migration) plus nits:

  1. boolean + multiple collapses to default (parse.ts:161-164) — the boolean branch runs before the multiple branch and checks raw === true, but node:util returns an array for repeated booleans, so a repeated boolean flag silently reads as unset. No current command hits this, but it's a trap as the framework grows.

  2. boolean default: true can't be turned off (parse.ts:162) — no negation path and the type doesn't prevent a truthy boolean default despite the doc saying it's always false.

  3. Unknown-flag leniency is global (parse.ts:126, strict: false) — a reviewer already asked about erroring on unknown flags; suggested making it per-spec opt-in rather than hardcoded, especially for deploy/init where typo'd flags silently no-op.

Nits: int/positiveInt help placeholder defaults to <n>; and --experimental-https is now an ignored passthrough under the new dev path (matches stated intent, just called out as a conscious drop).

None block the dev slice; items 1–2 are worth fixing in this PR since they're in the core.

github run

Review feedback from #1874:

- Error on unknown flags by default instead of silently ignoring them, with a
  per-command `passthroughUnknown` opt-in for cases that need leniency.
- Add a `hidden` flag option; hide the `--turbopack` no-op on `dev` and add a
  hidden `--experimental-https` no-op so `next dev` flags don't trip the new
  unknown-flag error.
- Model ArgSpec as a discriminated union so the compiler rejects boolean+multiple
  and a truthy boolean default (both latent footguns flagged in review).
- Hoist dev's lazy dynamic imports back to static module-level imports.

Command migrations:

- Migrate `build`, `start`, and `init` onto the framework (spec-driven parsing
  and help). Move createBuildLogger/hasPagesDir/loadBuildEmptyOutDir into the
  build command module and drop their printHelp templates + legacy parseArgs use.

Tests: 100 total (48 framework + 52 legacy) covering unknown-flag errors,
passthrough, hidden flags, dev compat flags, and build/start/init parse+help.
@james-elicx

Copy link
Copy Markdown
Member Author

Review addressed + build/start/init migrated (a5d3a52)

Inline comments (@james-elicx)

  • Hide a flag from help → added hidden?: boolean to ArgSpec; --turbopack is now hidden: true.
  • Error for unknown flags → unknown flags now error by default (Unknown option "--foo".), with a per-command passthroughUnknown opt-in.
  • Hoist the dynamic importsdev's lazy imports are back to static module-level imports.

/bigbonk review items

  1. boolean + multiple collapse (latent) — fixed at the type level: ArgSpec is now a discriminated union (BooleanArgSpec | ValueArgSpec) where multiple only exists on value specs, so boolean + multiple is a compile error. Added a runtime defensive check too (a repeated boolean still resolves to true).
  2. boolean default: true footgun — fixed at the type level: BooleanArgSpec.default?: false, so a truthy boolean default is not representable.
  3. Unknown-flag policy was global — now strict by default with a per-command passthroughUnknown?: boolean opt-in, exactly as suggested (also resolves the inline comment above).

Nits:

  • valueHint on integer flags — --prerender-concurrency now sets valueHint: "count" → renders --prerender-concurrency <count>.
  • --experimental-https — rather than relying on silent passthrough, it's now a declared hidden no-op on dev, so it's explicitly accepted and doesn't trip the new unknown-flag error.

Command migrations

  • Migrated build, start, and init onto the framework (spec-driven parse + help).
  • Moved createBuildLogger / hasPagesDir / loadBuildEmptyOutDir into cli/commands/build.ts; dropped their printHelp templates and legacy parseArgs usage.
  • cli.ts shrank further; only deploy/check/typegen/lint remain on the legacy path (next slices).

Verification

  • vp check on all changed files → format/lint/types clean.
  • vp test run tests/cli-framework.test.ts tests/cli-args.test.ts100 passed (48 framework incl. unknown-flag/hidden/passthrough/compat-flag coverage + 52 legacy).
  • Built dist/cli.js and smoke-tested build/start/init --help, unknown-flag errors, and hidden-flag omission.
  • Ran a real vinext build in tests/fixtures/pages-basic → full build report + exit 0, no fixture mutation.

Remaining for follow-ups: migrate deploy/check/typegen/lint, then move root help/version/dispatch into runCli(registry) and delete cli-args.ts + printHelp; wire @clack/prompts into init/deploy.

@ask-bonk

ask-bonk Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

… `next build --turbopack` compat

- types.ts: ArgType, BooleanArgSpec, ValueArgSpec, PositionalSpec,
  ExampleSpec, and CommandContext are only referenced within the module,
  so stop exporting them (knip failed CI on the unused exports). ArgSpec,
  CommandSpec, and InferValues remain the public surface.
- build: declare --turbopack as a hidden no-op (parity with dev). The
  legacy parser accepted it on every command and `next build --turbopack`
  is a real Next.js flag, so erroring on it would break drop-in usage.
- tests: cover the hidden build flag; refresh the stale header comment.
@james-elicx james-elicx changed the title refactor(cli): spec-driven command framework (dev migrated) refactor(cli): spec-driven command framework (dev/build/start/init migrated) Jun 10, 2026
@james-elicx

Copy link
Copy Markdown
Member Author

CI fixed + bonk review verified at HEAD (fa86678)

Check job failure

  • knip — 6 unused exported types in cli/types.ts (ArgType, BooleanArgSpec, ValueArgSpec, PositionalSpec, ExampleSpec, CommandContext): these are only referenced within the module, so they're no longer exported. ArgSpec, CommandSpec, and InferValues stay exported (consumed by parse/help/command and the tests). The two knip configuration hints (sass/agent in ignoreDependencies) are pre-existing on main and don't affect the exit code.

Bonk review (2026-06-09 14:16) — verified addressed at HEAD

  1. boolean + multiple collapses to default → fixed both ways in a5d3a52: the BooleanArgSpec/ValueArgSpec discriminated union makes multiple unrepresentable on booleans, and parse.ts still defensively resolves a repeated boolean array to true.
  2. boolean default: true footgunBooleanArgSpec now constrains default?: false, so a truthy boolean default is a compile error.
  3. Unknown-flag policy global → unknown flags now hard-error by default with a per-command passthroughUnknown opt-in (also requested inline).
  • Nit: valueHint for int flags--prerender-concurrency declares valueHint: "count"; help reads --prerender-concurrency <count>.
  • Nit: --experimental-https silently dropped → now an explicitly declared hidden no-op on dev (conscious, not accidental).

Self-review

  • vinext build --turbopack regression: the legacy parser accepted --turbopack on every command and it's a real next build flag, but the migrated build spec hard-errored on it under the new strict policy. Declared it a hidden no-op on build (same treatment dev got) + test.
  • Refreshed the stale test-file header and the PR description (it still said only dev was migrated and that unknown flags are ignored).

Verification

vp test run tests/cli-framework.test.ts tests/cli-args.test.ts → 101 passed; vp check + knip clean; vp run vinext#build + dist/cli.js smoke tests (help, unknown-flag error paths, hidden compat flags) all good.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review — spec-driven CLI framework (dev/build/start/init)

Re-reviewed at HEAD (fa86678). The framework is clean and the migration is a faithful port. I verified:

  • Parity with the legacy cli-args.ts parser for dev/build/start/init: validation messages (-p vs --port spelling, Number()-based coercion, missing/empty/looks-like-a-flag guards, --port= empty inline), defaults (dev 3000/localhost, start 0.0.0.0 + PORT-env fallback applied in run(), init 3001), and the build body is a 1:1 move (shared build-id/RSC/revalidate-secret env coordination, standalone, prerender, logger all intact).
  • Negative-number handlingnode:util consumes -1 as a value (not a flag), so --port -1 and --port=-1 both hit the port-range guard. Confirmed.
  • The three prior /bigbonk items are genuinely fixed: boolean + multiple is now unrepresentable (discriminated union) + defensively resolved; BooleanArgSpec.default?: false closes the default: true footgun; unknown flags hard-error by default with a per-command passthroughUnknown opt-in. valueHint nit addressed (--prerender-concurrency <count>), and --experimental-https/--turbopack are now declared hidden no-ops.
  • All 101 tests pass (tests/cli-framework.test.ts + tests/cli-args.test.ts) and vp check is clean on the changed files.

No correctness regressions. Two minor items, both in the framework core that the rest of the stack will build on:

1. Stale module docstring in parse.ts (doc drift)

packages/vinext/src/cli/parse.ts:13 still lists - Graceful pass-through of unknown flags (drop-in \next` CLI friendliness).as a layered behavior. That was the pre-review design; the parser is now **strict by default** (unknown flags throwCliUsageError) with an opt-in passthroughUnknown. The docstring should be updated to describe the strict-by-default + opt-in policy so it matches parseCommand's actual behavior (and the accurate CommandSpec.passthroughUnknowndoc intypes.ts`).

2. Latent: a boolean flag given an inline value silently resolves to false (foundation footgun)

With strict: false, node:util accepts an inline value on a boolean flag and stores it as a string, e.g. --verbose=truerawValues.verbose === "true". In parse.ts the boolean branch (parse.ts:168-172) resolves raw === true || (Array.isArray(raw) && raw.length > 0), so a string "true" fails both checks and the flag resolves to false — i.e. --verbose=true is silently ignored and reads as not-set, the opposite of what the user typed.

No current command hits this (booleans are only ever passed bare), and the legacy parser also mishandled --verbose=true (treated it as an ignored unknown token), so it's not a regression. But since this is foundation code and the framework now errors on most malformed input, it's worth either rejecting an inline value on a boolean (a CliUsageError like --verbose does not take a value) or treating any present boolean token as true. Silently flipping an explicitly-passed true to false is the kind of trap the strict-by-default policy is meant to prevent.

Neither blocks the merge — the migrated commands are correct and well-tested. Item 1 is a one-line doc fix; item 2 is a cheap guard worth landing while the parser is fresh.

@ask-bonk

ask-bonk Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1874.

Summary

I reviewed the spec-driven CLI framework PR. The framework is clean and the dev/build/start/init migration is a faithful port. I verified parity with the legacy cli-args.ts parser (validation messages, Number() coercion, defaults, negative-number handling), confirmed all 101 tests pass, and vp check is clean. The three prior /bigbonk items and the inline review comments are genuinely addressed at HEAD.

No correctness regressions. I flagged two minor framework-core items:

  1. Stale docstring (parse.ts:13) — still claims "graceful pass-through of unknown flags," but the parser is now strict-by-default with an opt-in passthroughUnknown. One-line doc fix.

  2. Latent boolean inline-value bug--verbose=true silently resolves to false because node:util stores the inline value as a string "true", which fails the raw === true check in parse.ts:171. Not a regression (no command hits it, legacy parser was also wrong here), but a footgun in foundation code where the strict-by-default policy otherwise rejects malformed input. Cheap to guard.

Neither blocks the merge.

github run

…ocstring

- parse.ts: with strict: false, node:util stores `--verbose=true` as the
  string "true", which failed the `raw === true` check and silently
  resolved the flag to false — the opposite of what the user typed. Raise
  a CliUsageError instead, consistent with the strict-by-default policy.
- parse.ts: the module docstring still described the pre-review
  "graceful pass-through of unknown flags" design; document the
  strict-by-default + passthroughUnknown opt-in policy instead.
@james-elicx

Copy link
Copy Markdown
Member Author

Bonk round 2 addressed (0664f8c)

  1. Stale parse.ts docstring → rewritten to describe the strict-by-default unknown-flag policy with the per-command passthroughUnknown opt-in (matches parseCommand's behavior and the CommandSpec doc).
  2. Boolean flag with an inline value silently resolves to false--verbose=true (stored by node:util as the string "true") now raises a clean CliUsageError--verbose does not take a value, but got "true". — instead of silently reading as not-set. Covered by tests for =true, =false, and the empty = form.

vp test run tests/cli-framework.test.ts tests/cli-args.test.ts → 102 passed; vp check + knip clean.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx marked this pull request as ready for review June 10, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant