@nifrajs/cli 2.14.1 → 3.2.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 (203) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2346
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +285 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +27 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +142 -2
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/doctor.d.ts +42 -0
  28. package/dist/doctor.d.ts.map +1 -1
  29. package/dist/doctor.js +64 -7
  30. package/dist/doctor.js.map +1 -1
  31. package/dist/env-file.d.ts +17 -0
  32. package/dist/env-file.d.ts.map +1 -0
  33. package/dist/env-file.js +113 -0
  34. package/dist/env-file.js.map +1 -0
  35. package/dist/fix-recipes.d.ts.map +1 -1
  36. package/dist/fix-recipes.js +91 -15
  37. package/dist/fix-recipes.js.map +1 -1
  38. package/dist/frontend-guidance.d.ts +57 -0
  39. package/dist/frontend-guidance.d.ts.map +1 -0
  40. package/dist/frontend-guidance.js +195 -0
  41. package/dist/frontend-guidance.js.map +1 -0
  42. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  43. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  44. package/dist/internal/reserved-segment-codemod.js +134 -0
  45. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  46. package/dist/internal/typescript-import.d.ts +1 -1
  47. package/dist/internal/typescript-import.d.ts.map +1 -1
  48. package/dist/internal/typescript-import.js +62 -11
  49. package/dist/internal/typescript-import.js.map +1 -1
  50. package/dist/introspect.d.ts +26 -1
  51. package/dist/introspect.d.ts.map +1 -1
  52. package/dist/introspect.js +74 -24
  53. package/dist/introspect.js.map +1 -1
  54. package/dist/levels-tool.d.ts.map +1 -1
  55. package/dist/levels-tool.js +6 -0
  56. package/dist/levels-tool.js.map +1 -1
  57. package/dist/load.d.ts.map +1 -1
  58. package/dist/load.js +4 -1
  59. package/dist/load.js.map +1 -1
  60. package/dist/mcp-context.d.ts +48 -0
  61. package/dist/mcp-context.d.ts.map +1 -0
  62. package/dist/mcp-context.js +215 -0
  63. package/dist/mcp-context.js.map +1 -0
  64. package/dist/mcp-docs-tools.d.ts.map +1 -1
  65. package/dist/mcp-docs-tools.js +31 -0
  66. package/dist/mcp-docs-tools.js.map +1 -1
  67. package/dist/mcp-exec.d.ts +74 -0
  68. package/dist/mcp-exec.d.ts.map +1 -0
  69. package/dist/mcp-exec.js +844 -0
  70. package/dist/mcp-exec.js.map +1 -0
  71. package/dist/mcp-http.js +1 -1
  72. package/dist/mcp-http.js.map +1 -1
  73. package/dist/mcp-io.d.ts +26 -0
  74. package/dist/mcp-io.d.ts.map +1 -0
  75. package/dist/mcp-io.js +100 -0
  76. package/dist/mcp-io.js.map +1 -0
  77. package/dist/mcp-reflect.d.ts +16 -0
  78. package/dist/mcp-reflect.d.ts.map +1 -0
  79. package/dist/mcp-reflect.js +101 -0
  80. package/dist/mcp-reflect.js.map +1 -0
  81. package/dist/mcp-root.d.ts +7 -0
  82. package/dist/mcp-root.d.ts.map +1 -1
  83. package/dist/mcp-root.js +51 -3
  84. package/dist/mcp-root.js.map +1 -1
  85. package/dist/mcp.d.ts +10 -84
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +13 -1170
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/openapi-tool.d.ts +5 -10
  90. package/dist/openapi-tool.d.ts.map +1 -1
  91. package/dist/openapi-tool.js +21 -2
  92. package/dist/openapi-tool.js.map +1 -1
  93. package/dist/openapi-types.d.ts +21 -0
  94. package/dist/openapi-types.d.ts.map +1 -0
  95. package/dist/openapi-types.js +214 -0
  96. package/dist/openapi-types.js.map +1 -0
  97. package/dist/port.d.ts.map +1 -1
  98. package/dist/port.js +2 -1
  99. package/dist/port.js.map +1 -1
  100. package/dist/project-facts.d.ts +2 -1
  101. package/dist/project-facts.d.ts.map +1 -1
  102. package/dist/project-facts.js.map +1 -1
  103. package/dist/recipes/3.0.0.d.ts +10 -0
  104. package/dist/recipes/3.0.0.d.ts.map +1 -0
  105. package/dist/recipes/3.0.0.js +23 -0
  106. package/dist/recipes/3.0.0.js.map +1 -0
  107. package/dist/recipes/index.d.ts.map +1 -1
  108. package/dist/recipes/index.js +2 -0
  109. package/dist/recipes/index.js.map +1 -1
  110. package/dist/release-verification.d.ts +4 -1
  111. package/dist/release-verification.d.ts.map +1 -1
  112. package/dist/release-verification.js +6 -123
  113. package/dist/release-verification.js.map +1 -1
  114. package/dist/rules/codes.d.ts +4 -0
  115. package/dist/rules/codes.d.ts.map +1 -1
  116. package/dist/rules/codes.js +4 -0
  117. package/dist/rules/codes.js.map +1 -1
  118. package/dist/rules/islands.d.ts +4 -0
  119. package/dist/rules/islands.d.ts.map +1 -0
  120. package/dist/rules/islands.js +155 -0
  121. package/dist/rules/islands.js.map +1 -0
  122. package/dist/rules/nano.d.ts +6 -0
  123. package/dist/rules/nano.d.ts.map +1 -0
  124. package/dist/rules/nano.js +264 -0
  125. package/dist/rules/nano.js.map +1 -0
  126. package/dist/rules/routes.d.ts.map +1 -1
  127. package/dist/rules/routes.js +17 -30
  128. package/dist/rules/routes.js.map +1 -1
  129. package/dist/rules/security.d.ts.map +1 -1
  130. package/dist/rules/security.js +38 -4
  131. package/dist/rules/security.js.map +1 -1
  132. package/dist/scaffold.d.ts +12 -6
  133. package/dist/scaffold.d.ts.map +1 -1
  134. package/dist/scaffold.js +149 -10
  135. package/dist/scaffold.js.map +1 -1
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -3
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/stylex-migrate.d.ts +38 -0
  140. package/dist/stylex-migrate.d.ts.map +1 -0
  141. package/dist/stylex-migrate.js +1052 -0
  142. package/dist/stylex-migrate.js.map +1 -0
  143. package/dist/sync-manifest.d.ts.map +1 -1
  144. package/dist/sync-manifest.js +2 -1
  145. package/dist/sync-manifest.js.map +1 -1
  146. package/dist/verification-plan.d.ts +24 -0
  147. package/dist/verification-plan.d.ts.map +1 -0
  148. package/dist/verification-plan.js +116 -0
  149. package/dist/verification-plan.js.map +1 -0
  150. package/dist/work-graph.d.ts.map +1 -1
  151. package/dist/work-graph.js +6 -3
  152. package/dist/work-graph.js.map +1 -1
  153. package/dist/workspace-link.d.ts +37 -0
  154. package/dist/workspace-link.d.ts.map +1 -0
  155. package/dist/workspace-link.js +72 -0
  156. package/dist/workspace-link.js.map +1 -0
  157. package/docs/examples.json +37 -1
  158. package/docs/llms-full.txt +1598 -44
  159. package/docs/types.json +5157 -172
  160. package/package.json +9 -9
  161. package/src/assure-hydration.ts +6 -1
  162. package/src/assure.ts +4 -2
  163. package/src/check-diagnostics.ts +1219 -0
  164. package/src/check-scan.ts +1825 -0
  165. package/src/check.ts +47 -2731
  166. package/src/cli.ts +291 -10
  167. package/src/command-catalog.ts +177 -2
  168. package/src/doctor.ts +99 -8
  169. package/src/env-file.ts +121 -0
  170. package/src/fix-recipes.ts +101 -15
  171. package/src/frontend-guidance.ts +259 -0
  172. package/src/internal/reserved-segment-codemod.ts +150 -0
  173. package/src/internal/typescript-import.ts +61 -11
  174. package/src/introspect.ts +82 -21
  175. package/src/levels-tool.ts +6 -0
  176. package/src/load.ts +4 -1
  177. package/src/mcp-context.ts +266 -0
  178. package/src/mcp-docs-tools.ts +37 -0
  179. package/src/mcp-exec.ts +1040 -0
  180. package/src/mcp-http.ts +1 -1
  181. package/src/mcp-io.ts +118 -0
  182. package/src/mcp-reflect.ts +140 -0
  183. package/src/mcp-root.ts +62 -4
  184. package/src/mcp.ts +61 -1436
  185. package/src/openapi-tool.ts +32 -2
  186. package/src/openapi-types.ts +261 -0
  187. package/src/port.ts +2 -1
  188. package/src/project-facts.ts +2 -4
  189. package/src/recipes/3.0.0.ts +24 -0
  190. package/src/recipes/index.ts +2 -0
  191. package/src/release-verification.ts +22 -147
  192. package/src/rules/codes.ts +4 -0
  193. package/src/rules/islands.ts +171 -0
  194. package/src/rules/nano.ts +295 -0
  195. package/src/rules/routes.ts +17 -30
  196. package/src/rules/security.ts +40 -4
  197. package/src/scaffold.ts +169 -9
  198. package/src/search-terms.ts +7 -3
  199. package/src/stylex-migrate.ts +1139 -0
  200. package/src/sync-manifest.ts +2 -1
  201. package/src/verification-plan.ts +242 -0
  202. package/src/work-graph.ts +6 -3
  203. package/src/workspace-link.ts +80 -0
