@nifrajs/cli 2.13.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +260 -49
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +130 -414
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +250 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1032 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +55 -0
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +82 -9
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/env-file.d.ts +17 -0
  26. package/dist/env-file.d.ts.map +1 -0
  27. package/dist/env-file.js +113 -0
  28. package/dist/env-file.js.map +1 -0
  29. package/dist/fix-recipes.d.ts.map +1 -1
  30. package/dist/fix-recipes.js +91 -15
  31. package/dist/fix-recipes.js.map +1 -1
  32. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  33. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  34. package/dist/internal/reserved-segment-codemod.js +134 -0
  35. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  36. package/dist/internal/typescript-import.d.ts +1 -1
  37. package/dist/internal/typescript-import.d.ts.map +1 -1
  38. package/dist/internal/typescript-import.js +62 -11
  39. package/dist/internal/typescript-import.js.map +1 -1
  40. package/dist/introspect.d.ts +26 -1
  41. package/dist/introspect.d.ts.map +1 -1
  42. package/dist/introspect.js +74 -24
  43. package/dist/introspect.js.map +1 -1
  44. package/dist/load.d.ts.map +1 -1
  45. package/dist/load.js +22 -5
  46. package/dist/load.js.map +1 -1
  47. package/dist/manifest-tool.d.ts +15 -1
  48. package/dist/manifest-tool.d.ts.map +1 -1
  49. package/dist/manifest-tool.js +23 -9
  50. package/dist/manifest-tool.js.map +1 -1
  51. package/dist/mcp-http.js +1 -1
  52. package/dist/mcp-http.js.map +1 -1
  53. package/dist/mcp.d.ts +16 -0
  54. package/dist/mcp.d.ts.map +1 -1
  55. package/dist/mcp.js +127 -355
  56. package/dist/mcp.js.map +1 -1
  57. package/dist/project-facts.d.ts +40 -0
  58. package/dist/project-facts.d.ts.map +1 -0
  59. package/dist/project-facts.js +36 -0
  60. package/dist/project-facts.js.map +1 -0
  61. package/dist/recipes/3.0.0.d.ts +10 -0
  62. package/dist/recipes/3.0.0.d.ts.map +1 -0
  63. package/dist/recipes/3.0.0.js +23 -0
  64. package/dist/recipes/3.0.0.js.map +1 -0
  65. package/dist/recipes/index.d.ts.map +1 -1
  66. package/dist/recipes/index.js +2 -0
  67. package/dist/recipes/index.js.map +1 -1
  68. package/dist/release-verification.d.ts.map +1 -1
  69. package/dist/release-verification.js +15 -0
  70. package/dist/release-verification.js.map +1 -1
  71. package/dist/rules/comment-markers.d.ts +8 -0
  72. package/dist/rules/comment-markers.d.ts.map +1 -1
  73. package/dist/rules/comment-markers.js +36 -0
  74. package/dist/rules/comment-markers.js.map +1 -1
  75. package/dist/rules/index.d.ts +2 -3
  76. package/dist/rules/index.d.ts.map +1 -1
  77. package/dist/rules/index.js.map +1 -1
  78. package/dist/rules/routes.d.ts.map +1 -1
  79. package/dist/rules/routes.js +29 -44
  80. package/dist/rules/routes.js.map +1 -1
  81. package/dist/rules/security.js +14 -14
  82. package/dist/rules/security.js.map +1 -1
  83. package/dist/workspace-link.d.ts +37 -0
  84. package/dist/workspace-link.d.ts.map +1 -0
  85. package/dist/workspace-link.js +72 -0
  86. package/dist/workspace-link.js.map +1 -0
  87. package/docs/examples.json +1 -1
  88. package/docs/llms-full.txt +362 -20
  89. package/docs/types.json +956 -56
  90. package/package.json +9 -9
  91. package/src/capabilities-tool.ts +229 -21
  92. package/src/check.ts +312 -65
  93. package/src/cli.ts +140 -405
  94. package/src/command-catalog.ts +1435 -0
  95. package/src/contracts.ts +28 -7
  96. package/src/doctor.ts +134 -12
  97. package/src/env-file.ts +121 -0
  98. package/src/fix-recipes.ts +101 -15
  99. package/src/internal/reserved-segment-codemod.ts +150 -0
  100. package/src/internal/typescript-import.ts +61 -11
  101. package/src/introspect.ts +82 -21
  102. package/src/load.ts +25 -6
  103. package/src/manifest-tool.ts +43 -12
  104. package/src/mcp-http.ts +1 -1
  105. package/src/mcp.ts +163 -420
  106. package/src/project-facts.ts +86 -0
  107. package/src/recipes/3.0.0.ts +24 -0
  108. package/src/recipes/index.ts +2 -0
  109. package/src/release-verification.ts +17 -0
  110. package/src/rules/comment-markers.ts +36 -0
  111. package/src/rules/index.ts +3 -4
  112. package/src/rules/routes.ts +30 -44
  113. package/src/rules/security.ts +14 -14
  114. package/src/workspace-link.ts +80 -0
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
11
11
  */
