@nifrajs/cli 3.1.0 → 3.3.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 (158) 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 -2366
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +247 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +21 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +122 -0
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/env-file.js +5 -5
  28. package/dist/env-file.js.map +1 -1
  29. package/dist/frontend-guidance.d.ts +57 -0
  30. package/dist/frontend-guidance.d.ts.map +1 -0
  31. package/dist/frontend-guidance.js +195 -0
  32. package/dist/frontend-guidance.js.map +1 -0
  33. package/dist/levels-tool.d.ts.map +1 -1
  34. package/dist/levels-tool.js +6 -0
  35. package/dist/levels-tool.js.map +1 -1
  36. package/dist/load.d.ts.map +1 -1
  37. package/dist/load.js +4 -1
  38. package/dist/load.js.map +1 -1
  39. package/dist/mcp-context.d.ts +48 -0
  40. package/dist/mcp-context.d.ts.map +1 -0
  41. package/dist/mcp-context.js +215 -0
  42. package/dist/mcp-context.js.map +1 -0
  43. package/dist/mcp-docs-tools.d.ts.map +1 -1
  44. package/dist/mcp-docs-tools.js +31 -0
  45. package/dist/mcp-docs-tools.js.map +1 -1
  46. package/dist/mcp-exec.d.ts +74 -0
  47. package/dist/mcp-exec.d.ts.map +1 -0
  48. package/dist/mcp-exec.js +844 -0
  49. package/dist/mcp-exec.js.map +1 -0
  50. package/dist/mcp-http.js +1 -1
  51. package/dist/mcp-io.d.ts +26 -0
  52. package/dist/mcp-io.d.ts.map +1 -0
  53. package/dist/mcp-io.js +100 -0
  54. package/dist/mcp-io.js.map +1 -0
  55. package/dist/mcp-reflect.d.ts +16 -0
  56. package/dist/mcp-reflect.d.ts.map +1 -0
  57. package/dist/mcp-reflect.js +101 -0
  58. package/dist/mcp-reflect.js.map +1 -0
  59. package/dist/mcp-root.d.ts +7 -0
  60. package/dist/mcp-root.d.ts.map +1 -1
  61. package/dist/mcp-root.js +51 -3
  62. package/dist/mcp-root.js.map +1 -1
  63. package/dist/mcp.d.ts +10 -92
  64. package/dist/mcp.d.ts.map +1 -1
  65. package/dist/mcp.js +13 -1232
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/openapi-tool.d.ts +5 -10
  68. package/dist/openapi-tool.d.ts.map +1 -1
  69. package/dist/openapi-tool.js +21 -2
  70. package/dist/openapi-tool.js.map +1 -1
  71. package/dist/openapi-types.d.ts +21 -0
  72. package/dist/openapi-types.d.ts.map +1 -0
  73. package/dist/openapi-types.js +214 -0
  74. package/dist/openapi-types.js.map +1 -0
  75. package/dist/port.d.ts.map +1 -1
  76. package/dist/port.js +2 -1
  77. package/dist/port.js.map +1 -1
  78. package/dist/project-facts.d.ts +2 -1
  79. package/dist/project-facts.d.ts.map +1 -1
  80. package/dist/project-facts.js.map +1 -1
  81. package/dist/release-verification.d.ts +4 -1
  82. package/dist/release-verification.d.ts.map +1 -1
  83. package/dist/release-verification.js +6 -138
  84. package/dist/release-verification.js.map +1 -1
  85. package/dist/rules/codes.d.ts +4 -0
  86. package/dist/rules/codes.d.ts.map +1 -1
  87. package/dist/rules/codes.js +4 -0
  88. package/dist/rules/codes.js.map +1 -1
  89. package/dist/rules/islands.d.ts +4 -0
  90. package/dist/rules/islands.d.ts.map +1 -0
  91. package/dist/rules/islands.js +155 -0
  92. package/dist/rules/islands.js.map +1 -0
  93. package/dist/rules/nano.d.ts +6 -0
  94. package/dist/rules/nano.d.ts.map +1 -0
  95. package/dist/rules/nano.js +264 -0
  96. package/dist/rules/nano.js.map +1 -0
  97. package/dist/rules/security.d.ts.map +1 -1
  98. package/dist/rules/security.js +38 -4
  99. package/dist/rules/security.js.map +1 -1
  100. package/dist/scaffold.d.ts +12 -6
  101. package/dist/scaffold.d.ts.map +1 -1
  102. package/dist/scaffold.js +149 -10
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/search-terms.d.ts.map +1 -1
  105. package/dist/search-terms.js +5 -3
  106. package/dist/search-terms.js.map +1 -1
  107. package/dist/stylex-migrate.d.ts +38 -0
  108. package/dist/stylex-migrate.d.ts.map +1 -0
  109. package/dist/stylex-migrate.js +1052 -0
  110. package/dist/stylex-migrate.js.map +1 -0
  111. package/dist/sync-manifest.d.ts.map +1 -1
  112. package/dist/sync-manifest.js +2 -1
  113. package/dist/sync-manifest.js.map +1 -1
  114. package/dist/verification-plan.d.ts +24 -0
  115. package/dist/verification-plan.d.ts.map +1 -0
  116. package/dist/verification-plan.js +116 -0
  117. package/dist/verification-plan.js.map +1 -0
  118. package/dist/work-graph.d.ts.map +1 -1
  119. package/dist/work-graph.js +6 -3
  120. package/dist/work-graph.js.map +1 -1
  121. package/docs/examples.json +46 -4
  122. package/docs/llms-full.txt +1346 -28
  123. package/docs/types.json +4185 -87
  124. package/package.json +9 -9
  125. package/src/assure-hydration.ts +6 -1
  126. package/src/assure.ts +4 -2
  127. package/src/check-diagnostics.ts +1219 -0
  128. package/src/check-scan.ts +1825 -0
  129. package/src/check.ts +47 -2752
  130. package/src/cli.ts +252 -10
  131. package/src/command-catalog.ts +155 -0
  132. package/src/env-file.ts +5 -5
  133. package/src/frontend-guidance.ts +259 -0
  134. package/src/levels-tool.ts +6 -0
  135. package/src/load.ts +4 -1
  136. package/src/mcp-context.ts +266 -0
  137. package/src/mcp-docs-tools.ts +37 -0
  138. package/src/mcp-exec.ts +1040 -0
  139. package/src/mcp-http.ts +1 -1
  140. package/src/mcp-io.ts +118 -0
  141. package/src/mcp-reflect.ts +140 -0
  142. package/src/mcp-root.ts +62 -4
  143. package/src/mcp.ts +61 -1511
  144. package/src/openapi-tool.ts +32 -2
  145. package/src/openapi-types.ts +261 -0
  146. package/src/port.ts +2 -1
  147. package/src/project-facts.ts +2 -4
  148. package/src/release-verification.ts +22 -164
  149. package/src/rules/codes.ts +4 -0
  150. package/src/rules/islands.ts +171 -0
  151. package/src/rules/nano.ts +295 -0
  152. package/src/rules/security.ts +40 -4
  153. package/src/scaffold.ts +169 -9
  154. package/src/search-terms.ts +7 -3
  155. package/src/stylex-migrate.ts +1139 -0
  156. package/src/sync-manifest.ts +2 -1
  157. package/src/verification-plan.ts +242 -0
  158. package/src/work-graph.ts +6 -3
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,
@@ -47,6 +48,23 @@ export interface Flags {
47
48
  readonly allowDuplicateIdentity: boolean
48
49
  }
