@nifrajs/cli 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +7 -2
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +4 -2
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts +12 -2
  6. package/dist/assure.d.ts.map +1 -1
  7. package/dist/assure.js +59 -8
  8. package/dist/assure.js.map +1 -1
  9. package/dist/capabilities-tool.d.ts.map +1 -1
  10. package/dist/capabilities-tool.js +24 -7
  11. package/dist/capabilities-tool.js.map +1 -1
  12. package/dist/check-diagnostics.d.ts +2 -0
  13. package/dist/check-diagnostics.d.ts.map +1 -1
  14. package/dist/check-diagnostics.js.map +1 -1
  15. package/dist/check-scan.d.ts +8 -2
  16. package/dist/check-scan.d.ts.map +1 -1
  17. package/dist/check-scan.js +43 -10
  18. package/dist/check-scan.js.map +1 -1
  19. package/dist/check.d.ts.map +1 -1
  20. package/dist/check.js +39 -33
  21. package/dist/check.js.map +1 -1
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +106 -48
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client-module.d.ts +5 -3
  27. package/dist/client-module.d.ts.map +1 -1
  28. package/dist/client-module.js +34 -10
  29. package/dist/client-module.js.map +1 -1
  30. package/dist/command-catalog.d.ts +9 -1
  31. package/dist/command-catalog.d.ts.map +1 -1
  32. package/dist/command-catalog.js +67 -22
  33. package/dist/command-catalog.js.map +1 -1
  34. package/dist/dev-feed-tool.d.ts +18 -1
  35. package/dist/dev-feed-tool.d.ts.map +1 -1
  36. package/dist/dev-feed-tool.js +93 -0
  37. package/dist/dev-feed-tool.js.map +1 -1
  38. package/dist/fix-recipes.d.ts +2 -0
  39. package/dist/fix-recipes.d.ts.map +1 -1
  40. package/dist/fix-recipes.js +93 -12
  41. package/dist/fix-recipes.js.map +1 -1
  42. package/dist/init-agents.d.ts +16 -0
  43. package/dist/init-agents.d.ts.map +1 -1
  44. package/dist/init-agents.js +3 -2
  45. package/dist/init-agents.js.map +1 -1
  46. package/dist/internal/config-paths.d.ts +16 -0
  47. package/dist/internal/config-paths.d.ts.map +1 -0
  48. package/dist/internal/config-paths.js +124 -0
  49. package/dist/internal/config-paths.js.map +1 -0
  50. package/dist/internal/installed-package.d.ts +4 -0
  51. package/dist/internal/installed-package.d.ts.map +1 -0
  52. package/dist/internal/installed-package.js +17 -0
  53. package/dist/internal/installed-package.js.map +1 -0
  54. package/dist/internal/project-specifiers.d.ts +14 -1
  55. package/dist/internal/project-specifiers.d.ts.map +1 -1
  56. package/dist/internal/project-specifiers.js +54 -15
  57. package/dist/internal/project-specifiers.js.map +1 -1
  58. package/dist/internal/source-facts.d.ts +6 -1
  59. package/dist/internal/source-facts.d.ts.map +1 -1
  60. package/dist/internal/source-facts.js +11 -5
  61. package/dist/internal/source-facts.js.map +1 -1
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +5 -3
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +9 -0
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +32 -1
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/levels-tool.d.ts.map +1 -1
  70. package/dist/levels-tool.js +5 -8
  71. package/dist/levels-tool.js.map +1 -1
  72. package/dist/load.d.ts +2 -2
  73. package/dist/load.d.ts.map +1 -1
  74. package/dist/load.js +23 -3
  75. package/dist/load.js.map +1 -1
  76. package/dist/mcp-context.d.ts.map +1 -1
  77. package/dist/mcp-context.js +6 -21
  78. package/dist/mcp-context.js.map +1 -1
  79. package/dist/mcp-delegate.d.ts +2 -2
  80. package/dist/mcp-delegate.d.ts.map +1 -1
  81. package/dist/mcp-delegate.js +19 -7
  82. package/dist/mcp-delegate.js.map +1 -1
  83. package/dist/mcp-exec.d.ts +1 -0
  84. package/dist/mcp-exec.d.ts.map +1 -1
  85. package/dist/mcp-exec.js +8 -3
  86. package/dist/mcp-exec.js.map +1 -1
  87. package/dist/mcp-http.js +1 -1
  88. package/dist/mcp-io.d.ts +13 -0
  89. package/dist/mcp-io.d.ts.map +1 -1
  90. package/dist/mcp-io.js +23 -0
  91. package/dist/mcp-io.js.map +1 -1
  92. package/dist/mcp-path.d.ts +2 -1
  93. package/dist/mcp-path.d.ts.map +1 -1
  94. package/dist/mcp-path.js +9 -24
  95. package/dist/mcp-path.js.map +1 -1
  96. package/dist/mcp.d.ts.map +1 -1
  97. package/dist/mcp.js +40 -17
  98. package/dist/mcp.js.map +1 -1
  99. package/dist/migrate-layout.d.ts +40 -1
  100. package/dist/migrate-layout.d.ts.map +1 -1
  101. package/dist/migrate-layout.js +1316 -228
  102. package/dist/migrate-layout.js.map +1 -1
  103. package/dist/output-schemas.d.ts +34 -0
  104. package/dist/output-schemas.d.ts.map +1 -1
  105. package/dist/output-schemas.js +132 -21
  106. package/dist/output-schemas.js.map +1 -1
  107. package/dist/project-path.d.ts +6 -0
  108. package/dist/project-path.d.ts.map +1 -1
  109. package/dist/project-path.js +48 -14
  110. package/dist/project-path.js.map +1 -1
  111. package/dist/release-verification.d.ts +5 -0
  112. package/dist/release-verification.d.ts.map +1 -1
  113. package/dist/release-verification.js +14 -0
  114. package/dist/release-verification.js.map +1 -1
  115. package/dist/review.d.ts.map +1 -1
  116. package/dist/review.js +5 -0
  117. package/dist/review.js.map +1 -1
  118. package/dist/rules/codes.d.ts +3 -1
  119. package/dist/rules/codes.d.ts.map +1 -1
  120. package/dist/rules/codes.js +3 -1
  121. package/dist/rules/codes.js.map +1 -1
  122. package/dist/rules/data-guard.d.ts +6 -0
  123. package/dist/rules/data-guard.d.ts.map +1 -1
  124. package/dist/rules/data-guard.js +37 -0
  125. package/dist/rules/data-guard.js.map +1 -1
  126. package/dist/rules/legacy.d.ts.map +1 -1
  127. package/dist/rules/legacy.js +20 -1
  128. package/dist/rules/legacy.js.map +1 -1
  129. package/dist/rules/pages.d.ts +29 -3
  130. package/dist/rules/pages.d.ts.map +1 -1
  131. package/dist/rules/pages.js +236 -22
  132. package/dist/rules/pages.js.map +1 -1
  133. package/dist/rules/security.d.ts +3 -2
  134. package/dist/rules/security.d.ts.map +1 -1
  135. package/dist/rules/security.js +42 -11
  136. package/dist/rules/security.js.map +1 -1
  137. package/dist/rules/zones.d.ts.map +1 -1
  138. package/dist/rules/zones.js +31 -9
  139. package/dist/rules/zones.js.map +1 -1
  140. package/dist/stylex-migrate.d.ts.map +1 -1
  141. package/dist/stylex-migrate.js +127 -17
  142. package/dist/stylex-migrate.js.map +1 -1
  143. package/dist/test-tool.d.ts.map +1 -1
  144. package/dist/test-tool.js +3 -9
  145. package/dist/test-tool.js.map +1 -1
  146. package/dist/upgrade.d.ts +20 -5
  147. package/dist/upgrade.d.ts.map +1 -1
  148. package/dist/upgrade.js +86 -9
  149. package/dist/upgrade.js.map +1 -1
  150. package/dist/verification-plan.d.ts.map +1 -1
  151. package/dist/verification-plan.js +5 -2
  152. package/dist/verification-plan.js.map +1 -1
  153. package/dist/version-drift.d.ts.map +1 -1
  154. package/dist/version-drift.js +55 -3
  155. package/dist/version-drift.js.map +1 -1
  156. package/docs/examples.json +2 -2
  157. package/docs/llms-full.txt +481 -61
  158. package/docs/types.json +147 -70
  159. package/package.json +11 -11
  160. package/src/assure-hydration.ts +4 -1
  161. package/src/assure.ts +88 -10
  162. package/src/capabilities-tool.ts +33 -7
  163. package/src/check-diagnostics.ts +2 -0
  164. package/src/check-scan.ts +56 -9
  165. package/src/check.ts +55 -39
  166. package/src/cli.ts +104 -35
  167. package/src/client-module.ts +45 -9
  168. package/src/command-catalog.ts +77 -21
  169. package/src/dev-feed-tool.ts +112 -1
  170. package/src/fix-recipes.ts +101 -10
  171. package/src/init-agents.ts +4 -3
  172. package/src/internal/config-paths.ts +154 -0
  173. package/src/internal/installed-package.ts +15 -0
  174. package/src/internal/project-specifiers.ts +69 -16
  175. package/src/internal/source-facts.ts +23 -5
  176. package/src/internal/typescript-import.ts +5 -3
  177. package/src/introspect.ts +39 -1
  178. package/src/levels-tool.ts +7 -9
  179. package/src/load.ts +29 -3
  180. package/src/mcp-context.ts +5 -18
  181. package/src/mcp-delegate.ts +22 -10
  182. package/src/mcp-exec.ts +9 -2
  183. package/src/mcp-http.ts +1 -1
  184. package/src/mcp-io.ts +33 -0
  185. package/src/mcp-path.ts +8 -22
  186. package/src/mcp.ts +48 -19
  187. package/src/migrate-layout.ts +1496 -237
  188. package/src/output-schemas.ts +179 -23
  189. package/src/project-path.ts +44 -13
  190. package/src/release-verification.ts +20 -0
  191. package/src/review.ts +5 -0
  192. package/src/rules/codes.ts +3 -1
  193. package/src/rules/data-guard.ts +40 -0
  194. package/src/rules/legacy.ts +22 -1
  195. package/src/rules/pages.ts +266 -24
  196. package/src/rules/security.ts +49 -11
  197. package/src/rules/zones.ts +30 -7
  198. package/src/stylex-migrate.ts +117 -17
  199. package/src/test-tool.ts +3 -9
  200. package/src/upgrade.ts +96 -8
  201. package/src/verification-plan.ts +9 -2
  202. package/src/version-drift.ts +53 -2