12
- import { existsSync } from "node:fs"
12
+ import { existsSync, readFileSync } from "node:fs"
13
13
  import { resolve } from "node:path"
14
14
  import { inProcessClient } from "@nifrajs/client"
15
15
  import {
@@ -20,7 +20,8 @@ import {
20
20
  } from "@nifrajs/web"
21
21
  import { discoverRoutes } from "@nifrajs/web/fs"
22
22
  import type { BunPlugin } from "bun"
23
- import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
23
+ import { bindCommandArgv, findCommandSpec, renderCommandCatalogHelp } from "./command-catalog.ts"
24
+ import { applyEnvFiles, takeEnvFileFlags } from "./env-file.ts"
24
25
  import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
25
26
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
26
27
 
@@ -40,6 +41,10 @@ export interface Flags {
40
41
  *
41
42
  * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
42
43
  readonly bun: boolean
44
+ /** `nifra dev --allow-duplicate-identity`: downgrade the Vite dev server's identity-parity check from
45
+ * a hard failure to a loud warning so a duplicate coming from a linked sibling repo doesn't take dev
46
+ * down while you fix the resolution. Dev only - `nifra build` always fails hard on a duplicate. */
47
+ readonly allowDuplicateIdentity: boolean
43
48
  }
44
49
 
45
50
  const HELP = `nifra - zero-config dev/build/start for a nifra app
@@ -53,6 +58,10 @@ Usage:
53
58
  preserved), no Vite in the process. CSS Modules, plain CSS,
54
59
  Tailwind and the app's own \`clientPlugins\`/\`serverPlugins\` run
55
60
  through the same Bun transforms production uses.
61
+ [--allow-duplicate-identity] Downgrade the Vite dev server's identity-parity check to a
62
+ loud warning instead of a hard failure, so a duplicate React/
63
+ adapter copy from a linked sibling repo doesn't take dev down
64
+ while you fix the resolution. Dev only - \`nifra build\` still fails.
56
65
  nifra build [--out <dir>] [--report] Emit a complete deploy directory.
57
66
  [--target <t>] Target a FULL deploy dir for <t>:
58
67
  bun | node | deno | cf-pages | vercel | static. Packages
@@ -123,11 +132,13 @@ Usage:
123
132
  closed. Exits non-zero on any breaking change - run it in CI.
124
133
  nifra sdk --lang <python|go> [--out <file>]
125
134
  Generate a deterministic non-TypeScript SDK from backend.ts.
126
- nifra assure [--config <file>] [--json] [--strict] [--out <file>] [--hydration] [--interact]
127
- Emit one structured assurance bundle. The bundle records every
128
- applicable gate as pass, fail, or skip and exits non-zero unless
129
- its verdict is green. Without these bundle flags, the legacy
130
- route-assurance report remains available.
135
+ nifra assure [--config <file>] [--json] Route-assurance report. Human table by default; --json emits
136
+ the {ok, routes, findings} report for agents.
137
+ nifra assure --bundle [--json] [--strict] [--out <file>] [--hydration] [--interact]
138
+ Emit one structured assurance {version, gates, verdict} bundle
139
+ (always JSON). Records every applicable gate as pass, fail, or
140
+ skip and exits non-zero unless its verdict is green. --strict,
141
+ --hydration, --interact and --out imply --bundle.
131
142
  nifra contracts snapshot [--out <file>] Write the deterministic route contract lock.
132
143
  nifra contracts check [--json] Check the current routes against contracts.lock.json.
133
144
  nifra replay <file> Replay a token-only verification metadata file.
@@ -137,6 +148,8 @@ Usage:
137
148
  nifra capabilities check [--lockfile <file>] [--config <file>] [--json]
138
149
  CI gate: fail on raw effect-import bypasses, declaration/evidence
139
150
  drift, unsafe GET/HEAD writes, idempotency gaps, or lockfile drift.
151
+ nifra capabilities explain <METHOD> <path> [--config <file>] [--json]
152
+ Explain one route's declared capability tokens and static provenance.
140
153
  nifra manifest emit [--out <file>] [--config <file>] [--sign <key-ref>]
141
154
  Emit a deterministic route trust artifact after assurance passes;
142
155
  optionally write an Ed25519 signature via the configured KMS signer.
@@ -174,10 +187,18 @@ Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backe
174
187
  routes/ from the current directory. Run from your project root.
175
188
 
176
189
  Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
177
- \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
190
+ \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).
191
+
192
+ Env: \`--env-file <path>\` works on EVERY command and may be repeated (later files win). Commands that
193
+ reflect your app import it, so an app that validates its environment at module scope needs that
194
+ environment present - without it the app aborts before nifra runs a single check. A variable already
195
+ set in the process environment is never overwritten by a file.
196
+ e.g. \`nifra check --env-file .env.local\`
197
+
198
+ ${renderCommandCatalogHelp()}`
178
199
 
179
200
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
180
- const CLI_VERSION = "2.13.0"
201
+ const CLI_VERSION = "3.1.0"
181
202
 
182
203
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
183
204
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -389,6 +410,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
389
410
  ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
390
411
  poll: flags.poll,
391
412
  port: flags.port,
413
+ ...(flags.allowDuplicateIdentity ? { allowDuplicateIdentity: true } : {}),
392
414
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
393
415
  ...(fw.define ? { define: fw.define } : {}),
394
416
  // `load` resolves route modules through VITE, not through Bun. That is what makes the Vite
@@ -445,6 +467,38 @@ export async function assertUseIsEdgeExported(
445
467
  )
446
468
  }