package/src/cli.ts CHANGED
@@ -9,8 +9,9 @@
9
9
  *
10
10
  * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
11
11
  */
12
- import { existsSync, readFileSync } from "node:fs"
12
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
13
13
  import { resolve } from "node:path"
14
+ import { fileURLToPath } from "node:url"
14
15
  import { inProcessClient } from "@nifrajs/client"
15
16
  import {
16
17
  type CreateWebAppOptions,
@@ -21,6 +22,7 @@ import {
21
22
  import { discoverRoutes } from "@nifrajs/web/fs"
22
23
  import type { BunPlugin } from "bun"
23
24
  import { bindCommandArgv, findCommandSpec, renderCommandCatalogHelp } from "./command-catalog.ts"
25
+ import { applyEnvFiles, takeEnvFileFlags } from "./env-file.ts"
24
26
  import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
25
27
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
26
28
 
@@ -46,6 +48,23 @@ export interface Flags {
46
48
  readonly allowDuplicateIdentity: boolean
47
49
  }
48
50
 
51
+ /** Forward a re-exec'd Bun child's output without relying on Windows inheriting a pipe-of-a-pipe. */
52
+ async function forwardChildOutput(
53
+ stream: ReadableStream<Uint8Array>,
54
+ sink: { write(chunk: Uint8Array): unknown },
55
+ ): Promise<void> {
56
+ const reader = stream.getReader()
57
+ try {
58
+ for (;;) {
59
+ const { done, value } = await reader.read()
60
+ if (done) return
61
+ await sink.write(value)
62
+ }
63
+ } finally {
64
+ reader.releaseLock()
65
+ }
66
+ }
67
+
49
68
  const HELP = `nifra - zero-config dev/build/start for a nifra app
50
69
 
51
70
  Usage:
@@ -188,10 +207,16 @@ routes/ from the current directory. Run from your project root.
188
207
  Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
189
208
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).
190
209
 
210
+ Env: \`--env-file <path>\` works on EVERY command and may be repeated (later files win). Commands that
211
+ reflect your app import it, so an app that validates its environment at module scope needs that
212
+ environment present - without it the app aborts before nifra runs a single check. A variable already
213
+ set in the process environment is never overwritten by a file.
214
+ e.g. \`nifra check --env-file .env.local\`
215
+
191
216
  ${renderCommandCatalogHelp()}`
192
217
 
193
218
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
194
- const CLI_VERSION = "2.14.1"
219
+ const CLI_VERSION = "3.2.0"
195
220
 
196
221
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
197
222
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -275,6 +300,11 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
275
300
  )
276
301
  }
