@nifrajs/cli 1.6.0 → 1.12.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 (62) hide show
  1. package/README.md +33 -1
  2. package/dist/assure.d.ts +11 -0
  3. package/dist/assure.d.ts.map +1 -0
  4. package/dist/assure.js +67 -0
  5. package/dist/assure.js.map +1 -0
  6. package/dist/capabilities-tool.d.ts +33 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -0
  8. package/dist/capabilities-tool.js +320 -0
  9. package/dist/capabilities-tool.js.map +1 -0
  10. package/dist/check.d.ts +1 -1
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +136 -11
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +199 -12
  16. package/dist/cli.js.map +1 -1
  17. package/dist/doctor.d.ts +17 -0
  18. package/dist/doctor.d.ts.map +1 -1
  19. package/dist/doctor.js +146 -10
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/levels-tool.d.ts +37 -0
  22. package/dist/levels-tool.d.ts.map +1 -0
  23. package/dist/levels-tool.js +183 -0
  24. package/dist/levels-tool.js.map +1 -0
  25. package/dist/manifest-tool.d.ts +15 -0
  26. package/dist/manifest-tool.d.ts.map +1 -0
  27. package/dist/manifest-tool.js +67 -0
  28. package/dist/manifest-tool.js.map +1 -0
  29. package/dist/mcp-http.js +1 -1
  30. package/dist/mcp-http.js.map +1 -1
  31. package/dist/mcp.d.ts +1 -0
  32. package/dist/mcp.d.ts.map +1 -1
  33. package/dist/mcp.js +31 -0
  34. package/dist/mcp.js.map +1 -1
  35. package/dist/recipes/1.8.0.d.ts +12 -0
  36. package/dist/recipes/1.8.0.d.ts.map +1 -0
  37. package/dist/recipes/1.8.0.js +20 -0
  38. package/dist/recipes/1.8.0.js.map +1 -0
  39. package/dist/recipes/index.d.ts +28 -0
  40. package/dist/recipes/index.d.ts.map +1 -0
  41. package/dist/recipes/index.js +27 -0
  42. package/dist/recipes/index.js.map +1 -0
  43. package/dist/upgrade.d.ts +82 -0
  44. package/dist/upgrade.d.ts.map +1 -0
  45. package/dist/upgrade.js +220 -0
  46. package/dist/upgrade.js.map +1 -0
  47. package/docs/examples.json +12 -0
  48. package/docs/llms-full.txt +524 -15
  49. package/docs/types.json +2409 -50
  50. package/package.json +9 -8
  51. package/src/assure.ts +88 -0
  52. package/src/capabilities-tool.ts +406 -0
  53. package/src/check.ts +155 -12
  54. package/src/cli.ts +206 -17
  55. package/src/doctor.ts +179 -11
  56. package/src/levels-tool.ts +232 -0
  57. package/src/manifest-tool.ts +103 -0
  58. package/src/mcp-http.ts +1 -1
  59. package/src/mcp.ts +37 -0
  60. package/src/recipes/1.8.0.ts +21 -0
  61. package/src/recipes/index.ts +52 -0
  62. package/src/upgrade.ts +269 -0
package/src/check.ts CHANGED
@@ -16,8 +16,8 @@
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
18
 
19
- import { readFileSync } from "node:fs"
20
- import { dirname, isAbsolute, join } from "node:path"
19
+ import { existsSync, readFileSync } from "node:fs"
20
+ import { dirname, isAbsolute, join, resolve } from "node:path"
21
21
  import { Glob } from "bun"
22
22
 
