@nifrajs/cli 2.12.1 → 2.14.1

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 (74) 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 +235 -47
  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 +96 -406
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +244 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1014 -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 +18 -11
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +30 -230
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/load.d.ts.map +1 -1
  26. package/dist/load.js +22 -5
  27. package/dist/load.js.map +1 -1
  28. package/dist/manifest-tool.d.ts +15 -1
  29. package/dist/manifest-tool.d.ts.map +1 -1
  30. package/dist/manifest-tool.js +23 -9
  31. package/dist/manifest-tool.js.map +1 -1
  32. package/dist/mcp-http.js +1 -1
  33. package/dist/mcp.d.ts +8 -0
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +59 -349
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/project-facts.d.ts +40 -0
  38. package/dist/project-facts.d.ts.map +1 -0
  39. package/dist/project-facts.js +36 -0
  40. package/dist/project-facts.js.map +1 -0
  41. package/dist/release-verification.d.ts +45 -0
  42. package/dist/release-verification.d.ts.map +1 -0
  43. package/dist/release-verification.js +303 -0
  44. package/dist/release-verification.js.map +1 -0
  45. package/dist/rules/comment-markers.d.ts +8 -0
  46. package/dist/rules/comment-markers.d.ts.map +1 -1
  47. package/dist/rules/comment-markers.js +36 -0
  48. package/dist/rules/comment-markers.js.map +1 -1
  49. package/dist/rules/index.d.ts +2 -3
  50. package/dist/rules/index.d.ts.map +1 -1
  51. package/dist/rules/index.js.map +1 -1
  52. package/dist/rules/routes.d.ts.map +1 -1
  53. package/dist/rules/routes.js +12 -14
  54. package/dist/rules/routes.js.map +1 -1
  55. package/dist/rules/security.js +14 -14
  56. package/dist/rules/security.js.map +1 -1
  57. package/docs/types.json +28 -15
  58. package/package.json +9 -9
  59. package/src/capabilities-tool.ts +229 -21
  60. package/src/check.ts +286 -64
  61. package/src/cli.ts +106 -399
  62. package/src/command-catalog.ts +1415 -0
  63. package/src/contracts.ts +28 -7
  64. package/src/doctor.ts +56 -264
  65. package/src/load.ts +25 -6
  66. package/src/manifest-tool.ts +43 -12
  67. package/src/mcp-http.ts +1 -1
  68. package/src/mcp.ts +82 -414
  69. package/src/project-facts.ts +86 -0
  70. package/src/release-verification.ts +377 -0
  71. package/src/rules/comment-markers.ts +36 -0
  72. package/src/rules/index.ts +3 -4
  73. package/src/rules/routes.ts +13 -14
  74. package/src/rules/security.ts +14 -14
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,7 @@ 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
24
  import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
25
25
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
26
26
 
@@ -40,6 +40,10 @@ export interface Flags {
40
40
  *
41
41
  * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
42
42
  readonly bun: boolean
43
+ /** `nifra dev --allow-duplicate-identity`: downgrade the Vite dev server's identity-parity check from
44
+ * a hard failure to a loud warning so a duplicate coming from a linked sibling repo doesn't take dev
45
+ * down while you fix the resolution. Dev only - `nifra build` always fails hard on a duplicate. */
46
+ readonly allowDuplicateIdentity: boolean
43
47
  }
44
48
 