277
302
  const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd, app.configPath)
303
+ // Bun 1.4 on Windows may discard the script-relative portion of argv when `--config` is
304
+ // present. Keep a second, authenticated copy of the user vector in the child's environment so
305
+ // the configured process cannot silently fall back to `nifra`'s help and exit 0. The launch token
306
+ // is still the proof boundary; these arguments are only a transport, never authorization.
307
+ const childCliArgv = [...cliArgv()]
278
308
  const child = Bun.spawn(
279
309
  [
280
310
  process.execPath,
@@ -288,14 +318,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
288
318
  // One flag per condition. `--conditions=a,b` is accepted and matches nothing: Bun takes the
289
319
  // whole string as a single condition name, so the comma form fails silently.
290
320
  ...(app.framework.conditions ?? []).map((condition) => `--conditions=${condition}`),
291
- ...process.argv.slice(1),
321
+ // Reuse the actual loaded module rather than trusting either runtime's reconstructed
322
+ // `argv[1]` spelling. This also works when a package-bin shim launches the CLI. `cliArgv()`
323
+ // is important here: Bun 1.4 on Windows can omit the script from Bun.argv, making the
324
+ // command start at index 1 instead of index 2. Slicing at a fixed index would silently drop
325
+ // `dev`, causing the child to load this module and exit successfully without starting it.
326
+ fileURLToPath(import.meta.url),
327
+ ...childCliArgv,
292
328
  ],
293
329
  {
294
- stdio: ["inherit", "inherit", "inherit"],
330
+ // On Windows, inheriting the parent's already-piped stdout/stderr can make a nested Bun
331
+ // process exit successfully without exposing either its banner or its error. Use explicit
332
+ // pipes and forward them from this process instead; the caller still observes one CLI stream,
333
+ // while the child gets real handles it can write to on every platform.
334
+ stdin: "inherit",
335
+ stdout: "pipe",
336
+ stderr: "pipe",
295
337
  env: {
296
338
  ...process.env,
297
339
  NIFRA_BUN_DEV_TOKEN: launchToken,
298
340
  NIFRA_BUN_DEV_DEPTH: String(depth + 1),
341
+ NIFRA_BUN_DEV_ARGS: JSON.stringify(childCliArgv),
299
342
  // The child is the process that renders, so it is the one that needs dev-shaped runtimes.
300
343
  // A library ships its dev build behind the `development` export condition with a `NODE_ENV`
301
344
  // fallback for resolvers that set no conditions - which is Bun's runtime, where SSR runs,
@@ -307,11 +350,28 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
307
350
  },
308
351
  },
309
352
  )
353
+ const forwarded = Promise.all([
354
+ forwardChildOutput(child.stdout as ReadableStream<Uint8Array>, Bun.stdout),
355
+ forwardChildOutput(child.stderr as ReadableStream<Uint8Array>, Bun.stderr),
356
+ ])
310
357
  const forward = (): void => child.kill("SIGINT")
311
358
  process.on("SIGINT", forward)
312
359
  process.on("SIGTERM", forward)
313
- process.exit(await child.exited)
360
+ try {
361
+ const [code] = await Promise.all([child.exited, forwarded])
362
+ process.exitCode = code
363
+ } finally {
364
+ process.off("SIGINT", forward)
365
+ process.off("SIGTERM", forward)
366
+ }
367
+ return
314
368
  }