49
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
+
50
68
  const HELP = `nifra - zero-config dev/build/start for a nifra app
51
69
 
52
70
  Usage:
@@ -198,7 +216,7 @@ set in the process environment is never overwritten by a file.
198
216
  ${renderCommandCatalogHelp()}`
199
217
 
200
218
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
201
- const CLI_VERSION = "3.1.0"
219
+ const CLI_VERSION = "3.3.0"
202
220
 
203
221
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
204
222
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -282,6 +300,11 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
282
300
  )
283
301
  }
284
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()]
285
308
  const child = Bun.spawn(
286
309
  [
287
310
  process.execPath,
@@ -295,14 +318,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
295
318
  // One flag per condition. `--conditions=a,b` is accepted and matches nothing: Bun takes the
296
319
  // whole string as a single condition name, so the comma form fails silently.
297
320
  ...(app.framework.conditions ?? []).map((condition) => `--conditions=${condition}`),
298
- ...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,
299
328
  ],
300
329
  {
301
- 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",
302
337
  env: {
303
338
  ...process.env,
304
339
  NIFRA_BUN_DEV_TOKEN: launchToken,
305
340
  NIFRA_BUN_DEV_DEPTH: String(depth + 1),
341
+ NIFRA_BUN_DEV_ARGS: JSON.stringify(childCliArgv),
306
342
  // The child is the process that renders, so it is the one that needs dev-shaped runtimes.
307
343
  // A library ships its dev build behind the `development` export condition with a `NODE_ENV`
308
344
  // fallback for resolvers that set no conditions - which is Bun's runtime, where SSR runs,
@@ -314,11 +350,28 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
314
350
  },
315
351
  },
316
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
+ ])
317
357
  const forward = (): void => child.kill("SIGINT")
318
358
  process.on("SIGINT", forward)
319
359
  process.on("SIGTERM", forward)
320
- 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
321
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
322
375
  // SSR runs in THIS process, on Bun's runtime - a different loader from the dev-server bundler the
323
376
  // bunfig above configures. Both halves have to agree, so the runtime gets the same transforms:
324
377
  //