23
23
  export interface SourceFinding {
@@ -195,8 +195,13 @@ function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefined {
195
195
  return { path, method, start: match.index, end: close + 1 }
196
196
  }
197
197
 
198
- function isPotentialNifraBackendSource(code: string): boolean {
199
- return code.includes("@nifrajs/core") || /(?<![.\w])server\s*\(/.test(code)
198
+ function isPotentialBackendSource(code: string): boolean {
199
+ return (
200
+ code.includes("@nifrajs/core") ||
201
+ /(?<![.\w])server\s*\(/.test(code) ||
202
+ /(?:from\s*|import\s*)["']hono["']/.test(code) ||
203
+ /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
204
+ )
200
205
  }
201
206
 
202
207
  function scanRoutePattern(
@@ -289,7 +294,7 @@ export function scanFetchText(file: string, content: string): SourceFinding[] {
289
294
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
290
295
  export function scanStaticRouteText(file: string, content: string): StaticRouteFinding[] {
291
296
  const code = stripComments(content)
292
- if (!isPotentialNifraBackendSource(code)) return []
297
+ if (!isPotentialBackendSource(code)) return []
293
298
  return [
294
299
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
295
300
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
@@ -765,7 +770,11 @@ export interface CheckDiagnostic {
765
770
  | "server-only-import"
766
771
  | "response-route"
767
772
  | "undeclared-dependency"
773
+ | "duplicate-install"
768
774
  | "server-manifest-drift"
775
+ | "manifest-drift"
776
+ | "capability-assurance"
777
+ | "capability-config"
769
778
  /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
770
779
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
771
780
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
@@ -826,6 +835,10 @@ const UNDECLARED_DEP_HINT =
826
835
  "imported package is not declared in package.json dependencies — run bun add to declare it"
827
836
  const MANIFEST_DRIFT_HINT =
828
837
  "server-manifest.ts is out of sync with routes/ — re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it"
838
+ const TRUST_MANIFEST_DRIFT_HINT =
839
+ "nifra.manifest.json is missing, invalid, or out of sync — run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
840
+ const CAPABILITY_HINT =
841
+ "effect/capability assurance failed — align the route declaration with approved adapter provenance; never bypass an owned effect seam"
829
842
 
830
843
  function oneLineDiff(file: string, line: number, before: string, after: string): string {
831
844
  return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
@@ -1127,6 +1140,24 @@ export async function collectCheckResult(
1127
1140
  },
1128
1141
  })
1129
1142
  }
1143
+ for (const finding of dr.duplicateInstalls) {
1144
+ const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
1145
+ diagnostics.push({
1146
+ rule: "duplicate-install",
1147
+ severity: "error",
1148
+ message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe`,
1149
+ fix: "align dependency ranges and reinstall from the workspace root",
1150
+ suggestion: {
1151
+ kind: "manual",
1152
+ title: `Deduplicate ${finding.package}`,
1153
+ steps: [
1154
+ "Align workspace dependency and peer ranges on one compatible version.",
1155
+ "Remove stale nested installs and run the package manager from the workspace root.",
1156
+ "Run `nifra doctor` again; do not suppress same-version duplicate paths.",
1157
+ ],
1158
+ },
1159
+ })
1160
+ }
1130
1161
  }
1131
1162
  // #7: a committed server-manifest.ts that drifted from routes/ — name the exact missing/extra routes.
1132
1163
  for (const f of manifestDrift) {
@@ -1150,19 +1181,127 @@ export async function collectCheckResult(
1150
1181
  })
1151
1182
  }
1152
1183
 
1184
+ // G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
1185
+ // provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
1186
+ // without nifra.assurance.ts retain the historical scan and hot path unchanged.
1187
+ const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
1188
+ if (existsSync(assuranceConfigPath)) {
1189
+ try {
1190
+ const { loadAssuranceConfig } = await import("./assure.ts")
1191
+ const config = await loadAssuranceConfig(cwd)
1192
+ let capabilityReport:
1193
+ | Awaited<
1194
+ ReturnType<typeof import("./capabilities-tool.ts")["collectCapabilityProjectReport"]>
1195
+ >["report"]
1196
+ | undefined
1197
+ if (config.capabilities !== undefined) {
1198
+ const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
1199
+ const project = await collectCapabilityProjectReport(
1200
+ cwd,
1201
+ config.source,
1202
+ config.capabilities,
1203
+ )
1204
+ capabilityReport = project.report
1205
+ for (const finding of project.report.findings) {
1206
+ const violation =
1207
+ finding.code === "forbidden-effect-import"
1208
+ ? project.violations.find(
1209
+ (candidate) =>
1210
+ candidate.method === finding.method && candidate.path === finding.path,
1211
+ )
1212
+ : undefined
1213
+ diagnostics.push({
1214
+ rule: "capability-assurance",
1215
+ severity: "error",
1216
+ ...(violation !== undefined ? { file: violation.module, chain: violation.chain } : {}),
1217
+ message: `${finding.message} — ${CAPABILITY_HINT}`,
1218
+ fix: CAPABILITY_HINT,
1219
+ suggestion: {
1220
+ kind: "manual",
1221
+ title: "Restore declared effect provenance",
1222
+ steps: [
1223
+ "Route effectful work through an import listed in capabilities.provenance.imports.",
1224
+ "Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
1225
+ "For domain writes, add the request-idempotency or durable-command adapter required by the capability definition.",
1226
+ "Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
1227
+ ],
1228
+ },
1229
+ })
1230
+ }
1231
+ }
1232
+ if (config.manifest !== undefined) {
1233
+ const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance")
1234
+ const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
1235
+ "@nifrajs/core/manifest"
1236
+ )
1237
+ const assurance = evaluateRouteAssurance(config.source, config.policy)
1238
+ const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
1239
+ let message: string | undefined
1240
+ if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
1241
+ message =
1242
+ "the configured assurance policy is failing, so a trusted manifest cannot be built"
1243
+ } else if (!existsSync(path)) {
1244
+ message = "the configured trust manifest is missing"
1245
+ } else {
1246
+ try {
1247
+ const current = await buildNifraManifest({
1248
+ source: config.source,
1249
+ assurance,
1250
+ ...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
1251
+ })
1252
+ const storedText = await Bun.file(path).text()
1253
+ const stored = await parseNifraManifest(storedText, path)
1254
+ const expectedText = `${serializeNifraManifest(current)}\n`
1255
+ if (storedText !== expectedText || stored.contentHash !== current.contentHash) {
1256
+ message = "the configured trust manifest does not match live route reflection"
1257
+ }
1258
+ } catch (error) {
1259
+ message = `the configured trust manifest is invalid: ${error instanceof Error ? error.message : String(error)}`
1260
+ }
1261
+ }
1262
+ if (message !== undefined) {
1263
+ diagnostics.push({
1264
+ rule: "manifest-drift",
1265
+ severity: "error",
1266
+ file: path,
1267
+ message: `${message} — ${TRUST_MANIFEST_DRIFT_HINT}`,
1268
+ fix: TRUST_MANIFEST_DRIFT_HINT,
1269
+ suggestion: {
1270
+ kind: "command",
1271
+ title: "Regenerate the signed-manifest input artifact",
1272
+ command: ["nifra", "manifest", "emit"],
1273
+ steps: [
1274
+ "Review the route, assurance, capability, and classification delta before committing it.",
1275
+ ],
1276
+ },
1277
+ })
1278
+ }
1279
+ }
1280
+ } catch (err) {
1281
+ diagnostics.push({
1282
+ rule: "capability-config",
1283
+ severity: "error",
1284
+ file: "nifra.assurance.ts",
1285
+ message: `capability assurance config could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
1286
+ suggestion: {
1287
+ kind: "manual",
1288
+ title: "Repair the assurance config",
1289
+ steps: [
1290
+ "Ensure nifra.assurance.ts default-exports defineAssuranceConfig({ source, policy, capabilities }).",
1291
+ "Fix configuration/import errors; the provenance firewall fails closed when its policy cannot load.",
1292
+ ],
1293
+ },
1294
+ })
1295
+ }
1296
+ }
1297
+
1153
1298
  // Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
1154
1299
  // message that breaks the stdio transport. `ok` reflects the FULL set — truncation never flips it.
1155
1300
  const total = diagnostics.length
1156
1301
  const max = opts.maxDiagnostics
1157
1302
  const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics
1158
1303
  return {
1159
- ok:
1160
- tc.ok &&
1161
- fetches.length === 0 &&
1162
- untypedClients.length === 0 &&
1163
- serverImports.length === 0 &&
1164
- manifestDrift.length === 0 &&
1165
- (!dr.ran || dr.findings.length === 0),
1304
+ ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
1166
1305
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
1167
1306
  diagnostics: shown,
1168
1307
  ...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
@@ -1197,7 +1336,11 @@ export async function runCheck(
1197
1336
  ["server-only-import", "server-only import in a route module"],
1198
1337
  ["response-route", "route returns a raw Response (typed client → data: never)"],
1199
1338
  ["undeclared-dependency", "undeclared dependency in package.json"],
1339
+ ["duplicate-install", "duplicate identity-sensitive dependency install"],
1200
1340
  ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
1341
+ ["manifest-drift", "versioned trust manifest drift"],
1342
+ ["capability-assurance", "effect/capability assurance"],
1343
+ ["capability-config", "capability assurance config"],
1201
1344
  ] as const) {
1202
1345
  const ds = counts(rule)
1203
1346
  if (rule === "response-route") {
package/src/cli.ts CHANGED
@@ -59,7 +59,7 @@ Usage:
59
59
  agent: nifra_context, nifra_routes (routes+schemas as JSON),
60
60
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
61
61
  nifra_example (verified snippets), nifra_scaffold (route→file),
62
- nifra_check (drift gate + fixes), nifra_doctor (undeclared deps).
62
+ nifra_check (drift gate + fixes), nifra_doctor (deps + duplicate installs).
63
63
  nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
64
64
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
65
65
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
@@ -72,9 +72,34 @@ Usage:
72
72
  request field or a removed response field breaks; widening a
73
73
  request enum or adding a response field doesn't) and fails
74
74
  closed. Exits non-zero on any breaking change — run it in CI.
75
- nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
76
- (resolve at Bun runtime, break tsc + standalone install);
75
+ nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
76
+ reflected backend route, and fail when required enforcement
77
+ evidence is missing/forbidden or a route is unclassified.
78
+ nifra capabilities snapshot [--out <file>] [--config <file>]
79
+ Write the deterministic, token-only capability lockfile, but
80
+ only after provenance + idempotency assurance passes.
81
+ nifra capabilities check [--lockfile <file>] [--config <file>] [--json]
82
+ CI gate: fail on raw effect-import bypasses, declaration/evidence
83
+ drift, unsafe GET/HEAD writes, idempotency gaps, or lockfile drift.
84
+ nifra manifest emit [--out <file>] [--config <file>] [--sign <key-ref>]
85
+ Emit a deterministic route trust artifact after assurance passes;
86
+ optionally write an Ed25519 signature via the configured KMS signer.
87
+ nifra manifest diff <before> <after> [--json]
88
+ Hash-verify and compare two manifests; fail on contract, assurance,
89
+ capability, or response-classification regressions.
90
+ nifra levels [--json] [--min <n>] [--config <file>]
91
+ Compute the verification ladder (L0 typed contract → L1 route
92
+ assurance → L2 capability lockfile → L3 route manifest → L4
93
+ invariant-tested); --min <n> fails the exit code below level n.
94
+ nifra doctor [--json] [--auto-fix] Flag undeclared imports and multiple physical copies of
95
+ @nifrajs/core/React identity-sensitive dependencies;
77
96
  --auto-fix writes safe local-version dependency fixes.
97
+ nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
98
+ [--write] [--no-verify] matching dependency pin (preserving ^/~/exact) + apply exact
99
+ [--list] [--json] import moves across the workspace, then verify with nifra check.
100
+ Dry-run by default; --write applies then verifies (--no-verify
101
+ to skip). --list shows available targets. Fail-closed on an
102
+ unknown version. Deterministic + idempotent.
78
103
  nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
79
104
  [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
80
105
  globals, node: builtins) with file:line evidence. --target auto-
@@ -89,7 +114,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
89
114
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
90
115
 
91
116
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
92
- const CLI_VERSION = "1.6.0"
117
+ const CLI_VERSION = "1.12.0"
93
118
 
94
119
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
95
120
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -181,24 +206,28 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
181
206
  if (report) console.log(`\n${renderSizeReport(result.size)}`)
182
207
  }
183
208
 
184
- /** Build a fetch-capable app instance for the `static` target's prerender pass. Mirrors `nifra start`:
185
- * register the framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import), discover routes,
186
- * and wire the in-process backend so build-time loaders can read data. The returned `Server` already
187
- * satisfies `buildTarget`'s `PrerenderAppLike` (a `fetch(req)` yielding a `Response`). */
209
+ /** Build the app FACTORY for the `static` target's prerender pass. Mirrors `nifra start`: register the
210
+ * framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import) once, then return a factory that
211
+ * `createWebApp`s the app for the client build's manifest. The client entry MUST be the real content-hashed
212
+ * bundle (`client.entry`) — it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
213
+ * would 404 and the pages would render but never hydrate (inert controls). Styles/route-preload are wired
214
+ * from the same manifest so the static HTML matches what `nifra start` serves. */
188
215
  async function buildPrerenderApp(
189
216
  app: LoadedApp,
190
- ): Promise<{ fetch(req: Request): Response | Promise<Response> }> {
217
+ ): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
191
218
  const { plugin } = await import("bun")
192
219
  const { framework: fw, routesDir, backend } = app
193
220
  for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
194
- return createWebApp({
195
- adapter: asAdapter(fw.adapter),
196
- manifest: discoverRoutes(routesDir),
197
- // The client entry is irrelevant to the static HTML (it's a hydration script tag) — a placeholder
198
- // is fine; the real hashed bundle is emitted to /assets by buildTarget's client build.
199
- clientEntry: "/assets/_nifra-entry.js",
200
- ...apiOf(backend),
201
- })
221
+ return (client) =>
222
+ createWebApp({
223
+ adapter: asAdapter(fw.adapter),
224
+ manifest: discoverRoutes(routesDir),
225
+ clientEntry: client.entry,
226
+ ...(client.routes ? { routePreload: client.routes } : {}),
227
+ ...(client.css ? { styles: client.css } : {}),
228
+ ...(client.routeStyles ? { routeStyles: client.routeStyles } : {}),
229
+ ...apiOf(backend),
230
+ })
202
231
  }
203
232
 
204
233
  interface BuiltManifest {
@@ -371,6 +400,166 @@ async function main(): Promise<void> {
371
400
  }
372
401
  return
373
402
  }
403
+ // Route assurance intentionally loads only its explicit config (which imports the backend it audits),
404
+ // not the full SSR app. Like snapshot/diff, this works for API-only projects.
405
+ if (command === "assure") {
406
+ const { runAssurance } = await import("./assure.ts")
407
+ const configIdx = argv.indexOf("--config")
408
+ const config = configIdx !== -1 ? argv[configIdx + 1] : undefined
409
+ if (configIdx !== -1 && (config === undefined || config.startsWith("-"))) {
410
+ console.error("[nifra] --config needs a file path")
411
+ process.exitCode = 1
412
+ return
413
+ }
414
+ try {
415
+ if (
416
+ !(await runAssurance(process.cwd(), {
417
+ json: argv.includes("--json"),
418
+ ...(config !== undefined ? { config } : {}),
419
+ }))
420
+ )
421
+ process.exitCode = 1
422
+ } catch (err) {
423
+ console.error(err instanceof Error ? err.message : String(err))
424
+ process.exitCode = 1
425
+ }
426
+ return
427
+ }
428
+ if (command === "capabilities") {
429
+ const action = argv[1]
430
+ const valueAfter = (flag: string): string | undefined => {
431
+ const index = argv.indexOf(flag)
432
+ const value = index === -1 ? undefined : argv[index + 1]
433
+ if (index !== -1 && (value === undefined || value.startsWith("-"))) {
434
+ throw new Error(`[nifra] ${flag} needs a file path`)
435
+ }
436
+ return value
437
+ }
438
+ try {
439
+ const { runCapabilityCheck, runCapabilitySnapshot } = await import("./capabilities-tool.ts")
440
+ const config = valueAfter("--config")
441
+ if (action === "snapshot") {
442
+ const out = valueAfter("--out")
443
+ if (
444
+ !(await runCapabilitySnapshot(process.cwd(), {
445
+ ...(config !== undefined ? { config } : {}),
446
+ ...(out !== undefined ? { out } : {}),
447
+ }))
448
+ )
449
+ process.exitCode = 1
450
+ } else if (action === "check") {
451
+ const lockfile = valueAfter("--lockfile")
452
+ if (
453
+ !(await runCapabilityCheck(process.cwd(), {
454
+ ...(config !== undefined ? { config } : {}),
455
+ ...(lockfile !== undefined ? { lockfile } : {}),
456
+ json: argv.includes("--json"),
457
+ }))
458
+ )
459
+ process.exitCode = 1
460
+ } else {
461
+ throw new Error("[nifra] capabilities needs `snapshot` or `check`")
462
+ }
463
+ } catch (err) {
464
+ console.error(err instanceof Error ? err.message : String(err))
465
+ process.exitCode = 1
466
+ }
467
+ return
468
+ }
469
+ if (command === "manifest") {
470
+ const action = argv[1]
471
+ const valueAfter = (flag: string): string | undefined => {
472
+ const index = argv.indexOf(flag)
473
+ const value = index === -1 ? undefined : argv[index + 1]
474
+ if (index !== -1 && (value === undefined || value.startsWith("-"))) {
475
+ throw new Error(`[nifra] ${flag} needs a value`)
476
+ }
477
+ return value
478
+ }
479
+ try {
480
+ const { runManifestDiff, runManifestEmit } = await import("./manifest-tool.ts")
481
+ if (action === "emit") {
482
+ const config = valueAfter("--config")
483
+ const out = valueAfter("--out")
484
+ const sign = valueAfter("--sign")
485
+ if (
486
+ !(await runManifestEmit(process.cwd(), {
487
+ ...(config !== undefined ? { config } : {}),
488
+ ...(out !== undefined ? { out } : {}),
489
+ ...(sign !== undefined ? { sign } : {}),
490
+ }))
491
+ )
492
+ process.exitCode = 1
493
+ } else if (action === "diff") {
494
+ const paths = argv.slice(2).filter((arg) => !arg.startsWith("-"))
495
+ if (paths.length !== 2) throw new Error("[nifra] manifest diff needs <before> <after>")
496
+ if (
497
+ !(await runManifestDiff(process.cwd(), paths[0]!, paths[1]!, {
498
+ json: argv.includes("--json"),
499
+ }))
500
+ )
501
+ process.exitCode = 1
502
+ } else {
503
+ throw new Error("[nifra] manifest needs `emit` or `diff`")
504
+ }
505
+ } catch (err) {
506
+ console.error(err instanceof Error ? err.message : String(err))
507
+ process.exitCode = 1
508
+ }
509
+ return
510
+ }
511
+ if (command === "levels") {
512
+ const valueAfter = (flag: string): string | undefined => {
513
+ const index = argv.indexOf(flag)
514
+ const value = index === -1 ? undefined : argv[index + 1]
515
+ if (index !== -1 && (value === undefined || value.startsWith("-"))) {
516
+ throw new Error(`[nifra] ${flag} needs a value`)
517
+ }
518
+ return value
519
+ }
520
+ try {
521
+ const { runLevels } = await import("./levels-tool.ts")
522
+ const config = valueAfter("--config")
523
+ const minRaw = valueAfter("--min")
524
+ const min = minRaw === undefined ? undefined : Number(minRaw)
525
+ if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
526
+ throw new Error("[nifra] --min must be an integer level between 0 and 4")
527
+ }
528
+ if (
529
+ !(await runLevels(process.cwd(), {
530
+ json: argv.includes("--json"),
531
+ ...(config !== undefined ? { config } : {}),
532
+ ...(min !== undefined ? { min } : {}),
533
+ }))
534
+ )
535
+ process.exitCode = 1
536
+ } catch (err) {
537
+ console.error(err instanceof Error ? err.message : String(err))
538
+ process.exitCode = 1
539
+ }
540
+ return
541
+ }
542
+ // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
543
+ // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` — it must run on any
544
+ // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
545
+ if (command === "upgrade") {
546
+ const { runUpgrade } = await import("./upgrade.ts")
547
+ const version = argv.slice(1).find((arg) => !arg.startsWith("-"))
548
+ try {
549
+ const ok = await runUpgrade(process.cwd(), {
550
+ ...(version !== undefined ? { version } : {}),
551
+ write: argv.includes("--write"),
552
+ json: argv.includes("--json"),
553
+ list: argv.includes("--list"),
554
+ verify: !argv.includes("--no-verify"),
555
+ })
556
+ if (!ok) process.exitCode = 1
557
+ } catch (err) {
558
+ console.error(err instanceof Error ? err.message : String(err))
559
+ process.exitCode = 1
560
+ }
561
+ return
562
+ }
374
563
  // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
375
564
  // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
376
565
  if (command === "port") {