45
49
  const HELP = `nifra - zero-config dev/build/start for a nifra app
@@ -53,6 +57,10 @@ Usage:
53
57
  preserved), no Vite in the process. CSS Modules, plain CSS,
54
58
  Tailwind and the app's own \`clientPlugins\`/\`serverPlugins\` run
55
59
  through the same Bun transforms production uses.
60
+ [--allow-duplicate-identity] Downgrade the Vite dev server's identity-parity check to a
61
+ loud warning instead of a hard failure, so a duplicate React/
62
+ adapter copy from a linked sibling repo doesn't take dev down
63
+ while you fix the resolution. Dev only - \`nifra build\` still fails.
56
64
  nifra build [--out <dir>] [--report] Emit a complete deploy directory.
57
65
  [--target <t>] Target a FULL deploy dir for <t>:
58
66
  bun | node | deno | cf-pages | vercel | static. Packages
@@ -104,6 +112,8 @@ Usage:
104
112
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
105
113
  server-only imports in routes/). Run as "done"; --json for agents;
106
114
  --lints-only skips tsc for a near-instant inner-loop pass.
115
+ nifra verify [--release] [--json] Run the shared repository verification gate. --release runs the
116
+ full build, test, coverage, corpus, consumer, and cross-runtime gate.
107
117
  nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
108
118
  structured diagnostics.
109
119
  nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
@@ -121,11 +131,13 @@ Usage:
121
131
  closed. Exits non-zero on any breaking change - run it in CI.
122
132
  nifra sdk --lang <python|go> [--out <file>]
123
133
  Generate a deterministic non-TypeScript SDK from backend.ts.
124
- nifra assure [--config <file>] [--json] [--strict] [--out <file>] [--hydration] [--interact]
125
- Emit one structured assurance bundle. The bundle records every
126
- applicable gate as pass, fail, or skip and exits non-zero unless
127
- its verdict is green. Without these bundle flags, the legacy
128
- route-assurance report remains available.
134
+ nifra assure [--config <file>] [--json] Route-assurance report. Human table by default; --json emits
135
+ the {ok, routes, findings} report for agents.
136
+ nifra assure --bundle [--json] [--strict] [--out <file>] [--hydration] [--interact]
137
+ Emit one structured assurance {version, gates, verdict} bundle
138
+ (always JSON). Records every applicable gate as pass, fail, or
139
+ skip and exits non-zero unless its verdict is green. --strict,
140
+ --hydration, --interact and --out imply --bundle.
129
141
  nifra contracts snapshot [--out <file>] Write the deterministic route contract lock.
130
142
  nifra contracts check [--json] Check the current routes against contracts.lock.json.
131
143
  nifra replay <file> Replay a token-only verification metadata file.
@@ -135,6 +147,8 @@ Usage:
135
147
  nifra capabilities check [--lockfile <file>] [--config <file>] [--json]
136
148
  CI gate: fail on raw effect-import bypasses, declaration/evidence
137
149
  drift, unsafe GET/HEAD writes, idempotency gaps, or lockfile drift.
150
+ nifra capabilities explain <METHOD> <path> [--config <file>] [--json]
151
+ Explain one route's declared capability tokens and static provenance.
138
152
  nifra manifest emit [--out <file>] [--config <file>] [--sign <key-ref>]
139
153
  Emit a deterministic route trust artifact after assurance passes;
140
154
  optionally write an Ed25519 signature via the configured KMS signer.
@@ -172,10 +186,12 @@ Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backe
172
186
  routes/ from the current directory. Run from your project root.
173
187
 
174
188
  Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
175
- \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
189
+ \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).
190
+
191
+ ${renderCommandCatalogHelp()}`
176
192
 
177
193
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
178
- const CLI_VERSION = "2.12.1"
194
+ const CLI_VERSION = "2.14.1"
179
195
 
180
196
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
181
197
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -387,6 +403,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
387
403
  ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
388
404
  poll: flags.poll,
389
405
  port: flags.port,
406
+ ...(flags.allowDuplicateIdentity ? { allowDuplicateIdentity: true } : {}),
390
407
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
391
408
  ...(fw.define ? { define: fw.define } : {}),
392
409
  // `load` resolves route modules through VITE, not through Bun. That is what makes the Vite
@@ -443,6 +460,38 @@ export async function assertUseIsEdgeExported(
443
460
  )
444
461
  }
445
462
 