@@ -82,8 +82,26 @@ function rawResponseExpression(ts: TypeScriptApi, node: TSApi.Expression): boole
82
82
  )
83
83
  }
84
84
 
85
- export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
85
+ export interface SourceFactsOptions {
86
+ /** Whether the compiler settings for a parsed file keep an import whose named specifiers are all
87
+ * type-only (`verbatimModuleSyntax`). Without it such an import is erased. */
88
+ readonly keepsTypeOnlyImports?: (file: string) => boolean
89
+ }
90
+
91
+ export function createSourceFacts(
92
+ ts: TypeScriptApi,
93
+ options: SourceFactsOptions = {},
94
+ ): SourceFacts {
86
95
  const cache = new Map<string, { content: string; source: TSApi.SourceFile | undefined }>()
96
+ // `{}` loads its module in every bundler; a list of type-only specifiers is erased unless the
97
+ // compiler keeps it as `import {} from "x"`.
98
+ const loadsModule = (
99
+ source: TSApi.SourceFile,
100
+ elements: readonly { readonly isTypeOnly: boolean }[],
101
+ ): boolean =>
102
+ elements.length === 0 ||
103
+ elements.some((element) => !element.isTypeOnly) ||
104
+ (options.keepsTypeOnlyImports?.(source.fileName) ?? false)
87
105
 
88
106
  const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
89
107
  const cached = cache.get(file)
@@ -118,7 +136,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
118
136
  if (reexport.isTypeOnly) return false
119
137
  const named = reexport.exportClause
120
138
  if (named !== undefined && ts.isNamedExports(named))
121
- return named.elements.some((element) => !element.isTypeOnly)
139
+ return loadsModule(source, named.elements)
122
140
  return true // `export *` and `export * as ns`
123
141
  }