@@ -481,6 +534,8 @@ async function assertFreshWorkspaceDists(cwd: string): Promise<void> {
481
534
  let rootPackage: Record<string, unknown>
482
535
  try {
483
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.
484
539
  } catch {
485
540
  return // an unreadable/!JSON package.json is a separate problem; don't mask it as a stale-dist failure
486
541
  }
@@ -691,13 +746,19 @@ function installReflectionExitHint(): (command: string | undefined) => void {
691
746
  }
692
747
 
693
748
  async function main(): Promise<void> {
694
- const { argv: rawArgv, files: envFiles } = takeEnvFileFlags(Bun.argv.slice(2))
749
+ const { argv: rawArgv, files: envFiles } = takeEnvFileFlags(cliArgv())
695
750
  // Applied before any command runs: a reflecting command imports the app on its first await, and the
696
751
  // app reads its environment at module scope, so the variables must already be in place by then.
697
752
  if (envFiles.length > 0) await applyEnvFiles(process.cwd(), envFiles)
698
753
  const argv = rawArgv
699
754
  const command = argv[0]
700
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
+ }
701
762
  console.log(HELP)
702
763
  return
703
764
  }
@@ -740,6 +801,25 @@ async function main(): Promise<void> {
740
801
  )
741
802
  return
742
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
+ }
743
823
  const catalogSpec = command === undefined ? undefined : findCommandSpec(command)
744
824
  if (catalogSpec?.transports.includes("cli")) {
745
825
  const markReflecting = installReflectionExitHint()
@@ -848,11 +928,173 @@ async function main(): Promise<void> {
848
928
  else await start(app, flags)
849
929
  }
850
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
+
851
1087
  // Only run the CLI when invoked as the entry (`bun cli.ts …`), not when a test imports it for the
852
- // exported `parseFlags`. `import.meta.main` is true only for the process's entry module.
853
- if (import.meta.main) {
854
- 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) {
855
1097
  console.error(formatCliError(err))
856
1098
  process.exitCode = 1
857
- })
1099
+ }
858
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,6 +388,11 @@ 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[]
@@ -381,6 +401,7 @@ interface FixCommandOutput {
381
401
  readonly failed: readonly { readonly code: string; readonly reason: string }[]
382
402
  readonly diagnostics: readonly unknown[]
383
403
  }
404
+ interface MigrateCommandOutput extends StylexMigrationResult {}
384
405
  interface SnapshotCommandOutput {
385
406
  readonly ok: true
386
407
  readonly file: string
@@ -572,6 +593,24 @@ const CONTEXT_SCHEMA = input<ContextInput>(
572
593
  },
573
594
  )
574
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
+
575
614
  const DOCTOR_SCHEMA = input<DoctorInput>(
576
615
  objectSchema({
577
616
  json: { type: "boolean" },
@@ -602,6 +641,32 @@ const FIX_SCHEMA = input<FixInput>(
602
641
  },
603
642
  )
604
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
+
605
670
  const SNAPSHOT_SCHEMA = input<SnapshotInput>(
606
671
  objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
607
672
  (value) => {
@@ -1027,6 +1092,38 @@ const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
1027
1092
  render: (out) => [out.text],
1028
1093
  }
1029
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
+
1030
1127
  const doctorSpec: CommandSpec<DoctorInput, DoctorResult> = {
1031
1128
  name: "doctor",
1032
1129
  summary: "Find undeclared imports, duplicate identity installs, and pipeline readiness drift.",
@@ -1128,6 +1225,62 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1128
1225
  success: (out) => out.ok,
1129
1226
  }
1130
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
+
1131
1284
  const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1132
1285
  name: "snapshot",
1133
1286
  summary: "Write the backend API contract as a versioned JSON baseline.",
@@ -1365,8 +1518,10 @@ export const commandSpecs = Object.freeze([
1365
1518
  manifestSpec,
1366
1519
  routesSpec,
1367
1520
  contextSpec,
1521
+ openApiSpec,
1368
1522
  doctorSpec,
1369
1523
  fixSpec,
1524
+ migrateSpec,
1370
1525
  snapshotSpec,
1371
1526
  diffSpec,
1372
1527
  contractsSpec,
package/src/env-file.ts CHANGED
@@ -68,8 +68,8 @@ export function takeEnvFileFlags(argv: readonly string[]): {
68
68
  const rest: string[] = []
69
69
  const files: string[] = []
70
70
  for (let index = 0; index < argv.length; index++) {
71
- const token = argv[index] as string
72
- if (token === ENV_FILE_FLAG) {
71
+ const arg = argv[index] as string
72
+ if (arg === ENV_FILE_FLAG) {
73
73
  const value = argv[index + 1]
74
74
  if (value === undefined || value.startsWith("-")) {
75
75
  throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG} .env.local`)
@@ -78,15 +78,15 @@ export function takeEnvFileFlags(argv: readonly string[]): {
78
78
  index++
79
79
  continue
80
80
  }
81
- if (token.startsWith(`${ENV_FILE_FLAG}=`)) {
82
- const value = token.slice(ENV_FILE_FLAG.length + 1)
81
+ if (arg.startsWith(`${ENV_FILE_FLAG}=`)) {
82
+ const value = arg.slice(ENV_FILE_FLAG.length + 1)
83
83
  if (value === "") {
84
84
  throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG}=.env.local`)
85
85
  }
86
86
  files.push(value)
87
87
  continue
88
88
  }
89
- rest.push(token)
89
+ rest.push(arg)
90
90
  }
91
91
  return { argv: rest, files }
92
92
  }