447
469
 
470
+ /**
471
+ * A production build must not ship a workspace-linked package whose `default`-condition artifact is
472
+ * missing or older than its source. The `"bun": "./src"` / `"default": "./dist"` split means Bun (this
473
+ * build, the tests) reads live source while the deployed app and any node consumer read `dist/` - so a
474
+ * green `nifra build` can bundle stale or absent dist and the drift never shows in a diff. The doctor
475
+ * reports the same skew as an advisory during development; at build time it is a hard failure, because
476
+ * this is the artifact that ships. No-op outside a workspace: tarball-installed deps cannot drift.
477
+ */
478
+ async function assertFreshWorkspaceDists(cwd: string): Promise<void> {
479
+ const pkgPath = resolve(cwd, "package.json")
480
+ if (!existsSync(pkgPath)) return
481
+ let rootPackage: Record<string, unknown>
482
+ try {
483
+ rootPackage = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>
484
+ } catch {
485
+ return // an unreadable/!JSON package.json is a separate problem; don't mask it as a stale-dist failure
486
+ }
487
+ const { collectStaleWorkspaceDists } = await import("./doctor.ts")
488
+ const stale = await collectStaleWorkspaceDists(cwd, rootPackage)
489
+ if (stale.length === 0) return
490
+ const lines = stale.map((f) =>
491
+ f.missing
492
+ ? ` ${f.package}: ${f.distFile} was never built (source ${f.sourceFile})`
493
+ : ` ${f.package}: ${f.distFile} is ${f.behindSeconds}s behind ${f.sourceFile}`,
494
+ )
495
+ throw new Error(
496
+ `[nifra] build blocked: ${stale.length} workspace-linked package(s) ship a stale or missing dist artifact, so the deployed app would serve code this build did not produce:\n${lines.join(
497
+ "\n",
498
+ )}\nBuild the offending package(s) (\`bun run build\` in each) so every export target exists and is at least as new as its source, then rebuild.`,
499
+ )
500
+ }
501
+
448
502
  async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
449
503
  const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
450
504
  if (!isBuildTarget(target)) {
@@ -505,6 +559,11 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
505
559
  // The static target needs a built app to drive prerendering - only build it when targeting static.
506
560
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
507
561
  })
562
+ // A production build must not certify itself green while a workspace-linked dependency ships a dist
563
+ // artifact older than (or absent next to) its source: the `bun`->src condition let this compile read
564
+ // live source, but the deployed app and every node consumer read `dist/`. Gate here, after the compile
565
+ // proved the source is buildable, so the failure names the package to rebuild rather than a bundle error.
566
+ await assertFreshWorkspaceDists(cwd)
508
567
  console.log(`nifra build (${target}, ${decision.pipeline}) → ${result.run}`)
509
568
  // The pipeline is stated on every build, not only the surprising ones - an auto-selected Vite build
510
569
  // must never look like the default, and a default Bun build must not leave the question open either.