369
+ // The verified child no longer needs the transport or its launch proof. Remove both from the
370
+ // long-lived dev process so command-line values (which may include an env-file path) are not
371
+ // inherited by an app-spawned subprocess or exposed through its environment diagnostics.
372
+ delete process.env.NIFRA_BUN_DEV_ARGS
373
+ delete process.env.NIFRA_BUN_DEV_TOKEN
374
+ delete process.env.NIFRA_BUN_DEV_DEPTH
315
375
  // SSR runs in THIS process, on Bun's runtime - a different loader from the dev-server bundler the
316
376
  // bunfig above configures. Both halves have to agree, so the runtime gets the same transforms:
317
377
  //
@@ -474,6 +534,8 @@ async function assertFreshWorkspaceDists(cwd: string): Promise<void> {
474
534
  let rootPackage: Record<string, unknown>
475
535
  try {
476
536
  rootPackage = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>
537
+ // @nifra-gate-reviewed: not a security gate. An unreadable/non-JSON package.json is a separate
538
+ // build problem; returning here skips the freshness check rather than masking a denial.
477
539
  } catch {
478
540
  return // an unreadable/!JSON package.json is a separate problem; don't mask it as a stale-dist failure
479
541
  }
@@ -661,10 +723,42 @@ export function assureBundleRequested(argv: readonly string[]): boolean {
661
723
  )
662
724
  }
663
725
 
726
+ /**
727
+ * A command that reflects the project imports it, and an app that validates its environment at module
728
+ * scope calls `process.exit` from inside that import. Nothing is throwable at that point, so the whole
729
+ * output is the app's own abort message with no hint that a nifra command triggered it. Name the cause
730
+ * on the way out and point at the flag that supplies the environment.
731
+ */
732
+ function installReflectionExitHint(): (command: string | undefined) => void {
733
+ let inFlight: string | undefined
734
+ process.on("exit", (code) => {
735
+ if (inFlight === undefined || code === 0) return
736
+ process.stderr.write(
737
+ `\n[nifra] \`nifra ${inFlight}\` reflects this project by importing it, and the import exited ` +
738
+ `with code ${code} before the command produced a result - the message above came from your app, not nifra.\n` +
739
+ ` If it is the app's environment validation, supply the environment and re-run:\n` +
740
+ ` nifra ${inFlight} --env-file .env.local\n`,
741
+ )
742
+ })
743
+ return (command) => {
744
+ inFlight = command
745
+ }
746
+ }
747
+
664
748
  async function main(): Promise<void> {
665
- const argv = Bun.argv.slice(2)
749
+ const { argv: rawArgv, files: envFiles } = takeEnvFileFlags(cliArgv())
750
+ // Applied before any command runs: a reflecting command imports the app on its first await, and the
751
+ // app reads its environment at module scope, so the variables must already be in place by then.
752
+ if (envFiles.length > 0) await applyEnvFiles(process.cwd(), envFiles)
753
+ const argv = rawArgv
666
754
  const command = argv[0]
667
755
  if (command === undefined || command === "--help" || command === "-h" || command === "help") {
756
+ if (command === undefined && isBunDevReexecChild()) {
757
+ throw new Error(
758
+ "[nifra] the configured Bun dev child lost its command arguments; refusing to serve without " +
759
+ "the verified dev configuration. Re-run `nifra dev`.",
760
+ )
761
+ }
668
762
  console.log(HELP)
669
763
  return
670
764
  }
@@ -707,11 +801,33 @@ async function main(): Promise<void> {
707
801
  )
708
802
  return
709
803
  }
804
+ // `frontend` prints the client-side footgun catalog (the human head of `nifra_frontend`).
805
+ // Project-independent and static. `nifra frontend` for the index, `nifra frontend vue "lost reactivity"`
806
+ // to filter by adapter and/or symptom (either arg may be omitted; an adapter is recognized by name).
807
+ if (command === "frontend") {
808
+ const { renderFrontendResult, parseAdapter } = await import("./frontend-guidance.ts")
809
+ const first = argv[1]
810
+ const maybeAdapter = parseAdapter(first)
811
+ const adapter = maybeAdapter === undefined ? undefined : first
812
+ const symptom = (maybeAdapter === undefined ? argv.slice(1) : argv.slice(2))
813
+ .filter((a) => !a.startsWith("-"))
814
+ .join(" ")
815
+ console.log(
816
+ renderFrontendResult({
817
+ adapter,
818
+ symptom: symptom.length > 0 ? symptom : undefined,
819
+ }),
820
+ )
821
+ return
822
+ }
710
823
  const catalogSpec = command === undefined ? undefined : findCommandSpec(command)