463
+ /**
464
+ * A production build must not ship a workspace-linked package whose `default`-condition artifact is
465
+ * missing or older than its source. The `"bun": "./src"` / `"default": "./dist"` split means Bun (this
466
+ * build, the tests) reads live source while the deployed app and any node consumer read `dist/` - so a
467
+ * green `nifra build` can bundle stale or absent dist and the drift never shows in a diff. The doctor
468
+ * reports the same skew as an advisory during development; at build time it is a hard failure, because
469
+ * this is the artifact that ships. No-op outside a workspace: tarball-installed deps cannot drift.
470
+ */
471
+ async function assertFreshWorkspaceDists(cwd: string): Promise<void> {
472
+ const pkgPath = resolve(cwd, "package.json")
473
+ if (!existsSync(pkgPath)) return
474
+ let rootPackage: Record<string, unknown>
475
+ try {
476
+ rootPackage = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>
477
+ } catch {
478
+ return // an unreadable/!JSON package.json is a separate problem; don't mask it as a stale-dist failure
479
+ }
480
+ const { collectStaleWorkspaceDists } = await import("./doctor.ts")
481
+ const stale = await collectStaleWorkspaceDists(cwd, rootPackage)
482
+ if (stale.length === 0) return
483
+ const lines = stale.map((f) =>
484
+ f.missing
485
+ ? ` ${f.package}: ${f.distFile} was never built (source ${f.sourceFile})`
486
+ : ` ${f.package}: ${f.distFile} is ${f.behindSeconds}s behind ${f.sourceFile}`,
487
+ )
488
+ throw new Error(
489
+ `[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(
490
+ "\n",
491
+ )}\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.`,
492
+ )
493
+ }
494
+
446
495
  async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
447
496
  const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
448
497
  if (!isBuildTarget(target)) {
@@ -503,6 +552,11 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
503
552
  // The static target needs a built app to drive prerendering - only build it when targeting static.
504
553
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
505
554
  })
555
+ // A production build must not certify itself green while a workspace-linked dependency ships a dist
556
+ // artifact older than (or absent next to) its source: the `bun`->src condition let this compile read
557
+ // live source, but the deployed app and every node consumer read `dist/`. Gate here, after the compile
558
+ // proved the source is buildable, so the failure names the package to rebuild rather than a bundle error.
559
+ await assertFreshWorkspaceDists(cwd)
506
560
  console.log(`nifra build (${target}, ${decision.pipeline}) → ${result.run}`)
507
561
  // The pipeline is stated on every build, not only the surprising ones - an auto-selected Vite build
508
562
  // must never look like the default, and a default Bun build must not leave the question open either.
@@ -573,6 +627,7 @@ export function parseFlags(args: readonly string[]): Flags {
573
627
  let report = false
574
628
  let vite = false
575
629
  let bun = false
630
+ let allowDuplicateIdentity = false
576
631
  for (let i = 0; i < args.length; i++) {
577
632
  const a = args[i]
578
633
  if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
@@ -582,11 +637,28 @@ export function parseFlags(args: readonly string[]): Flags {
582
637
  else if (a === "--report") report = true
583
638
  else if (a === "--vite") vite = true
584
639
  else if (a === "--bun") bun = true
640
+ else if (a === "--allow-duplicate-identity") allowDuplicateIdentity = true
585
641
  }
586
642
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
587
643
  throw new Error(`[nifra] invalid --port: ${port}`)
588
644
  }
589
- return { port, out, poll, target, report, vite, bun }
645
+ return { port, out, poll, target, report, vite, bun, allowDuplicateIdentity }
646
+ }
647
+
648
+ /**
649
+ * `assure` uses the structured-bundle lane only when explicitly asked. A bare `--json` is NOT a
650
+ * request for the bundle - it keeps the legacy `{ ok, routes, findings }` report machine-readable, so a
651
+ * pre-bundle consumer is never silently handed the `{ gates, verdict }` shape. `--bundle` (or a
652
+ * bundle-only flag) opts in; `--bundle --json` still yields the bundle, which is always JSON.
653
+ */
654
+ export function assureBundleRequested(argv: readonly string[]): boolean {
655
+ return (
656
+ argv.includes("--bundle") ||
657
+ argv.includes("--strict") ||
658
+ argv.includes("--hydration") ||
659
+ argv.includes("--interact") ||
660
+ argv.includes("--out")
661
+ )
590
662
  }