@@ -575,6 +634,7 @@ export function parseFlags(args: readonly string[]): Flags {
575
634
  let report = false
576
635
  let vite = false
577
636
  let bun = false
637
+ let allowDuplicateIdentity = false
578
638
  for (let i = 0; i < args.length; i++) {
579
639
  const a = args[i]
580
640
  if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
@@ -584,15 +644,58 @@ export function parseFlags(args: readonly string[]): Flags {
584
644
  else if (a === "--report") report = true
585
645
  else if (a === "--vite") vite = true
586
646
  else if (a === "--bun") bun = true
647
+ else if (a === "--allow-duplicate-identity") allowDuplicateIdentity = true
587
648
  }
588
649
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
589
650
  throw new Error(`[nifra] invalid --port: ${port}`)
590
651
  }
591
- return { port, out, poll, target, report, vite, bun }
652
+ return { port, out, poll, target, report, vite, bun, allowDuplicateIdentity }
653
+ }
654
+
655
+ /**
656
+ * `assure` uses the structured-bundle lane only when explicitly asked. A bare `--json` is NOT a
657
+ * request for the bundle - it keeps the legacy `{ ok, routes, findings }` report machine-readable, so a
658
+ * pre-bundle consumer is never silently handed the `{ gates, verdict }` shape. `--bundle` (or a
659
+ * bundle-only flag) opts in; `--bundle --json` still yields the bundle, which is always JSON.
660
+ */
661
+ export function assureBundleRequested(argv: readonly string[]): boolean {
662
+ return (
663
+ argv.includes("--bundle") ||
664
+ argv.includes("--strict") ||
665
+ argv.includes("--hydration") ||
666
+ argv.includes("--interact") ||
667
+ argv.includes("--out")
668
+ )
669
+ }
670
+
671
+ /**
672
+ * A command that reflects the project imports it, and an app that validates its environment at module
673
+ * scope calls `process.exit` from inside that import. Nothing is throwable at that point, so the whole
674
+ * output is the app's own abort message with no hint that a nifra command triggered it. Name the cause
675
+ * on the way out and point at the flag that supplies the environment.
676
+ */
677
+ function installReflectionExitHint(): (command: string | undefined) => void {
678
+ let inFlight: string | undefined
679
+ process.on("exit", (code) => {
680
+ if (inFlight === undefined || code === 0) return
681
+ process.stderr.write(
682
+ `\n[nifra] \`nifra ${inFlight}\` reflects this project by importing it, and the import exited ` +
683
+ `with code ${code} before the command produced a result - the message above came from your app, not nifra.\n` +
684
+ ` If it is the app's environment validation, supply the environment and re-run:\n` +
685
+ ` nifra ${inFlight} --env-file .env.local\n`,
686
+ )
687
+ })
688
+ return (command) => {
689
+ inFlight = command
690
+ }
592
691
  }
593
692
 
594
693
  async function main(): Promise<void> {
595
- const argv = Bun.argv.slice(2)
694
+ const { argv: rawArgv, files: envFiles } = takeEnvFileFlags(Bun.argv.slice(2))
695
+ // Applied before any command runs: a reflecting command imports the app on its first await, and the
696
+ // app reads its environment at module scope, so the variables must already be in place by then.
697
+ if (envFiles.length > 0) await applyEnvFiles(process.cwd(), envFiles)
698
+ const argv = rawArgv
596
699
  const command = argv[0]
597
700
  if (command === undefined || command === "--help" || command === "-h" || command === "help") {
598
701
  console.log(HELP)
@@ -637,18 +740,31 @@ async function main(): Promise<void> {
637
740
  )
638
741
  return
639
742
  }
640
- // `check` is a pure cwd-based gate (typecheck + lint) - it must run even when the project doesn't
641
- // load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
642
- if (command === "check") {
643
- const { runCheck } = await import("./check.ts")
644
- if (
645
- !(await runCheck(process.cwd(), {
646
- json: argv.includes("--json"),
647
- structured: argv.includes("--json"),
648
- lintsOnly: argv.includes("--lints-only"),
649
- }))
650
- )
743
+ const catalogSpec = command === undefined ? undefined : findCommandSpec(command)
744
+ if (catalogSpec?.transports.includes("cli")) {
745
+ const markReflecting = installReflectionExitHint()
746
+ try {
747
+ const input = bindCommandArgv(catalogSpec, argv.slice(1))
748
+ markReflecting(command)
749
+ const output = await catalogSpec.run(input, { cwd: process.cwd(), cliVersion: CLI_VERSION })
750
+ markReflecting(undefined)
751
+ const asRecord = output as unknown as Record<string, unknown>
752
+ const wantsJson = asRecord.json === true || argv.includes("--json")
753
+ if (wantsJson) {
754
+ const value = catalogSpec.json?.(output, input) ?? output
755
+ console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2))
756
+ } else {
757
+ console.log(catalogSpec.render(output, input).join("\n"))
758
+ }
759
+ if (catalogSpec.success !== undefined && !catalogSpec.success(output, input))
760
+ process.exitCode = 1
761
+ } catch (err) {
762
+ // A thrown error is nifra's own reporting path, not a silent abort: clear the sentinel so the
763
+ // hint stays reserved for the case where the process died inside the app import.
764
+ markReflecting(undefined)
765
+ console.error(formatCliError(err))
651
766
  process.exitCode = 1
767
+ }
652
768
  return
653
769
  }