711
824
  if (catalogSpec?.transports.includes("cli")) {
825
+ const markReflecting = installReflectionExitHint()
712
826
  try {
713
827
  const input = bindCommandArgv(catalogSpec, argv.slice(1))
828
+ markReflecting(command)
714
829
  const output = await catalogSpec.run(input, { cwd: process.cwd(), cliVersion: CLI_VERSION })
830
+ markReflecting(undefined)
715
831
  const asRecord = output as unknown as Record<string, unknown>
716
832
  const wantsJson = asRecord.json === true || argv.includes("--json")
717
833
  if (wantsJson) {
@@ -723,6 +839,9 @@ async function main(): Promise<void> {
723
839
  if (catalogSpec.success !== undefined && !catalogSpec.success(output, input))
724
840
  process.exitCode = 1
725
841
  } catch (err) {
842
+ // A thrown error is nifra's own reporting path, not a silent abort: clear the sentinel so the
843
+ // hint stays reserved for the case where the process died inside the app import.
844
+ markReflecting(undefined)
726
845
  console.error(formatCliError(err))
727
846
  process.exitCode = 1
728
847
  }
@@ -809,11 +928,173 @@ async function main(): Promise<void> {
809
928
  else await start(app, flags)
810
929
  }
811
930
 
931
+ /**
932
+ * Bun 1.4 on Windows can report `import.meta.main === false` for a module launched after a `--config`
933
+ * re-exec, even though that module is the process entry. Treat the runtime flag as authoritative when
934
+ * it is true, but recover from that false negative by comparing the runtime's entry path with this file.
935
+ * The comparison is deliberately realpath-based so a package-bin symlink is accepted while an imported
936
+ * copy of the CLI remains inert (tests import `parseFlags` from this module).
937
+ */
938
+ function isMainModule(): boolean {
939
+ if (import.meta.main) return true
940
+ const target = fileURLToPath(import.meta.url)
941
+ /**
942
+ * Bun's `--config`/`--conditions` flags can sit before the script in the child argv. `argv[1]` is
943
+ * therefore a runtime flag on Bun 1.4/Windows, not the entry module. `Bun.main` is the authoritative
944
+ * runtime answer when available; the bounded argv fallback covers Bun releases that do not expose it.
945
+ * Only non-flag entries are considered and every match must resolve to this exact file, so an
946
+ * unresolved or arbitrary flag can never turn an imported CLI module into a running CLI.
947
+ */
948
+ const bunMain = (Bun as unknown as { main?: unknown }).main
949
+ const argvEntries = (argv: readonly string[]): string[] => {
950
+ const entries: string[] = []
951
+ for (const value of argv.slice(1)) {
952
+ if (value === "--") break
953
+ if (value.startsWith("-")) continue
954
+ entries.push(value)
955
+ // The script is before the user's command/flags. A small bound keeps a caller's arbitrary
956
+ // arguments from becoming an entry-point probe while covering a config plus a few conditions.
957
+ if (entries.length === 4) break
958
+ }
959
+ return entries
960
+ }
961
+ const entries = [
962
+ ...(typeof bunMain === "string" ? [bunMain] : []),
963
+ ...argvEntries(Bun.argv),
964
+ ...argvEntries(process.argv),
965
+ ]
966
+ const canonical = (value: string): Set<string> => {
967
+ const paths = new Set<string>()
968
+ const add = (path: string): void => {
969
+ const slashed = path
970
+ .replaceAll("\\", "/")
971
+ .replace(/^\/\/\?\/UNC\//i, "//")
972
+ .replace(/^\/\/\?\//, "")
973
+ paths.add(process.platform === "win32" ? slashed.toLowerCase() : slashed)
974
+ }
975
+ try {
976
+ add(resolve(value))
977
+ } catch {
978
+ return paths
979
+ }
980
+ for (const resolver of [realpathSync.native, realpathSync]) {
981
+ try {
982
+ add(resolver(value))
983
+ } catch {
984
+ // A virtual/synthetic argv entry has no realpath; it is not safe evidence of main-ness.
985
+ }
986
+ }
987
+ return paths
988
+ }
989
+ const targetPaths = canonical(target)
990
+ for (const entry of entries) {
991
+ if (entry !== "" && [...canonical(entry)].some((path) => targetPaths.has(path))) return true
992
+ }
993
+ return false
994
+ }
995
+
996
+ /**
997
+ * Bun 1.4 on Windows can omit the script path from both argv vectors when a TS file is launched by
998
+ * `Bun.spawn`. The command token is a safe last-resort discriminator: a test importing this module
999
+ * sees test-file paths and runner flags, not an exact Nifra command, while a direct CLI invocation
1000
+ * has one of the manual or catalogued command names below.
1001
+ */
1002
+ function isCliCommandToken(value: string): boolean {
1003
+ return (
1004
+ value === "dev" ||
1005
+ value === "build" ||
1006
+ value === "start" ||
1007
+ value === "mcp" ||
1008
+ value === "init-agents" ||
1009
+ value === "upgrade" ||
1010
+ value === "help" ||
1011
+ value === "--help" ||
1012
+ value === "-h" ||
1013
+ value === "--version" ||
1014
+ value === "-v" ||
1015
+ findCommandSpec(value) !== undefined
1016
+ )
1017
+ }
1018
+
1019
+ function findCliCommand(values: readonly string[]): number {
1020
+ // Most Bun/Node argv vectors reserve index 0 for the runtime and index 1 for the script. Bun 1.4
1021
+ // on Windows can omit the script for a directly spawned TS entry, leaving the command at index 0.
1022
+ // Accept that one exact-token shape while keeping arbitrary imported-module argv inert.
1023
+ return values.findIndex((value) => isCliCommandToken(value))
1024
+ }
1025
+
1026
+ function cliArgv(): readonly string[] {
1027
+ const reexec = reexecCliArgv()
1028
+ if (reexec !== undefined) return reexec
1029
+ for (const values of [Bun.argv, process.argv]) {
1030
+ const commandIndex = findCliCommand(values)
1031
+ if (commandIndex >= 0) return values.slice(commandIndex)
1032
+ }
1033
+ return Bun.argv.slice(2)
1034
+ }
1035
+
1036
+ /**
1037
+ * Recover the original command vector when Bun's Windows `--config` launch drops it from both public
1038
+ * argv arrays. This is deliberately narrow: only a positive re-exec marker can enable the channel, the
1039
+ * first item must be a real Nifra command, and malformed/oversized data is ignored so an inherited
1040
+ * environment can never turn an imported CLI module into an arbitrary command runner.
1041
+ */
1042
+ function reexecCliArgv(): readonly string[] | undefined {
1043
+ if (!isBunDevReexecChild()) return undefined
1044
+ const encoded = process.env.NIFRA_BUN_DEV_ARGS
1045
+ if (encoded === undefined || encoded.length > 64 * 1024) return undefined
1046
+ let parsed: unknown
1047
+ try {
1048
+ parsed = JSON.parse(encoded)
1049
+ } catch {
1050
+ return undefined
1051
+ }
1052
+ if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 128) return undefined
1053
+ if (
1054
+ !parsed.every(
1055
+ (value) => typeof value === "string" && value.length <= 16 * 1024 && !value.includes("\0"),
1056
+ )
1057
+ ) {
1058
+ return undefined
1059
+ }
1060
+ const command = parsed[0]
1061
+ if (typeof command !== "string") return undefined
1062
+ return isCliCommandToken(command) ? (parsed as string[]) : undefined
1063
+ }
1064
+
1065
+ function hasCliCommandToken(): boolean {
1066
+ return findCliCommand(Bun.argv) >= 0 || findCliCommand(process.argv) >= 0
1067
+ }
1068
+
1069
+ /**
1070
+ * Bun 1.4/Windows can load the explicit script after a `--config` re-exec while reporting neither a
1071
+ * usable `import.meta.main` nor the script in its public argv vectors. The child still carries the
1072
+ * parent-generated launch proof and a positive depth; accepting that narrow marker lets it enter
1073
+ * `main()`, where `consumeLaunchToken()` remains the fail-closed gate before the configured server can
1074
+ * run. A forged marker only causes the normal token-mismatch relaunch/error path - it cannot bypass the
1075
+ * boundary plugin proof.
1076
+ */
1077
+ function isBunDevReexecChild(): boolean {
1078
+ const depth = Number(process.env.NIFRA_BUN_DEV_DEPTH)
1079
+ return (
1080
+ process.env.NIFRA_BUN_DEV_TOKEN !== undefined &&
1081
+ process.env.NIFRA_BUN_DEV_TOKEN !== "" &&
1082
+ Number.isSafeInteger(depth) &&
1083
+ depth > 0
1084
+ )
1085
+ }
1086
+
812
1087
  // Only run the CLI when invoked as the entry (`bun cli.ts …`), not when a test imports it for the
813
- // exported `parseFlags`. `import.meta.main` is true only for the process's entry module.
814
- if (import.meta.main) {
815
- main().catch((err) => {
1088
+ // exported `parseFlags`.
1089
+ if (isMainModule() || isBunDevReexecChild() || hasCliCommandToken()) {
1090
+ // Keep the entry module alive until the async CLI has completed. Bun 1.4 on Windows can otherwise
1091
+ // terminate a process created by Bun.spawn while its first dynamic import is pending, leaving an
1092
+ // exit-0 child with no output. Imported test modules never enter this branch, so the CLI's top-level
1093
+ // await cannot start a server merely because a test imports a helper from this file.
1094
+ try {
1095
+ await main()
1096
+ } catch (err) {
816
1097
  console.error(formatCliError(err))
817
1098
  process.exitCode = 1
818
- })
1099
+ }
819
1100
  }
@@ -21,6 +21,7 @@ import type { LoadedApp } from "./load.ts"
21
21
  import type { ManifestEmitCommandResult } from "./manifest-tool.ts"
22
22
  import { collectPortResult, type PortResult, renderReport } from "./port.ts"
23
23
  import type { ReplayResult } from "./replay.ts"
24
+ import type { StylexMigrationResult } from "./stylex-migrate.ts"
24
25
  import {
25
26
  collectProjectWorkGraph,
26
27
  type ProjectWorkGraphResult,
@@ -298,6 +299,12 @@ interface ContextInput {
298
299
  readonly dir?: string | undefined
299
300
  }
300
301
 
302
+ interface OpenApiInput {
303
+ readonly format?: "json" | "yaml" | undefined
304
+ readonly path?: string | undefined
305
+ readonly dir?: string | undefined
306
+ }
307
+
301
308
  interface DoctorInput {
302
309
  readonly json?: boolean | undefined
303
310
  readonly autoFix?: boolean | undefined
@@ -312,6 +319,14 @@ interface FixInput {
312
319
  readonly dir?: string | undefined
313
320
  }
314
321
 
322
+ interface MigrateInput {
323
+ readonly from: "tailwind"
324
+ readonly to: "stylex"
325
+ readonly write?: boolean | undefined
326
+ readonly json?: boolean | undefined
327
+ readonly dir?: string | undefined
328
+ }
329
+
315
330
  interface SnapshotInput {
316
331
  readonly out?: string | undefined
317
332
  readonly json?: boolean | undefined
@@ -373,11 +388,20 @@ interface ContextCommandOutput {
373
388
  readonly ok: true
374
389
  readonly text: string
375
390
  }
391
+ interface OpenApiCommandOutput {
392
+ readonly ok: true
393
+ readonly format: "json" | "yaml"
394
+ readonly text: string
395
+ }
376
396
  interface FixCommandOutput {
377
397
  readonly ok: boolean
378
398
  readonly changed: readonly string[]
399
+ /** Recipes that refused, with the reason. A recipe that cannot act says so here instead of
400
+ * aborting the run or - worse - reporting nothing at all. */
401
+ readonly failed: readonly { readonly code: string; readonly reason: string }[]
379
402
  readonly diagnostics: readonly unknown[]
380
403
  }
404
+ interface MigrateCommandOutput extends StylexMigrationResult {}
381
405
  interface SnapshotCommandOutput {
382
406
  readonly ok: true
383
407
  readonly file: string
@@ -569,6 +593,24 @@ const CONTEXT_SCHEMA = input<ContextInput>(
569
593
  },
570
594
  )
571
595
 
596
+ const OPENAPI_SCHEMA = input<OpenApiInput>(
597
+ objectSchema({
598
+ format: { type: "string", enum: ["json", "yaml"] },
599
+ path: { type: "string" },
600
+ dir: { type: "string" },
601
+ }),
602
+ (value) => {
603
+ const raw = withDir(record(value))
604
+ if (raw.format !== undefined && raw.format !== "json" && raw.format !== "yaml")
605
+ throw new TypeError("format must be json or yaml")
606
+ return {
607
+ format: (raw.format ?? "json") as "json" | "yaml",
608
+ path: optionalString(raw.path, "path"),
609
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
610
+ }
611
+ },
612
+ )
613
+
572
614
  const DOCTOR_SCHEMA = input<DoctorInput>(
573
615
  objectSchema({
574
616
  json: { type: "boolean" },
@@ -599,6 +641,32 @@ const FIX_SCHEMA = input<FixInput>(
599
641
  },
600
642
  )
601
643
 
644
+ const MIGRATE_SCHEMA = input<MigrateInput>(
645
+ objectSchema(
646
+ {
647
+ from: { type: "string", enum: ["tailwind"] },
648
+ to: { type: "string", enum: ["stylex"] },
649
+ write: { type: "boolean" },
650
+ json: { type: "boolean" },
651
+ dir: { type: "string" },
652
+ },
653
+ ["from", "to"],
654
+ ),
655
+ (value) => {
656
+ const raw = withDir(record(value))
657
+ const from = optionalString(raw.from, "from")
658
+ const to = optionalString(raw.to, "to")
659
+ if (from !== "tailwind") throw new TypeError("from must be tailwind")
660
+ if (to !== "stylex") throw new TypeError("to must be stylex")
661
+ return {
662
+ from,
663
+ to,
664
+ ...parseBooleanFlags(raw, ["write", "json"]),
665
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
666
+ }
667
+ },
668
+ )
669
+
602
670
  const SNAPSHOT_SCHEMA = input<SnapshotInput>(
603
671
  objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
604
672
  (value) => {
@@ -1024,6 +1092,38 @@ const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
1024
1092
  render: (out) => [out.text],
1025
1093
  }
1026
1094
 
1095
+ const openApiSpec: CommandSpec<OpenApiInput, OpenApiCommandOutput> = {
1096
+ name: "openapi",
1097
+ summary:
1098
+ "Generate the backend OpenAPI document, including supported build-time response inference.",
1099
+ input: OPENAPI_SCHEMA,
1100
+ output: output({
1101
+ type: "object",
1102
+ properties: { ok: { type: "boolean" }, format: { type: "string" }, text: { type: "string" } },
1103
+ required: ["ok", "format", "text"],
1104
+ }),
1105
+ transports: ["cli"],
1106
+ stability: "stable",
1107
+ argv: {
1108
+ flags: [
1109
+ { name: "format", field: "format", type: "string" },
1110
+ { name: "path", field: "path", type: "string" },
1111
+ ],
1112
+ },
1113
+ async run(value, ctx) {
1114
+ const app = await loadAppFor(ctx)
1115
+ const { renderOpenApiWithTypes } = await import("./openapi-tool.ts")
1116
+ const format = value.format ?? "json"
1117
+ return {
1118
+ ok: true,
1119
+ format,
1120
+ text: await renderOpenApiWithTypes(app, format, value.path),
1121
+ }
1122
+ },
1123
+ render: (out) => [out.text],
1124
+ json: (out) => (out.format === "json" ? JSON.parse(out.text) : out.text),
1125
+ }
1126
+
1027
1127
  const doctorSpec: CommandSpec<DoctorInput, DoctorResult> = {
1028
1128
  name: "doctor",
1029
1129
  summary: "Find undeclared imports, duplicate identity installs, and pipeline readiness drift.",
@@ -1071,6 +1171,7 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1071
1171
  properties: {
1072
1172
  ok: { type: "boolean" },
1073
1173
  changed: { type: "array" },
1174
+ failed: { type: "array" },
1074
1175
  diagnostics: { type: "array" },
1075
1176
  },
1076
1177
  required: ["ok", "diagnostics"],
@@ -1091,23 +1192,95 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1091
1192
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1092
1193
  })
1093
1194
  const changed: string[] = []
1195
+ const failed: { code: string; reason: string }[] = []
1094
1196
  for (const diagnostic of result.structuredDiagnostics ?? [])
1095
1197
  if (value.code === undefined || diagnostic.code === value.code)
1096
- changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1198
+ // One recipe that cannot act must not cancel the others, and must not pass silently either:
1199
+ // record why and keep going, so a run over many diagnostics still reports every outcome.
1200
+ try {
1201
+ changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1202
+ } catch (error) {
1203
+ failed.push({
1204
+ code: diagnostic.code,
1205
+ reason: error instanceof Error ? error.message : String(error),
1206
+ })
1207
+ }
1097
1208
  const final = await collectCheckResult(ctx.cwd, {
1098
1209
  lintsOnly: true,
1099
1210
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1100
1211
  })
1101
- return { ok: final.ok, changed, diagnostics: final.structuredDiagnostics ?? final.diagnostics }
1212
+ return {
1213
+ ok: final.ok,
1214
+ changed,
1215
+ failed,
1216
+ diagnostics: final.structuredDiagnostics ?? final.diagnostics,
1217
+ }
1102
1218
  },
1103
1219
  render: (out) => [
1104
1220
  out.ok ? "✓ fixes applied" : "✖ unresolved diagnostics remain",
1105
1221
  ...out.changed.map((file) => ` changed ${file}`),
1222
+ ...out.failed.map((f) => ` could not fix ${f.code}: ${f.reason}`),
1106
1223
  ...out.diagnostics.map((d) => ` ${String((d as { code?: unknown }).code ?? "diagnostic")}`),
1107
1224
  ],
1108
1225
  success: (out) => out.ok,
1109
1226
  }
1110
1227
 
1228
+ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1229
+ name: "migrate",
1230
+ summary: "Migrate safe static Tailwind className utilities to native StyleX props.",
1231
+ input: MIGRATE_SCHEMA,
1232
+ output: output({
1233
+ type: "object",
1234
+ properties: {
1235
+ ok: { type: "boolean" },
1236
+ from: { type: "string" },
1237
+ to: { type: "string" },
1238
+ write: { type: "boolean" },
1239
+ scanned: { type: "integer" },
1240
+ changed: { type: "array" },
1241
+ written: { type: "array" },
1242
+ issues: { type: "array" },
1243
+ files: { type: "array" },
1244
+ },
1245
+ required: ["ok", "from", "to", "write", "scanned", "changed", "written", "issues", "files"],
1246
+ }),
1247
+ transports: ["cli"],
1248
+ stability: "stable",
1249
+ argv: {
1250
+ flags: [
1251
+ { name: "from", field: "from", type: "string" },
1252
+ { name: "to", field: "to", type: "string" },
1253
+ { name: "write", field: "write", type: "boolean" },
1254
+ { name: "json", field: "json", type: "boolean" },
1255
+ { name: "dir", field: "dir", type: "string" },
1256
+ ],
1257
+ },
1258
+ async run(value, ctx) {
1259
+ const { migrateTailwindToStylex } = await import("./stylex-migrate.ts")
1260
+ return migrateTailwindToStylex(resolve(ctx.cwd, value.dir ?? "."), {
1261
+ ...(value.write === undefined ? {} : { write: value.write }),
1262
+ })
1263
+ },
1264
+ render: (out) => [
1265
+ out.ok
1266
+ ? `✓ migrated ${out.changed.length} file${out.changed.length === 1 ? "" : "s"}`
1267
+ : `⚠ migrated ${out.changed.length} safe file${out.changed.length === 1 ? "" : "s"}; ${out.issues.length} manual issue${out.issues.length === 1 ? "" : "s"} remain`,
1268
+ ` scanned ${out.scanned} source file${out.scanned === 1 ? "" : "s"}`,
1269
+ ...(out.write
1270
+ ? [` wrote ${out.written.length} file${out.written.length === 1 ? "" : "s"}`]
1271
+ : out.changed.length > 0
1272
+ ? [" dry run: pass --write to apply the safe changes"]
1273
+ : []),
1274
+ ...out.issues
1275
+ .slice(0, 30)
1276
+ .map((issue) => ` ${issue.file}:${issue.line} ${issue.token} — ${issue.reason}`),
1277
+ ...(out.issues.length > 30
1278
+ ? [` … ${out.issues.length - 30} more issue${out.issues.length - 30 === 1 ? "" : "s"}`]
1279
+ : []),
1280
+ ],
1281
+ success: (out) => out.ok,
1282
+ }
1283
+
1111
1284
  const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1112
1285
  name: "snapshot",
1113
1286
  summary: "Write the backend API contract as a versioned JSON baseline.",
@@ -1345,8 +1518,10 @@ export const commandSpecs = Object.freeze([
1345
1518
  manifestSpec,
1346
1519
  routesSpec,
1347
1520
  contextSpec,
1521
+ openApiSpec,
1348
1522
  doctorSpec,
1349
1523
  fixSpec,
1524
+ migrateSpec,
1350
1525
  snapshotSpec,
1351
1526
  diffSpec,
1352
1527
  contractsSpec,