591
663
 
592
664
  async function main(): Promise<void> {
@@ -635,43 +707,36 @@ async function main(): Promise<void> {
635
707
  )
636
708
  return
637
709
  }
638
- // `check` is a pure cwd-based gate (typecheck + lint) - it must run even when the project doesn't
639
- // load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
640
- if (command === "check") {
641
- const { runCheck } = await import("./check.ts")
642
- if (
643
- !(await runCheck(process.cwd(), {
644
- json: argv.includes("--json"),
645
- structured: argv.includes("--json"),
646
- lintsOnly: argv.includes("--lints-only"),
647
- }))
648
- )
649
- process.exitCode = 1
650
- return
651
- }
652
- if (command === "fix") {
653
- const codeIndex = argv.indexOf("--code")
654
- const code = codeIndex === -1 ? undefined : argv[codeIndex + 1]
710
+ const catalogSpec = command === undefined ? undefined : findCommandSpec(command)
711
+ if (catalogSpec?.transports.includes("cli")) {
655
712
  try {
656
- const { collectCheckResult } = await import("./check.ts")
657
- const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
658
- const result = await collectCheckResult(process.cwd(), { lintsOnly: true })
659
- const changed: string[] = []
660
- for (const diagnostic of result.structuredDiagnostics ?? []) {
661
- if (code !== undefined && diagnostic.code !== code) continue
662
- changed.push(...(await applyDiagnosticRecipe(process.cwd(), diagnostic)))
713
+ const input = bindCommandArgv(catalogSpec, argv.slice(1))
714
+ const output = await catalogSpec.run(input, { cwd: process.cwd(), cliVersion: CLI_VERSION })
715
+ const asRecord = output as unknown as Record<string, unknown>
716
+ const wantsJson = asRecord.json === true || argv.includes("--json")
717
+ if (wantsJson) {
718
+ const value = catalogSpec.json?.(output, input) ?? output
719
+ console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2))
720
+ } else {
721
+ console.log(catalogSpec.render(output, input).join("\n"))
663
722
  }
664
- const final = await collectCheckResult(process.cwd(), { lintsOnly: true })
665
- console.log(
666
- JSON.stringify({ changed, diagnostics: final.structuredDiagnostics ?? [] }, null, 2),
667
- )
668
- if (final.ok === false) process.exitCode = 1
723
+ if (catalogSpec.success !== undefined && !catalogSpec.success(output, input))
724
+ process.exitCode = 1
669
725
  } catch (err) {
670
726
  console.error(formatCliError(err))
671
727
  process.exitCode = 1
672
728
  }
673
729
  return
674
730
  }
731
+ if (command === "verify") {
732
+ const { runReleaseVerification } = await import("./release-verification.ts")
733
+ const ok = await runReleaseVerification(process.cwd(), {
734
+ mode: argv.includes("--release") ? "release" : "default",
735
+ json: argv.includes("--json"),
736
+ })
737
+ if (!ok) process.exitCode = 1
738
+ return
739
+ }
675
740
  // `init-agents` retrofits the agent-discovery files (.mcp.json, CLAUDE.md, …) into the cwd. It's a
676
741
  // pure file-writing command independent of the app loading, so dispatch it before the eager `loadApp`
677
742
  // (an existing app might be API-only or not yet built). It always succeeds unless a write throws.
@@ -683,75 +748,6 @@ async function main(): Promise<void> {
683
748
  })
684
749
  return
685
750
  }