654
770
  if (command === "verify") {
@@ -660,29 +776,6 @@ async function main(): Promise<void> {
660
776
  if (!ok) process.exitCode = 1
661
777
  return
662
778
  }
663
- if (command === "fix") {
664
- const codeIndex = argv.indexOf("--code")
665
- const code = codeIndex === -1 ? undefined : argv[codeIndex + 1]
666
- try {
667
- const { collectCheckResult } = await import("./check.ts")
668
- const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
669
- const result = await collectCheckResult(process.cwd(), { lintsOnly: true })
670
- const changed: string[] = []
671
- for (const diagnostic of result.structuredDiagnostics ?? []) {
672
- if (code !== undefined && diagnostic.code !== code) continue
673
- changed.push(...(await applyDiagnosticRecipe(process.cwd(), diagnostic)))
674
- }
675
- const final = await collectCheckResult(process.cwd(), { lintsOnly: true })
676
- console.log(
677
- JSON.stringify({ changed, diagnostics: final.structuredDiagnostics ?? [] }, null, 2),
678
- )
679
- if (final.ok === false) process.exitCode = 1
680
- } catch (err) {
681
- console.error(formatCliError(err))
682
- process.exitCode = 1
683
- }
684
- return
685
- }
686
779
  // `init-agents` retrofits the agent-discovery files (.mcp.json, CLAUDE.md, …) into the cwd. It's a
687
780
  // pure file-writing command independent of the app loading, so dispatch it before the eager `loadApp`
688
781
  // (an existing app might be API-only or not yet built). It always succeeds unless a write throws.
@@ -694,75 +787,6 @@ async function main(): Promise<void> {
694
787
  })
695
788
  return
696
789
  }
697
- // `sync-manifest` rewrites a committed web `server-manifest.ts` from the current `routes/` tree, without
698
- // a full build. Pure fs/text (re-scan routes, preserve baked client assets), so - like `check` - dispatch
699
- // it before the eager `loadApp`.
700
- if (command === "sync-manifest") {
701
- const { runSyncManifest } = await import("./sync-manifest.ts")
702
- if (!(await runSyncManifest(process.cwd()))) process.exitCode = 1
703
- return
704
- }
705
- // `sync-routes` regenerates the route-search types `.d.ts` (typed `navigate({ to, search })`) from
706
- // `routes/`. Pure fs/text (needs only the route tree, not `framework.ts`), so dispatch before `loadApp`.
707
- if (command === "sync-routes") {
708
- const { runSyncRoutes } = await import("./sync-routes.ts")
709
- if (!(await runSyncRoutes(process.cwd()))) process.exitCode = 1
710
- return
711
- }
712
- // `doctor` is a pure cwd check (imports vs declared deps) - like `check`, dispatch before `loadApp`
713
- // so it runs on an API-only / not-yet-built project.
714
- if (command === "doctor") {
715
- const { runDoctor } = await import("./doctor.ts")
716
- const targetIdx = argv.indexOf("--target")
717
- const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
718
- if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
719
- console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
720
- process.exitCode = 1
721
- return
722
- }
723
- if (
724
- !(await runDoctor(process.cwd(), {
725
- json: argv.includes("--json"),
726
- autoFix: argv.includes("--auto-fix") || argv.includes("--fix"),
727
- strict: argv.includes("--strict"),
728
- ...(target === undefined ? {} : { target }),
729
- }))
730
- )
731
- process.exitCode = 1
732
- return
733
- }
734
- // `snapshot` / `diff` load ONLY backend.ts (the API contract) - like `check`, they must run on an
735
- // API-only project, so they dispatch before the eager `loadApp`.
736
- if (command === "snapshot") {
737
- const { runSnapshot } = await import("./diff-tool.ts")
738
- const outIdx = argv.indexOf("--out")
739
- const out = outIdx !== -1 ? argv[outIdx + 1] : undefined
740
- if (outIdx !== -1 && (out === undefined || out.startsWith("-"))) {
741
- console.error("[nifra] --out needs a file path")
742
- process.exitCode = 1
743
- return
744
- }
745
- try {
746
- await runSnapshot(process.cwd(), out !== undefined ? { out } : {})
747
- } catch (err) {
748
- console.error(formatCliError(err))
749
- process.exitCode = 1
750
- }
751
- return
752
- }
753
- if (command === "diff") {
754
- const { runDiff, DEFAULT_SNAPSHOT_FILE } = await import("./diff-tool.ts")
755
- const baseline = argv.slice(1).find((arg) => !arg.startsWith("-")) ?? DEFAULT_SNAPSHOT_FILE
756
- try {
757
- if (!(await runDiff(process.cwd(), baseline, { json: argv.includes("--json") }))) {
758
- process.exitCode = 1
759
- }
760
- } catch (err) {
761
- console.error(formatCliError(err))
762
- process.exitCode = 1
763
- }
764
- return
765
- }
766
790
  if (command === "sdk") {
767
791
  const languageIndex = argv.indexOf("--lang")
768
792
  const language = languageIndex === -1 ? undefined : argv[languageIndex + 1]
@@ -787,237 +811,6 @@ async function main(): Promise<void> {
787
811
  }
788
812
  return
789
813
  }