124
142
  const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration)
@@ -128,9 +146,9 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
128
146
  const clause = declaration.importClause
129
147
  if (clause === undefined) return true // side-effect import
130
148
  if (clause.isTypeOnly) return false
131
- if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
132
- return clause.namedBindings.elements.some((element) => !element.isTypeOnly)
133
- }
149
+ // A default binding is a runtime import whatever its named list holds.
150
+ const named = clause.name === undefined ? clause.namedBindings : undefined
151
+ if (named !== undefined && ts.isNamedImports(named)) return loadsModule(source, named.elements)
134
152
  return true // default and namespace bindings are runtime imports
135
153
  }
136
154
 
@@ -323,9 +323,9 @@ function syntaxKinds(module: UnknownRecord, names: readonly string[]): ReadonlyS
323
323
 
324
324
  function createVirtualFileSystem(
325
325
  sourceFiles: ReadonlyMap<string, string>,
326
- root: string,
326
+ roots: readonly string[],
327
327
  ): UnknownRecord {
328
- const rootPaths = pathVariants(root)
328
+ const rootPaths = [...new Set(roots.flatMap(pathVariants))]
329
329
  const virtualFiles: string[] = []
330
330
  const seenFiles = new Set<string>()
331
331
  for (const file of sourceFiles.keys()) {
@@ -454,7 +454,9 @@ async function createTypeScript7Session(
454
454
 
455
455
  const api = new API({
456
456
  cwd: sessionRoot,
457
- fs: createVirtualFileSystem(files, sessionRoot),
457
+ // The root as the caller spells it too: a file not yet on disk keeps that spelling (`/var`, not
458
+ // `/private/var`), and the session reaches it through the caller's config.
459
+ fs: createVirtualFileSystem(files, [sessionRoot, root]),
458
460
  })
459
461
  let snapshot: TypeScript7Snapshot | undefined
460
462
  let configuredProject: TypeScript7Project | undefined
package/src/introspect.ts CHANGED
@@ -438,6 +438,9 @@ export interface RouteTableEntry {
438
438
  * through the same web app, no hand-wired `if (pathname.startsWith("/api/"))`). Pages are never
439
439
  * auto-mounted (they ARE the page router); only API routes carry this flag. */
440
440
  readonly autoMounted?: boolean
441
+ /** True for an API route outside a set `apiPrefix` while `apiStrip` is off: the mount passes the
442
+ * request's full path, so no HTTP request reaches it; loaders still call it in-process. */
443
+ readonly inProcessOnly?: boolean
441
444
  }
442
445
 
443
446
  /** A page route + whether its module exports an `action` (→ it also serves POST). The caller resolves
@@ -506,6 +509,8 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
506
509
  path,
507
510
  methods: [...info.methods].sort(),
508
511
  autoMounted: info.autoMounted,
512
+ // biome-ignore lint/plugin/noConditionalEmptyObjectSpread: only a route outside apiPrefix carries the flag, so every other row keeps its shape.
513
+ ...(apiPrefix !== "" && !info.autoMounted ? { inProcessOnly: true } : {}),
509
514
  })
510
515
  }
511
516
  return rows.sort(
@@ -544,7 +549,12 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
544
549
  const display = rows.map((r) => ({
545
550
  methods: r.methods.join(", "),
546
551
  kind: r.kind,
547
- path: r.kind === "api" && r.autoMounted ? `${r.path} (auto-mounted)` : r.path,
552
+ path:
553
+ r.kind === "api" && r.autoMounted
554
+ ? `${r.path} (auto-mounted)`
555
+ : r.inProcessOnly === true
556
+ ? `${r.path} (in-process only: outside apiPrefix)`
557
+ : r.path,
548
558
  client: clientSpellingFor(r.path),
549
559
  }))
550
560
  const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
@@ -577,6 +587,7 @@ export interface RouteJsonRow {
577
587
  readonly methods: readonly string[]
578
588
  readonly file?: string
579
589
  readonly autoMounted?: boolean
590
+ readonly inProcessOnly?: boolean
580
591
  /** The reserved typed-client key a static segment of this path collides with, when one does. */
581
592
  readonly reservedKey?: string
582
593
  /** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
@@ -599,6 +610,8 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
599
610
  methods: r.methods,
600
611
  ...(r.file !== undefined ? { file: r.file } : {}),
601
612
  ...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
613
+ // biome-ignore lint/plugin/noConditionalEmptyObjectSpread: `--json` rows gain the key only where it is true, so existing consumers see no change.
614
+ ...(r.inProcessOnly === true ? { inProcessOnly: true } : {}),
602
615
  ...(client === undefined
603
616
  ? {}
604
617
  : { reservedKey: client.reserved, clientSpelling: client.spelling }),
@@ -748,6 +761,31 @@ async function collectRouteTable(app: IntrospectedApp): Promise<RouteTableEntry[
748
761
  return rows
749
762
  }
750
763
 
764
+ /**
765
+ * The warning `nifra dev` and `nifra build` print for backend routes no HTTP request reaches, read from
766
+ * the routes the backend registers, prefixes and mounts resolved; `undefined` when there are none.
767
+ */
768
+ export function unservedRoutesWarning(
769
+ app: Pick<IntrospectedApp, "framework" | "backend">,
770
+ ): string | undefined {
771
+ const apiPrefix = app.framework?.apiPrefix ?? ""
772
+ if (apiPrefix === "" || app.backend === undefined) return undefined
773
+ const rows = buildRouteTable({
774
+ pages: [],
775
+ api: backendRoutes(app.backend),
776
+ apiPrefix,
777
+ apiStrip: app.framework?.apiStrip === true,
778
+ }).filter((row) => row.inProcessOnly === true)
779
+ if (rows.length === 0) return undefined
780
+ const listed = rows.map((row) => `${row.methods.join(",")} ${row.path}`)
781
+ const named =
782
+ listed.length > 3
783
+ ? `${listed.slice(0, 3).join(", ")} and ${listed.length - 3} more`
784
+ : listed.join(", ")
785
+ const one = rows.length === 1
786
+ return `[nifra] ${one ? "a backend route is" : `${rows.length} backend routes are`} served over HTTP nowhere: ${named}. backend/app.ts is mounted at apiPrefix ${apiPrefix} with apiStrip off, so it sees each request's full path, and ${one ? "this route sits" : "these sit"} outside it; loaders still call ${one ? "it" : "them"} in-process. Register ${one ? "it" : "them"} under ${apiPrefix}, or export \`apiStrip = true\` to serve every backend route at ${apiPrefix} plus its path.`
787
+ }
788
+
751
789
  export async function describeRoutes(
752
790
  app: IntrospectedApp,
753
791
  opts: { readonly json?: boolean } = {},
@@ -15,6 +15,7 @@ import { existsSync } from "node:fs"
15
15
  import { resolve } from "node:path"
16
16
  import type {
17
17
  AssuranceReport,
18
+ AssuredRoute,
18
19
  InvariantSetup,
19
20
  InvariantSetupResult,
20
21
  InvariantsConfig,
@@ -26,6 +27,7 @@ import {
26
27
  type ContractTestApp,
27
28
  runAdversarialContract,
28
29
  } from "@nifrajs/testing"
30
+ import { RESPONSE_CONTRACT_BASELINE_HINT, responseContractGaps } from "./assure.ts"
29
31
  import {
30
32
  type CapabilityProjectReport,
31
33
  diffCapabilitySnapshots,
@@ -310,7 +312,7 @@ const messageOf = (error: unknown): string =>
310
312
 
311
313
  function finalize(
312
314
  statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
313
- routes: readonly { readonly responseContract?: "enforce" | "warn" | "unchecked" }[],
315
+ routes: readonly AssuredRoute[],
314
316
  ): VerificationLevelsResult {
315
317
  // Cumulative: the achieved level is the last rung with every rung below it also holding.
316
318
  let achieved = -1
@@ -322,6 +324,7 @@ function finalize(
322
324
  const enforced = declared.filter((route) => route.responseContract === "enforce").length
323
325
  const warnOnly = declared.filter((route) => route.responseContract === "warn").length
324
326
  const typedOnly = declared.filter((route) => route.responseContract === "unchecked").length
327
+ const gaps = responseContractGaps(routes)
325
328
  const responseContracts: ResponseContractStatus = {
326
329
  declared: declared.length,
327
330
  enforced,
@@ -333,14 +336,9 @@ function finalize(
333
336
  declared.length === 0
334
337
  ? ["no response/error schemas declared"]
335
338
  : [
336
- ...(typedOnly > 0
337
- ? [
338
- `${typedOnly} declared route(s) are typed-only; install responseContract("enforce")`,
339
- ]
340
- : []),
341
- ...(warnOnly > 0
342
- ? [`${warnOnly} declared route(s) are warn-only and still send the original payload`]
343
- : []),
339
+ ...(gaps.typedOnlyReason === undefined ? [] : [gaps.typedOnlyReason]),
340
+ ...(gaps.warnOnlyReason === undefined ? [] : [gaps.warnOnlyReason]),
341
+ ...(typedOnly + warnOnly > 0 ? [RESPONSE_CONTRACT_BASELINE_HINT] : []),
344
342
  ],
345
343
  }
346
344
  return Object.freeze({
package/src/load.ts CHANGED
@@ -342,9 +342,31 @@ export async function loadBackendApp(
342
342
  return { cwd, backend }
343
343
  }
344
344
 
345
+ /** The fields the generated server entry imports from backend/framework.ts. */
346
+ const SERVER_OPTIONS = [...FRAMEWORK_WEB_OPTIONS, "use"] as const
347
+
348
+ /**
349
+ * `config` with each server option it leaves unset taken from backend/framework.ts, so `nifra dev` and
350
+ * the server entry `nifra build` generates read one value without nifra.config.ts re-exporting it.
351
+ */
352
+ async function withServerOptions(
353
+ config: Partial<NifraFramework>,
354
+ frameworkPath: string,
355
+ importQuery: string | undefined,
356
+ ): Promise<Partial<NifraFramework>> {
357
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a config module, read like nifra.config.ts; every web option taken from it is validated below.
358
+ const framework = (await importWithQuery(frameworkPath, importQuery)) as Partial<NifraFramework>
359
+ const taken: Record<string, unknown> = {}
360
+ for (const name of SERVER_OPTIONS) {
361
+ if (config[name] === undefined && framework[name] !== undefined) taken[name] = framework[name]
362
+ }
363
+ assertFrameworkWebOptions(taken, FRAMEWORK_FILE)
364
+ return { ...config, ...taken }
365
+ }
366
+
345
367
  /** Discover + validate the app conventions rooted at `cwd`. Prefers `nifra.config.ts`, falls back
346
- * to
347
- * `backend/framework.ts`. Throws a clear, actionable error if neither exists or the config is
368
+ * to `backend/framework.ts`; with both, a server option nifra.config.ts leaves unset comes from
369
+ * backend/framework.ts. Throws a clear, actionable error if neither exists or the config is
348
370
  * malformed. */
349
371
  export async function loadApp(
350
372
  cwd: string,
@@ -375,7 +397,11 @@ export async function loadApp(
375
397
  `[nifra] no routes/ directory in ${cwd} - nifra apps are file-routed under routes/.`,
376
398
  )
377
399
  }
378
- const fw = (await importWithQuery(configPath, options.importQuery)) as Partial<NifraFramework>
400
+ const loaded = (await importWithQuery(configPath, options.importQuery)) as Partial<NifraFramework>
401
+ const fw =
402
+ configFile === CONFIG_FILE && existsSync(resolve(cwd, FRAMEWORK_FILE))
403
+ ? await withServerOptions(loaded, resolve(cwd, FRAMEWORK_FILE), options.importQuery)
404
+ : loaded
379
405
  if (!isAdapter(fw.adapter) || typeof fw.clientModule !== "string") {
380
406
  throw new Error(
381
407
  `[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
@@ -12,6 +12,7 @@ import type { AppLoader } from "./load.ts"
12
12
  import { createCachedAppLoader } from "./mcp-exec.ts"
13
13
  import { resolveMcpProjectPath } from "./mcp-path.ts"
14
14
  import type { McpPrompt, McpResource, McpServerFeatures, McpTool } from "./mcp-protocol.ts"
15
+ import { writeTargetOf } from "./project-path.ts"
15
16
 
16
17
  function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
17
18
  const format = args.format
@@ -186,24 +187,10 @@ export function resolveProjectDir(root: string, dir: string | undefined): string
186
187
  const lexical = relative(root, target)
187
188
  return lexical === "" || (!lexical.startsWith("..") && !isAbsolute(lexical)) ? target : null
188
189
  }
189
- let probe = target
190
- const missing: string[] = []
191
- while (true) {
192
- try {
193
- const canonicalProbe = realpathSync(probe)
194
- const rel = relative(canonicalRoot, canonicalProbe)
195
- if (rel.startsWith("..") || isAbsolute(rel)) return null
196
- return missing.length === 0 ? canonicalProbe : resolve(canonicalProbe, ...missing.reverse())
197
- } catch {
198
- const parent = resolve(probe, "..")
199
- if (parent === probe) return null
200
- // `basename`, not a slice past `parent`: when `parent` is the filesystem root its trailing
201
- // separator is part of it, so `parent.length + 1` eats the first character of the segment and
202
- // the path gets rebuilt wrong.
203
- missing.push(basename(probe))
204
- probe = parent
205
- }
206
- }
190
+ const canonical = writeTargetOf(target)
191
+ if (canonical === undefined) return null
192
+ const rel = relative(canonicalRoot, canonical)
193
+ return rel.startsWith("..") || isAbsolute(rel) ? null : canonical
207
194
  }
208
195
 
209
196
  /** Consistent error string for a `dir` that escapes the project root. */
@@ -6,7 +6,7 @@
6
6
  * answer still reads as authoritative. So the server settles the version before it serves:
7
7
  *
8
8
  * 1. The project installs its own `@nifrajs/cli` at a different version: hand the whole session to
9
- * that CLI (`node_modules/.bin/nifra mcp`, stdio inherited) and step aside.
9
+ * that CLI (its `bin` script, run by this Bun with `--no-env-file`, stdio inherited) and step aside.
10
10
  * 2. Handing off is impossible (no project CLI, a missing bin, or this process IS the hand-off and
11
11
  * the versions still disagree): serve, but the version-sensitive tools refuse with the fix
12
12
  * instead of answering for the wrong release.
@@ -31,27 +31,37 @@ export const VERSION_SENSITIVE_TOOLS: ReadonlySet<string> = new Set([
31
31
  "nifra_contracts",
32
32
  ])
33
33
 
34
- /** The project's own CLI: its version and, when the install linked one, its bin. */
34
+ /** The project's own CLI: its version and, when it is on disk, the script its `bin` names. */
35
35
  export interface ProjectCli {
36
36
  readonly version: string
37
37
  readonly bin?: string
38
38
  }
39
39
 
40
- const BIN_NAME = process.platform === "win32" ? "nifra.exe" : "nifra"
41
40
  const MAX_ANCESTOR_DIRS = 8
42
41
 
42
+ function binScript(bin: unknown): string | undefined {
43
+ if (typeof bin === "string") return bin
44
+ if (typeof bin !== "object" || bin === null) return undefined
45
+ const named: unknown = Reflect.get(bin, "nifra")
46
+ return typeof named === "string" ? named : undefined
47
+ }
48
+
43
49
  /** The nearest `@nifrajs/cli` install at or above `root` (a monorepo app hoists to the repo root). */
44
50
  export async function findProjectCli(root: string): Promise<ProjectCli | undefined> {
45
51
  let dir = root
46
52
  for (let depth = 0; depth < MAX_ANCESTOR_DIRS; depth++) {
47
- const modules = join(dir, "node_modules")
53
+ const pkg = join(dir, "node_modules", "@nifrajs", "cli")
48
54
  try {
49
- const meta = JSON.parse(
50
- await readFile(join(modules, "@nifrajs", "cli", "package.json"), "utf8"),
51
- ) as { version?: unknown }
55
+ const meta = JSON.parse(await readFile(join(pkg, "package.json"), "utf8")) as {
56
+ version?: unknown
57
+ bin?: unknown
58
+ }
52
59
  if (typeof meta.version === "string" && meta.version.length > 0) {
53
- const bin = join(modules, ".bin", BIN_NAME)
54
- return existsSync(bin) ? { version: meta.version, bin } : { version: meta.version }
60
+ const script = binScript(meta.bin)
61
+ const bin = script === undefined ? undefined : join(pkg, script)
62
+ return bin !== undefined && existsSync(bin)
63
+ ? { version: meta.version, bin }
64
+ : { version: meta.version }
55
65
  }
56
66
  } catch {
57
67
  // Not installed at this level - keep walking up.
@@ -79,7 +89,9 @@ export async function delegateToProjectCli(options: {
79
89
  const cli = await findProjectCli(options.root)
80
90
  if (cli === undefined || cli.version === options.version || cli.bin === undefined)
81
91
  return undefined
82
- return handOffSession([cli.bin, "mcp", ...options.args], {
92
+ // Not the `.bin` shim: its `bun` would load the project's `.env`, and the inherited
93
+ // NIFRA_MCP_WITHOUT_ENV_FILES then tells that CLI the values are already gone.
94
+ return handOffSession([process.execPath, "--no-env-file", cli.bin, "mcp", ...options.args], {
83
95
  cwd: options.cwd,
84
96
  env: { ...process.env, [MCP_DELEGATED_ENV]: "1" },
85
97
  announce: `[nifra] mcp: this CLI is ${options.version}; the project installs @nifrajs/cli ${cli.version} - serving this session from ${cli.bin}`,
package/src/mcp-exec.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  CHILD_INPUT_MAX_BYTES,
37
37
  CHILD_OUTPUT_MAX_BYTES,
38
38
  CHILD_TIMEOUT_MS,
39
+ type EscalatingStop,
40
+ escalatingStop,
39
41
  OWN_PROCESS_GROUP,
40
42
  readBoundedStream,
41
43
  signalProcessGroup,
@@ -714,11 +716,12 @@ export async function spawnChild(
714
716
  stderr: "pipe",
715
717
  detached: OWN_PROCESS_GROUP,
716
718
  })
719
+ const stopper = escalatingStop((name) => signalProcessGroup(proc, name))
717
720
  let killed = false
718
721
  const kill = (): void => {
719
722
  if (killed) return
720
723
  killed = true
721
- signalProcessGroup(proc, "SIGTERM")
724
+ stopper.stop()
722
725
  }
723
726
  const abort = (): void => kill()
724
727
  signal?.addEventListener("abort", abort, { once: true })
@@ -759,6 +762,7 @@ export async function spawnChild(
759
762
  return text || `${label} failed:\n${err.text.trim() || "(no output)"}`
760
763
  } finally {
761
764
  clearTimeout(timer)
765
+ stopper.dispose()
762
766
  signal?.removeEventListener("abort", abort)
763
767
  }
764
768
  }
@@ -838,6 +842,7 @@ export class WarmWorker {
838
842
  readonly cleanup: () => void
839
843
  }
840
844
  >()
845
+ private readonly stopper: EscalatingStop
841
846
  private stdoutBuffer = ""
842
847
  private stdoutBufferBytes = 0
843
848
  private stderrBuffer = ""
@@ -858,11 +863,13 @@ export class WarmWorker {
858
863
  stdout: "pipe",
859
864
  stderr: "pipe",
860
865
  }) as PipeSubprocess
866
+ this.stopper = escalatingStop((name) => this.proc.kill(name))
861
867
  liveWarmWorkers.add(this)
862
868
  void this.readStdout()
863
869
  void this.readStderr()
864
870
  void this.proc.exited.then((code) => {
865
871
  this.closed = true
872
+ this.stopper.dispose()
866
873
  liveWarmWorkers.delete(this)
867
874
  const err = new Error(
868
875
  `warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
@@ -876,7 +883,7 @@ export class WarmWorker {
876
883
  }
877
884
 
878
885
  stop(): void {
879
- if (!this.closed) this.proc.kill()
886
+ if (!this.closed) this.stopper.stop()
880
887
  }
881
888
 
882
889
  async request(input: unknown, signal?: AbortSignal): Promise<string> {
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "5.0.0"
32
+ const VERSION = "5.1.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>
package/src/mcp-io.ts CHANGED
@@ -36,6 +36,39 @@ export function signalProcessGroup(
36
36
  }
37
37
  }
38
38
 
39
+ /** How long a stopped child gets to exit after SIGTERM before it is sent SIGKILL. */
40
+ export const KILL_GRACE_MS = 2_000
41
+
42
+ /** Stopping a child: `stop` sends SIGTERM, `dispose` drops the SIGKILL still pending once it is done. */
43
+ export interface EscalatingStop {
44
+ readonly stop: () => void
45
+ readonly dispose: () => void
46
+ }
47
+
48
+ /**
49
+ * Stop a child with SIGTERM, then SIGKILL once `graceMs` passes. Project code can catch SIGTERM
50
+ * (a graceful-shutdown hook waiting on a hung request), and a child that never exits keeps its
51
+ * caller waiting and outlives it. `send` delivers one signal to the child or its group.
52
+ */
53
+ export function escalatingStop(
54
+ send: (signal: NodeJS.Signals) => void,
55
+ graceMs: number = KILL_GRACE_MS,
56
+ ): EscalatingStop {
57
+ let escalation: ReturnType<typeof setTimeout> | undefined
58
+ let disposed = false
59
+ return {
60
+ stop: () => {
61
+ if (disposed) return
62
+ send("SIGTERM")
63
+ escalation ??= setTimeout(() => send("SIGKILL"), graceMs)
64
+ },
65
+ dispose: () => {
66
+ disposed = true
67
+ clearTimeout(escalation)
68
+ },
69
+ }
70
+ }
71
+
39
72
  const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
40
73
 
41
74
  /**
package/src/mcp-path.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { realpathSync } from "node:fs"
2
- import { basename, isAbsolute, relative, resolve } from "node:path"
2
+ import { isAbsolute, relative, resolve } from "node:path"
3
+ import { writeTargetOf } from "./project-path.ts"
3
4
 
4
5
  /**
5
6
  * Resolve an MCP path without allowing lexical or symlink escapes from the selected project.
6
7
  *
7
8
  * MCP callers are not equivalent to a local CLI user: the selected checkout is their filesystem
8
9
  * capability boundary. The nearest-existing-ancestor walk also supports output files that do not
9
- * exist yet while still canonicalizing every existing path component.
10
+ * exist yet while still canonicalizing every existing path component, and a dangling symlink is
11
+ * judged by where it points.
10
12
  */
11
13
  export function resolveMcpProjectPath(root: string, value: string): string | null {
12
14
  if (value.length === 0 || isAbsolute(value)) return null
@@ -23,26 +25,10 @@ export function resolveMcpProjectPath(root: string, value: string): string | nul
23
25
  return null
24
26
  }
25
27
 
26
- let probe = target
27
- const missing: string[] = []
28
- while (true) {
29
- try {
30
- const canonicalProbe = realpathSync(probe)
31
- const canonicalRelative = relative(canonicalRoot, canonicalProbe)
32
- if (canonicalRelative.startsWith("..") || isAbsolute(canonicalRelative)) return null
33
-
34
- const candidate = resolve(canonicalProbe, ...missing.reverse())
35
- const candidateRelative = relative(canonicalRoot, candidate)
36
- return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
37
- } catch (error) {
38
- if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT"))
39
- return null
40
- const parent = resolve(probe, "..")
41
- if (parent === probe) return null
42
- missing.push(basename(probe))
43
- probe = parent
44
- }
45
- }
28
+ const candidate = writeTargetOf(target)
29
+ if (candidate === undefined) return null
30
+ const candidateRelative = relative(canonicalRoot, candidate)
31
+ return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
46
32
  }
47
33
 
48
34
  export function mcpProjectPathError(label: string, value: string): string {
package/src/mcp.ts CHANGED
@@ -181,6 +181,13 @@ function appFeatures(cwd: string, surface: IsolatedSurface): McpServerFeatures {
181
181
  }
182
182
 
183
183
  const ROOTS_REQUEST_ID = "nifra:roots/list"
184
+ /** Messages whose answer reads nothing the app declares: the handshake is never held up by its import. */
185
+ const ANSWERED_WITHOUT_THE_APP: ReadonlySet<string> = new Set([
186
+ "initialize",
187
+ "ping",
188
+ "notifications/initialized",
189
+ "notifications/roots/list_changed",
190
+ ])
184
191
  const MAX_STDIO_MESSAGE_BYTES = 8 * 1024 * 1024
185
192
  const STDIO_ENCODER = new TextEncoder()
186
193
 
@@ -207,8 +214,12 @@ async function createProjectContext(root: string): Promise<ProjectContext> {
207
214
  )
208
215
  const allResources: McpResource[] = []
209
216
  const allPrompts: McpPrompt[] = []
210
- for (const { name, cwd: appCwd, surface } of apps) {
211
- const ns = namespaceForApp(name, [], appFeatures(appCwd, await surface()))
217
+ // Each app loads in its own subprocess, so they load side by side.
218
+ const loaded = await Promise.all(
219
+ apps.map(async (app) => ({ app, surface: await app.surface() })),
220
+ )
221
+ for (const { app, surface } of loaded) {
222
+ const ns = namespaceForApp(app.name, [], appFeatures(app.cwd, surface))
212
223
  allResources.push(...(ns.features.resources ?? []))
213
224
  allPrompts.push(...(ns.features.prompts ?? []))
214
225
  }
@@ -251,7 +262,9 @@ export async function runMcpServer(
251
262
  return
252
263
  }
253
264
  }
254
- let ctx = await createProjectContext(rootState.root)
265
+ // Loaded in the background: the app's import can take seconds, and the handshake needs none of it.
266
+ let ctx = createProjectContext(rootState.root)
267
+ ctx.catch(() => {})
255
268
  let drift: ToolingDrift | undefined = await detectToolingDrift(rootState.root, version)
256
269
  const serverInfo = { name: "nifra", version }
257
270
  const state = createMcpProtocolState()
@@ -286,7 +299,8 @@ export async function runMcpServer(
286
299
  if (result === undefined) return
287
300
  const next = await applyClientRoots(rootState, pathsFromRootsResult(result))
288
301
  if (next.root !== rootState.root) {
289
- ctx = await createProjectContext(next.root)
302
+ ctx = createProjectContext(next.root)
303
+ ctx.catch(() => {})
290
304
  drift = await detectToolingDrift(next.root, version)
291
305
  }
292
306
  rootState = next
@@ -313,15 +327,38 @@ export async function runMcpServer(
313
327
  // the code it started with: re-read the installed version per call so no answer is for a stale one.
314
328
  if (message.method === "tools/call") drift = await detectToolingDrift(rootState.root, version)
315
329
 
330
+ const method = message.method ?? ""
331
+ if (ANSWERED_WITHOUT_THE_APP.has(method)) {
332
+ const response = await handleRpc(
333
+ message,
334
+ [],
335
+ serverInfo,
336
+ {
337
+ resources: projectResources(rootState.root, noAppInServer),
338
+ prompts: projectPrompts(),
339
+ instructions: rootInstructions(rootState, drift),
340
+ },
341
+ { state, sendNotification: send, exposeToolErrors: true },
342
+ )
343
+ if (response) send(response)
344
+ // Ask for the client's workspace roots once the handshake completes, and again whenever the
345
+ // client says they changed. The answer comes back through the interception above.
346
+ if (method.startsWith("notifications/")) requestRoots()
347
+ return
348
+ }
349
+ const project = await ctx
316
350
  let activeTools: McpTool[]
317
- if (ctx.apps) {
351
+ if (project.apps) {
352
+ const loaded = await Promise.all(
353
+ project.apps.map(async (app) => ({ app, surface: await app.surface() })),
354
+ )
318
355
  const allTools: McpTool[] = []
319
- for (const { name, cwd: appCwd, surface } of ctx.apps) {
356
+ for (const { app, surface } of loaded) {
320
357
  const tools = [
321
- ...refuseVersionSensitive(isolatedProjectTools(appCwd), drift),
322
- ...(await surface()).tools,
358
+ ...refuseVersionSensitive(isolatedProjectTools(app.cwd), drift),
359
+ ...surface.tools,
323
360
  ]
324
- const ns = namespaceForApp(name, tools, { resources: [], prompts: [] })
361
+ const ns = namespaceForApp(app.name, tools, { resources: [], prompts: [] })
325
362
  allTools.push(...ns.tools)
326
363
  }
327
364
  activeTools = [
@@ -334,7 +371,7 @@ export async function runMcpServer(
334
371
  } else {
335
372
  activeTools = [
336
373
  ...refuseVersionSensitive(isolatedProjectTools(rootState.root), drift),
337
- ...(await ctx.surface()).tools,
374
+ ...(await project.surface()).tools,
338
375
  ]
339
376
  }
340
377
  const verdict = await rootVerdict(rootState)
@@ -346,7 +383,7 @@ export async function runMcpServer(
346
383
  )
347
384
 
348
385
  const features: McpServerFeatures = {
349
- ...ctx.features,
386
+ ...project.features,
350
387
  instructions: rootInstructions(rootState, drift),
351
388
  }
352
389
  const response = await handleRpc(message, activeTools, serverInfo, features, {
@@ -356,14 +393,6 @@ export async function runMcpServer(
356
393
  exposeToolErrors: true,
357
394
  })
358
395
  if (response) send(response)
359
- // Ask for the client's workspace roots once the handshake completes, and again whenever the
360
- // client says they changed. The answer comes back through the interception above.
361
- if (
362
- message.method === "notifications/initialized" ||
363
- message.method === "notifications/roots/list_changed"
364
- ) {
365
- requestRoots()
366
- }
367
396
  }
368
397
 
369
398
  const decoder = new TextDecoder()