686
- // `sync-manifest` rewrites a committed web `server-manifest.ts` from the current `routes/` tree, without
687
- // a full build. Pure fs/text (re-scan routes, preserve baked client assets), so - like `check` - dispatch
688
- // it before the eager `loadApp`.
689
- if (command === "sync-manifest") {
690
- const { runSyncManifest } = await import("./sync-manifest.ts")
691
- if (!(await runSyncManifest(process.cwd()))) process.exitCode = 1
692
- return
693
- }
694
- // `sync-routes` regenerates the route-search types `.d.ts` (typed `navigate({ to, search })`) from
695
- // `routes/`. Pure fs/text (needs only the route tree, not `framework.ts`), so dispatch before `loadApp`.
696
- if (command === "sync-routes") {
697
- const { runSyncRoutes } = await import("./sync-routes.ts")
698
- if (!(await runSyncRoutes(process.cwd()))) process.exitCode = 1
699
- return
700
- }
701
- // `doctor` is a pure cwd check (imports vs declared deps) - like `check`, dispatch before `loadApp`
702
- // so it runs on an API-only / not-yet-built project.
703
- if (command === "doctor") {
704
- const { runDoctor } = await import("./doctor.ts")
705
- const targetIdx = argv.indexOf("--target")
706
- const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
707
- if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
708
- console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
709
- process.exitCode = 1
710
- return
711
- }
712
- if (
713
- !(await runDoctor(process.cwd(), {
714
- json: argv.includes("--json"),
715
- autoFix: argv.includes("--auto-fix") || argv.includes("--fix"),
716
- strict: argv.includes("--strict"),
717
- ...(target === undefined ? {} : { target }),
718
- }))
719
- )
720
- process.exitCode = 1
721
- return
722
- }
723
- // `snapshot` / `diff` load ONLY backend.ts (the API contract) - like `check`, they must run on an
724
- // API-only project, so they dispatch before the eager `loadApp`.
725
- if (command === "snapshot") {
726
- const { runSnapshot } = await import("./diff-tool.ts")
727
- const outIdx = argv.indexOf("--out")
728
- const out = outIdx !== -1 ? argv[outIdx + 1] : undefined
729
- if (outIdx !== -1 && (out === undefined || out.startsWith("-"))) {
730
- console.error("[nifra] --out needs a file path")
731
- process.exitCode = 1
732
- return
733
- }
734
- try {
735
- await runSnapshot(process.cwd(), out !== undefined ? { out } : {})
736
- } catch (err) {
737
- console.error(formatCliError(err))
738
- process.exitCode = 1
739
- }
740
- return
741
- }
742
- if (command === "diff") {
743
- const { runDiff, DEFAULT_SNAPSHOT_FILE } = await import("./diff-tool.ts")
744
- const baseline = argv.slice(1).find((arg) => !arg.startsWith("-")) ?? DEFAULT_SNAPSHOT_FILE
745
- try {
746
- if (!(await runDiff(process.cwd(), baseline, { json: argv.includes("--json") }))) {
747
- process.exitCode = 1
748
- }
749
- } catch (err) {
750
- console.error(formatCliError(err))
751
- process.exitCode = 1
752
- }
753
- return
754
- }
755
751
  if (command === "sdk") {
756
752
  const languageIndex = argv.indexOf("--lang")
757
753
  const language = languageIndex === -1 ? undefined : argv[languageIndex + 1]
@@ -776,237 +772,6 @@ async function main(): Promise<void> {
776
772
  }
777
773
  return
778
774
  }