790
- // Route assurance intentionally loads only its explicit config (which imports the backend it audits),
791
- // not the full SSR app. Like snapshot/diff, this works for API-only projects.
792
- if (command === "assure") {
793
- const { runAssurance } = await import("./assure.ts")
794
- const configIdx = argv.indexOf("--config")
795
- const config = configIdx !== -1 ? argv[configIdx + 1] : undefined
796
- if (configIdx !== -1 && (config === undefined || config.startsWith("-"))) {
797
- console.error("[nifra] --config needs a file path")
798
- process.exitCode = 1
799
- return
800
- }
801
- try {
802
- if (
803
- !(await runAssurance(process.cwd(), {
804
- json: argv.includes("--json"),
805
- bundle:
806
- argv.includes("--json") ||
807
- argv.includes("--strict") ||
808
- argv.includes("--hydration") ||
809
- argv.includes("--interact") ||
810
- argv.includes("--out"),
811
- strict: argv.includes("--strict"),
812
- hydration: argv.includes("--hydration"),
813
- interact: argv.includes("--interact"),
814
- ...(argv.includes("--out") ? { out: argv[argv.indexOf("--out") + 1] } : {}),
815
- ...(config !== undefined ? { config } : {}),
816
- }))
817
- )
818
- process.exitCode = 1
819
- } catch (err) {
820
- console.error(formatCliError(err))
821
- process.exitCode = 1
822
- }
823
- return
824
- }
825
- if (command === "contracts") {
826
- const action = argv[1]
827
- const outIndex = argv.indexOf("--out")
828
- const out = outIndex === -1 ? undefined : argv[outIndex + 1]
829
- try {
830
- const { checkContractsLock, snapshotContracts } = await import("./contracts.ts")
831
- if (action === "snapshot") {
832
- const lock = await snapshotContracts(process.cwd(), out)
833
- if (argv.includes("--json")) console.log(JSON.stringify(lock, null, 2))
834
- else console.log(`[nifra] wrote ${Object.keys(lock.routes).length} contract digests`)
835
- } else if (action === "check") {
836
- const result = await checkContractsLock(process.cwd())
837
- const output = { ok: result.present && result.diagnostics.length === 0, ...result }
838
- console.log(JSON.stringify(output, null, 2))
839
- if (!output.ok) process.exitCode = 1
840
- } else {
841
- console.error("[nifra] contracts needs snapshot or check")
842
- process.exitCode = 1
843
- }
844
- } catch (err) {
845
- console.error(formatCliError(err))
846
- process.exitCode = 1
847
- }
848
- return
849
- }
850
- if (command === "replay") {
851
- const file = argv[1]
852
- if (file === undefined || file.startsWith("-")) {
853
- console.error("[nifra] replay needs a file path")
854
- process.exitCode = 1
855
- return
856
- }
857
- try {
858
- const { runReplay } = await import("./replay.ts")
859
- const result = await runReplay(process.cwd(), file)
860
- console.log(JSON.stringify(result, null, 2))
861
- if (!result.ok) process.exitCode = 1
862
- } catch (err) {
863
- console.error(formatCliError(err))
864
- process.exitCode = 1
865
- }
866
- return
867
- }
868
- if (command === "capabilities") {
869
- const action = argv[1]
870
- const valueAfter = (flag: string): string | undefined => {
871
- const index = argv.indexOf(flag)
872
- const value = index === -1 ? undefined : argv[index + 1]
873
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
874
- throw new Error(`[nifra] ${flag} needs a file path`)
875
- }
876
- return value
877
- }
878
- try {
879
- const { runCapabilityCheck, runCapabilitySnapshot } = await import("./capabilities-tool.ts")
880
- const config = valueAfter("--config")
881
- if (action === "snapshot") {
882
- const out = valueAfter("--out")
883
- if (
884
- !(await runCapabilitySnapshot(process.cwd(), {
885
- ...(config !== undefined ? { config } : {}),
886
- ...(out !== undefined ? { out } : {}),
887
- }))
888
- )
889
- process.exitCode = 1
890
- } else if (action === "check") {
891
- const lockfile = valueAfter("--lockfile")
892
- if (
893
- !(await runCapabilityCheck(process.cwd(), {
894
- ...(config !== undefined ? { config } : {}),
895
- ...(lockfile !== undefined ? { lockfile } : {}),
896
- json: argv.includes("--json"),
897
- }))
898
- )
899
- process.exitCode = 1
900
- } else {
901
- throw new Error("[nifra] capabilities needs `snapshot` or `check`")
902
- }
903
- } catch (err) {
904
- console.error(formatCliError(err))
905
- process.exitCode = 1
906
- }
907
- return
908
- }
909
- if (command === "manifest") {
910
- const action = argv[1]
911
- const valueAfter = (flag: string): string | undefined => {
912
- const index = argv.indexOf(flag)
913
- const value = index === -1 ? undefined : argv[index + 1]
914
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
915
- throw new Error(`[nifra] ${flag} needs a value`)
916
- }
917
- return value
918
- }
919
- try {
920
- const { runManifestDiff, runManifestEmit } = await import("./manifest-tool.ts")
921
- if (action === "emit") {
922
- const config = valueAfter("--config")
923
- const out = valueAfter("--out")
924
- const sign = valueAfter("--sign")
925
- if (
926
- !(await runManifestEmit(process.cwd(), {
927
- ...(config !== undefined ? { config } : {}),
928
- ...(out !== undefined ? { out } : {}),
929
- ...(sign !== undefined ? { sign } : {}),
930
- }))
931
- )
932
- process.exitCode = 1
933
- } else if (action === "diff") {
934
- const paths = argv.slice(2).filter((arg) => !arg.startsWith("-"))
935
- if (paths.length !== 2) throw new Error("[nifra] manifest diff needs <before> <after>")
936
- if (
937
- !(await runManifestDiff(process.cwd(), paths[0]!, paths[1]!, {
938
- json: argv.includes("--json"),
939
- }))
940
- )
941
- process.exitCode = 1
942
- } else {
943
- throw new Error("[nifra] manifest needs `emit` or `diff`")
944
- }
945
- } catch (err) {
946
- console.error(formatCliError(err))
947
- process.exitCode = 1
948
- }
949
- return
950
- }
951
- if (command === "levels") {
952
- const valueAfter = (flag: string): string | undefined => {
953
- const index = argv.indexOf(flag)
954
- const value = index === -1 ? undefined : argv[index + 1]
955
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
956
- throw new Error(`[nifra] ${flag} needs a value`)
957
- }
958
- return value
959
- }
960
- try {
961
- const { runLevels } = await import("./levels-tool.ts")
962
- const config = valueAfter("--config")
963
- const minRaw = valueAfter("--min")
964
- const min = minRaw === undefined ? undefined : Number(minRaw)
965
- if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
966
- throw new Error("[nifra] --min must be an integer level between 0 and 4")
967
- }
968
- if (
969
- !(await runLevels(process.cwd(), {
970
- json: argv.includes("--json"),
971
- ...(config !== undefined ? { config } : {}),
972
- ...(min !== undefined ? { min } : {}),
973
- }))
974
- )
975
- process.exitCode = 1
976
- } catch (err) {
977
- console.error(formatCliError(err))
978
- process.exitCode = 1
979
- }
980
- return
981
- }
982
- if (command === "prove") {
983
- const valueAfter = (flag: string): string | undefined => {
984
- const index = argv.indexOf(flag)
985
- const value = index === -1 ? undefined : argv[index + 1]
986
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
987
- throw new Error(`[nifra] ${flag} needs a value`)
988
- }
989
- return value
990
- }
991
- try {
992
- const minRaw = valueAfter("--min")
993
- const min = minRaw === undefined ? undefined : Number(minRaw)
994
- if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
995
- throw new Error("[nifra] --min must be an integer level between 0 and 4")
996
- }
997
- const changedFiles: string[] = []
998
- for (let index = 0; index < argv.length; index++) {
999
- if (argv[index] !== "--file") continue
1000
- const file = argv[index + 1]
1001
- if (file === undefined || file.startsWith("-"))
1002
- throw new Error("[nifra] --file needs a path")
1003
- changedFiles.push(file)
1004
- index++
1005
- }
1006
- const { runWorkGraph } = await import("./work-graph.ts")
1007
- if (
1008
- !(await runWorkGraph(process.cwd(), {
1009
- json: argv.includes("--json"),
1010
- ...(changedFiles.length === 0 ? {} : { changedFiles }),
1011
- ...(min === undefined ? {} : { minLevel: min }),
1012
- }))
1013
- )
1014
- process.exitCode = 1
1015
- } catch (err) {
1016
- console.error(formatCliError(err))
1017
- process.exitCode = 1
1018
- }
1019
- return
1020
- }
1021
814
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
1022
815
  // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