779
- // Route assurance intentionally loads only its explicit config (which imports the backend it audits),
780
- // not the full SSR app. Like snapshot/diff, this works for API-only projects.
781
- if (command === "assure") {
782
- const { runAssurance } = await import("./assure.ts")
783
- const configIdx = argv.indexOf("--config")
784
- const config = configIdx !== -1 ? argv[configIdx + 1] : undefined
785
- if (configIdx !== -1 && (config === undefined || config.startsWith("-"))) {
786
- console.error("[nifra] --config needs a file path")
787
- process.exitCode = 1
788
- return
789
- }
790
- try {
791
- if (
792
- !(await runAssurance(process.cwd(), {
793
- json: argv.includes("--json"),
794
- bundle:
795
- argv.includes("--json") ||
796
- argv.includes("--strict") ||
797
- argv.includes("--hydration") ||
798
- argv.includes("--interact") ||
799
- argv.includes("--out"),
800
- strict: argv.includes("--strict"),
801
- hydration: argv.includes("--hydration"),
802
- interact: argv.includes("--interact"),
803
- ...(argv.includes("--out") ? { out: argv[argv.indexOf("--out") + 1] } : {}),
804
- ...(config !== undefined ? { config } : {}),
805
- }))
806
- )
807
- process.exitCode = 1
808
- } catch (err) {
809
- console.error(formatCliError(err))
810
- process.exitCode = 1
811
- }
812
- return
813
- }
814
- if (command === "contracts") {
815
- const action = argv[1]
816
- const outIndex = argv.indexOf("--out")
817
- const out = outIndex === -1 ? undefined : argv[outIndex + 1]
818
- try {
819
- const { checkContractsLock, snapshotContracts } = await import("./contracts.ts")
820
- if (action === "snapshot") {
821
- const lock = await snapshotContracts(process.cwd(), out)
822
- if (argv.includes("--json")) console.log(JSON.stringify(lock, null, 2))
823
- else console.log(`[nifra] wrote ${Object.keys(lock.routes).length} contract digests`)
824
- } else if (action === "check") {
825
- const result = await checkContractsLock(process.cwd())
826
- const output = { ok: result.present && result.diagnostics.length === 0, ...result }
827
- console.log(JSON.stringify(output, null, 2))
828
- if (!output.ok) process.exitCode = 1
829
- } else {
830
- console.error("[nifra] contracts needs snapshot or check")
831
- process.exitCode = 1
832
- }
833
- } catch (err) {
834
- console.error(formatCliError(err))
835
- process.exitCode = 1
836
- }
837
- return
838
- }
839
- if (command === "replay") {
840
- const file = argv[1]
841
- if (file === undefined || file.startsWith("-")) {
842
- console.error("[nifra] replay needs a file path")
843
- process.exitCode = 1
844
- return
845
- }
846
- try {
847
- const { runReplay } = await import("./replay.ts")
848
- const result = await runReplay(process.cwd(), file)
849
- console.log(JSON.stringify(result, null, 2))
850
- if (!result.ok) process.exitCode = 1
851
- } catch (err) {
852
- console.error(formatCliError(err))
853
- process.exitCode = 1
854
- }
855
- return
856
- }
857
- if (command === "capabilities") {
858
- const action = argv[1]
859
- const valueAfter = (flag: string): string | undefined => {
860
- const index = argv.indexOf(flag)
861
- const value = index === -1 ? undefined : argv[index + 1]
862
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
863
- throw new Error(`[nifra] ${flag} needs a file path`)
864
- }
865
- return value
866
- }
867
- try {
868
- const { runCapabilityCheck, runCapabilitySnapshot } = await import("./capabilities-tool.ts")
869
- const config = valueAfter("--config")
870
- if (action === "snapshot") {
871
- const out = valueAfter("--out")
872
- if (
873
- !(await runCapabilitySnapshot(process.cwd(), {
874
- ...(config !== undefined ? { config } : {}),
875
- ...(out !== undefined ? { out } : {}),
876
- }))
877
- )
878
- process.exitCode = 1
879
- } else if (action === "check") {
880
- const lockfile = valueAfter("--lockfile")
881
- if (
882
- !(await runCapabilityCheck(process.cwd(), {
883
- ...(config !== undefined ? { config } : {}),
884
- ...(lockfile !== undefined ? { lockfile } : {}),
885
- json: argv.includes("--json"),
886
- }))
887
- )
888
- process.exitCode = 1
889
- } else {
890
- throw new Error("[nifra] capabilities needs `snapshot` or `check`")
891
- }
892
- } catch (err) {
893
- console.error(formatCliError(err))
894
- process.exitCode = 1
895
- }
896
- return
897
- }
898
- if (command === "manifest") {
899
- const action = argv[1]
900
- const valueAfter = (flag: string): string | undefined => {
901
- const index = argv.indexOf(flag)
902
- const value = index === -1 ? undefined : argv[index + 1]
903
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
904
- throw new Error(`[nifra] ${flag} needs a value`)
905
- }
906
- return value
907
- }
908
- try {
909
- const { runManifestDiff, runManifestEmit } = await import("./manifest-tool.ts")
910
- if (action === "emit") {
911
- const config = valueAfter("--config")
912
- const out = valueAfter("--out")
913
- const sign = valueAfter("--sign")
914
- if (
915
- !(await runManifestEmit(process.cwd(), {
916
- ...(config !== undefined ? { config } : {}),
917
- ...(out !== undefined ? { out } : {}),
918
- ...(sign !== undefined ? { sign } : {}),
919
- }))
920
- )
921
- process.exitCode = 1
922
- } else if (action === "diff") {
923
- const paths = argv.slice(2).filter((arg) => !arg.startsWith("-"))
924
- if (paths.length !== 2) throw new Error("[nifra] manifest diff needs <before> <after>")
925
- if (
926
- !(await runManifestDiff(process.cwd(), paths[0]!, paths[1]!, {
927
- json: argv.includes("--json"),
928
- }))
929
- )
930
- process.exitCode = 1
931
- } else {
932
- throw new Error("[nifra] manifest needs `emit` or `diff`")
933
- }
934
- } catch (err) {
935
- console.error(formatCliError(err))
936
- process.exitCode = 1
937
- }
938
- return
939
- }
940
- if (command === "levels") {
941
- const valueAfter = (flag: string): string | undefined => {
942
- const index = argv.indexOf(flag)
943
- const value = index === -1 ? undefined : argv[index + 1]
944
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
945
- throw new Error(`[nifra] ${flag} needs a value`)
946
- }
947
- return value
948
- }
949
- try {
950
- const { runLevels } = await import("./levels-tool.ts")
951
- const config = valueAfter("--config")
952
- const minRaw = valueAfter("--min")
953
- const min = minRaw === undefined ? undefined : Number(minRaw)
954
- if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
955
- throw new Error("[nifra] --min must be an integer level between 0 and 4")
956
- }
957
- if (
958
- !(await runLevels(process.cwd(), {
959
- json: argv.includes("--json"),
960
- ...(config !== undefined ? { config } : {}),
961
- ...(min !== undefined ? { min } : {}),
962
- }))
963
- )
964
- process.exitCode = 1
965
- } catch (err) {
966
- console.error(formatCliError(err))
967
- process.exitCode = 1
968
- }
969
- return
970
- }
971
- if (command === "prove") {
972
- const valueAfter = (flag: string): string | undefined => {
973
- const index = argv.indexOf(flag)
974
- const value = index === -1 ? undefined : argv[index + 1]
975
- if (index !== -1 && (value === undefined || value.startsWith("-"))) {
976
- throw new Error(`[nifra] ${flag} needs a value`)
977
- }
978
- return value
979
- }
980
- try {
981
- const minRaw = valueAfter("--min")
982
- const min = minRaw === undefined ? undefined : Number(minRaw)
983
- if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
984
- throw new Error("[nifra] --min must be an integer level between 0 and 4")
985
- }
986
- const changedFiles: string[] = []
987
- for (let index = 0; index < argv.length; index++) {
988
- if (argv[index] !== "--file") continue
989
- const file = argv[index + 1]
990
- if (file === undefined || file.startsWith("-"))
991
- throw new Error("[nifra] --file needs a path")
992
- changedFiles.push(file)
993
- index++
994
- }
995
- const { runWorkGraph } = await import("./work-graph.ts")
996
- if (
997
- !(await runWorkGraph(process.cwd(), {
998
- json: argv.includes("--json"),
999
- ...(changedFiles.length === 0 ? {} : { changedFiles }),
1000
- ...(min === undefined ? {} : { minLevel: min }),
1001
- }))
1002
- )
1003
- process.exitCode = 1
1004
- } catch (err) {
1005
- console.error(formatCliError(err))
1006
- process.exitCode = 1
1007
- }
1008
- return
1009
- }
1010
775
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
1011
776
  // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
1012
777
  // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
@@ -1031,36 +796,7 @@ async function main(): Promise<void> {
1031
796
  }
1032
797
  // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) - like `check`/
1033
798
  // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
1034
- if (command === "port") {
1035
- const { runPort } = await import("./port.ts")
1036
- const targetIdx = argv.indexOf("--target")
1037
- const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
1038
- if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
1039
- console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
1040
- process.exitCode = 1
1041
- return
1042
- }
1043
- try {
1044
- const passed = await runPort(process.cwd(), {
1045
- ...(target !== undefined ? { target } : {}),
1046
- json: argv.includes("--json"),
1047
- ci: argv.includes("--ci"),
1048
- strict: argv.includes("--strict"),
1049
- })
1050
- if (!passed) process.exitCode = 1
1051
- } catch (err) {
1052
- console.error(formatCliError(err))
1053
- process.exitCode = 1
1054
- }
1055
- return
1056
- }
1057
- if (
1058
- command !== "dev" &&
1059
- command !== "build" &&
1060
- command !== "start" &&
1061
- command !== "context" &&
1062
- command !== "routes"
1063
- ) {
799
+ if (command !== "dev" && command !== "build" && command !== "start") {
1064
800
  console.error(`[nifra] unknown command: ${command}\n`)
1065
801
  console.error(HELP)
1066
802
  process.exitCode = 1
@@ -1070,36 +806,7 @@ async function main(): Promise<void> {
1070
806
  const app = await loadApp(process.cwd(), flags.out)
1071
807
  if (command === "dev") await dev(app, flags)
1072
808
  else if (command === "build") await buildForTarget(app, flags.target, flags)
1073
- else if (command === "context") console.log(describeProject(app))
1074
- else if (command === "routes") {
1075
- if (argv.includes("--graph")) {
1076
- console.log(await describeRouteGraph(app, { json: argv.includes("--json") }))
1077
- } else if (argv.includes("--modes")) {
1078
- // `--modes` answers a different question than the default table: not "what URLs exist" but "how
1079
- // does each render, and can the target I'm about to deploy to actually honour that". Resolves
1080
- // against `--target` when given, so a `static` build's server-needing route fails here, in one
1081
- // place, instead of 404ing in production.
1082
- const { buildRouteManifest, renderRouteManifest } = await import(
1083
- "@nifrajs/web/route-manifest"
1084
- )
1085
- const manifest = discoverRoutes(app.routesDir)
1086
- // `flags.target` defaults to "bun" for `build`; here an EXPLICIT `--target` is what opts into
1087
- // gating, so read it from argv rather than inheriting the build default (which would silently gate
1088
- // every `nifra routes --modes` against bun).
1089
- const targetIdx = argv.indexOf("--target")
1090
- const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
1091
- const routeManifest = await buildRouteManifest(manifest, {
1092
- ...(target !== undefined ? { target } : {}),
1093
- })
1094
- if (argv.includes("--json")) console.log(JSON.stringify(routeManifest, null, 2))
1095
- else console.log(renderRouteManifest(routeManifest))
1096
- // A target that cannot honour a route is a build-breaking fact, not a note: exit non-zero so the
1097
- // same command in CI gates the deploy it is warning about.
1098
- if (routeManifest.conflicts.length > 0) process.exitCode = 1
1099
- } else {
1100
- console.log(await describeRoutes(app, { json: argv.includes("--json") }))
1101
- }
1102
- } else await start(app, flags)
809
+ else await start(app, flags)
1103
810
  }
1104
811
 
1105
812
  // Only run the CLI when invoked as the entry (`bun cli.ts …`), not when a test imports it for the