1023
816
  // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
@@ -1042,36 +835,7 @@ async function main(): Promise<void> {
1042
835
  }
1043
836
  // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) - like `check`/
1044
837
  // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
1045
- if (command === "port") {
1046
- const { runPort } = await import("./port.ts")
1047
- const targetIdx = argv.indexOf("--target")
1048
- const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
1049
- if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
1050
- console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
1051
- process.exitCode = 1
1052
- return
1053
- }
1054
- try {
1055
- const passed = await runPort(process.cwd(), {
1056
- ...(target !== undefined ? { target } : {}),
1057
- json: argv.includes("--json"),
1058
- ci: argv.includes("--ci"),
1059
- strict: argv.includes("--strict"),
1060
- })
1061
- if (!passed) process.exitCode = 1
1062
- } catch (err) {
1063
- console.error(formatCliError(err))
1064
- process.exitCode = 1
1065
- }
1066
- return
1067
- }
1068
- if (
1069
- command !== "dev" &&
1070
- command !== "build" &&
1071
- command !== "start" &&
1072
- command !== "context" &&
1073
- command !== "routes"
1074
- ) {
838
+ if (command !== "dev" && command !== "build" && command !== "start") {
1075
839
  console.error(`[nifra] unknown command: ${command}\n`)
1076
840
  console.error(HELP)
1077
841
  process.exitCode = 1
@@ -1081,36 +845,7 @@ async function main(): Promise<void> {
1081
845
  const app = await loadApp(process.cwd(), flags.out)
1082
846
  if (command === "dev") await dev(app, flags)
1083
847
  else if (command === "build") await buildForTarget(app, flags.target, flags)
1084
- else if (command === "context") console.log(describeProject(app))
1085
- else if (command === "routes") {
1086
- if (argv.includes("--graph")) {
1087
- console.log(await describeRouteGraph(app, { json: argv.includes("--json") }))
1088
- } else if (argv.includes("--modes")) {
1089
- // `--modes` answers a different question than the default table: not "what URLs exist" but "how
1090
- // does each render, and can the target I'm about to deploy to actually honour that". Resolves
1091
- // against `--target` when given, so a `static` build's server-needing route fails here, in one
1092
- // place, instead of 404ing in production.
1093
- const { buildRouteManifest, renderRouteManifest } = await import(
1094
- "@nifrajs/web/route-manifest"
1095
- )
1096
- const manifest = discoverRoutes(app.routesDir)
1097
- // `flags.target` defaults to "bun" for `build`; here an EXPLICIT `--target` is what opts into
1098
- // gating, so read it from argv rather than inheriting the build default (which would silently gate
1099
- // every `nifra routes --modes` against bun).
1100
- const targetIdx = argv.indexOf("--target")
1101
- const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
1102
- const routeManifest = await buildRouteManifest(manifest, {
1103
- ...(target !== undefined ? { target } : {}),
1104
- })
1105
- if (argv.includes("--json")) console.log(JSON.stringify(routeManifest, null, 2))
1106
- else console.log(renderRouteManifest(routeManifest))
1107
- // A target that cannot honour a route is a build-breaking fact, not a note: exit non-zero so the
1108
- // same command in CI gates the deploy it is warning about.
1109
- if (routeManifest.conflicts.length > 0) process.exitCode = 1
1110
- } else {
1111
- console.log(await describeRoutes(app, { json: argv.includes("--json") }))
1112
- }
1113
- } else await start(app, flags)
848
+ else await start(app, flags)
1114
849
  }
1115
850
 
1116
851
  // Only run the CLI when invoked as the entry (`bun cli.ts …`), not when a test imports it for the