@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
@@ -1099,27 +1099,126 @@ const existingStylexImport = (source: string): string | undefined => {
1099
1099
  return match?.[1]
1100
1100
  }
1101
1101
 
1102
- const insertionOffset = (source: string): number => {
1103
- let offset = source.startsWith("#!") ? source.indexOf("\n") + 1 || source.length : 0
1104
- const lines = source.slice(offset).split(/(?<=\n)/)
1105
- let consumed = 0
1106
- let inImport = false
1107
- for (const line of lines) {
1108
- const trimmed = line.trim()
1109
- const directive = /^(['"])(?:use client|use server)\1;?$/.test(trimmed)
1110
- if (inImport || trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
1111
- inImport = !/(?:from\s+["'][^"']+["']|import\s+["'][^"']+["'])\s*;?$/.test(trimmed)
1112
- consumed += line.length
1102
+ const isIdentifierChar = (char: string | undefined): boolean =>
1103
+ char !== undefined && /[\w$]/.test(char)
1104
+
1105
+ /** Past the whitespace and comments at `index`. `inLine` stops at the end of the line. */
1106
+ function skipTrivia(source: string, index: number, inLine = false): number {
1107
+ let at = index
1108
+ while (at < source.length) {
1109
+ const char = source[at]
1110
+ if (char === " " || char === "\t" || char === "\r" || (char === "\n" && !inLine)) at++
1111
+ else if (source.startsWith("//", at)) {
1112
+ const end = source.indexOf("\n", at)
1113
+ if (end === -1) return source.length
1114
+ at = inLine ? end : end + 1
1115
+ } else if (source.startsWith("/*", at)) {
1116
+ const end = source.indexOf("*/", at + 2)
1117
+ if (end === -1) return source.length
1118
+ at = end + 2
1119
+ } else break
1120
+ }
1121
+ return at
1122
+ }
1123
+
1124
+ function skipString(source: string, index: number): number {
1125
+ const quote = source[index]
1126
+ let at = index + 1
1127
+ while (at < source.length && source[at] !== quote && source[at] !== "\n")
1128
+ at += source[at] === "\\" ? 2 : 1
1129
+ return Math.min(at + 1, source.length)
1130
+ }
1131
+
1132
+ /** Past an import's specifier: its `with { ... }` (or `assert`) attributes and a `;`. */
1133
+ function afterSpecifier(source: string, index: number): number {
1134
+ let at = index
1135
+ const next = skipTrivia(source, at, true)
1136
+ for (const keyword of ["with", "assert"]) {
1137
+ if (!source.startsWith(keyword, next) || isIdentifierChar(source[next + keyword.length]))
1138
+ continue
1139
+ const open = skipTrivia(source, next + keyword.length)
1140
+ const close = source[open] === "{" ? source.indexOf("}", open) : -1
1141
+ if (close !== -1) at = close + 1
1142
+ }
1143
+ const semicolon = skipTrivia(source, at, true)
1144
+ return source[semicolon] === ";" ? semicolon + 1 : at
1145
+ }
1146
+
1147
+ /** Where the import declaration whose `import` keyword is at `index` ends, or `undefined` for an
1148
+ * `import(...)` / `import.meta` expression or a declaration this scan cannot read. */
1149
+ function importEnd(source: string, index: number): number | undefined {
1150
+ let at = skipTrivia(source, index + "import".length)
1151
+ if (source[at] === "(" || source[at] === ".") return undefined
1152
+ if (source[at] === '"' || source[at] === "'")
1153
+ return afterSpecifier(source, skipString(source, at))
1154
+ let depth = 0
1155
+ while (at < source.length) {
1156
+ const char = source[at]
1157
+ if (char === '"' || char === "'") {
1158
+ at = skipString(source, at)
1113
1159
  continue
1114
1160
  }
1115
- if (directive || trimmed === "") {
1116
- consumed += line.length
1161
+ if (source.startsWith("//", at) || source.startsWith("/*", at)) {
1162
+ at = skipTrivia(source, at)
1117
1163
  continue
1118
1164
  }
1119
- break
1165
+ if (char === "{") depth++
1166
+ else if (char === "}") depth--
1167
+ else if (depth === 0 && (char === ";" || char === "=")) return undefined
1168
+ else if (
1169
+ depth === 0 &&
1170
+ source.startsWith("from", at) &&
1171
+ !isIdentifierChar(source[at - 1]) &&
1172
+ !isIdentifierChar(source[at + 4])
1173
+ ) {
1174
+ const specifier = skipTrivia(source, at + 4)
1175
+ if (source[specifier] === '"' || source[specifier] === "'")
1176
+ return afterSpecifier(source, skipString(source, specifier))
1177
+ }
1178
+ at++
1179
+ }
1180
+ return undefined
1181
+ }
1182
+
1183
+ /** Past the rest of the line `index` is on when only trivia is left on it, then any blank lines. */
1184
+ function nextLine(source: string, index: number): number {
1185
+ let at = skipTrivia(source, index, true)
1186
+ if (at < source.length && source[at] !== "\n") return index
1187
+ for (;;) {
1188
+ if (source[at] !== "\n") return at
1189
+ let next = at + 1
1190
+ while (source[next] === " " || source[next] === "\t" || source[next] === "\r") next++
1191
+ if (next < source.length && source[next] !== "\n") return at + 1
1192
+ at = next
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Where the generated block goes: after the directive prologue and the import declarations. Comments
1198
+ * are read as comments, so one before `"use client"` keeps the directive first, and a trailing
1199
+ * `// note` on the last import does not hide where it ends.
1200
+ */
1201
+ const insertionOffset = (source: string): number => {
1202
+ let end = source.startsWith("#!") ? source.indexOf("\n") + 1 || source.length : 0
1203
+ let prologue = true
1204
+ for (;;) {
1205
+ const at = skipTrivia(source, end)
1206
+ const char = source[at]
1207
+ let statementEnd: number | undefined
1208
+ if (prologue && (char === '"' || char === "'")) {
1209
+ const literal = skipString(source, at)
1210
+ const after = skipTrivia(source, literal, true)
1211
+ // A string with more of an expression after it on its line is not a directive.
1212
+ if (source[after] === ";") statementEnd = after + 1
1213
+ else if (after === source.length || source[after] === "\n") statementEnd = literal
1214
+ } else if (source.startsWith("import", at) && !isIdentifierChar(source[at + 6])) {
1215
+ prologue = false
1216
+ statementEnd = importEnd(source, at)
1217
+ }
1218
+ if (statementEnd === undefined) break
1219
+ end = nextLine(source, statementEnd)
1120
1220
  }
1121
- offset += consumed
1122
- return offset
1221
+ return end
1123
1222
  }
1124
1223
 
1125
1224
  /** Transform one source file. The returned source is unchanged when every match is unsupported. */
@@ -1196,7 +1295,8 @@ export function migrateTailwindSource(source: string, file: string): SourceMigra
1196
1295
  const needsImport = existingStylexImport(source) === undefined
1197
1296
  const generated = `${needsImport ? `import * as ${stylexName} from "${STYLEX_SPECIFIER}"\n\n` : ""}const ${stylesName} = ${stylexName}.create(${JSON.stringify(Object.fromEntries(styles), null, 2)})\n\n`
1198
1297
  const offset = insertionOffset(rewritten)
1199
- const withGenerated = `${rewritten.slice(0, offset)}${generated}${rewritten.slice(offset)}`
1298
+ const separator = offset > 0 && rewritten[offset - 1] !== "\n" ? "\n" : ""
1299
+ const withGenerated = `${rewritten.slice(0, offset)}${separator}${generated}${rewritten.slice(offset)}`
1200
1300
  return { source: withGenerated, changed: true, replacements: replacements.length, issues }
1201
1301
  }
1202
1302
 
package/src/test-tool.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ escalatingStop,
2
3
  type HeadTailOutput,
3
4
  OWN_PROCESS_GROUP,
4
5
  readHeadTail,
@@ -63,8 +64,6 @@ function normalizeTimeout(value: unknown): number {
63
64
  return Math.min(Math.max(Math.trunc(value), 1_000), MAX_TIMEOUT_MS)
64
65
  }
65
66
 
66
- const KILL_GRACE_MS = 2_000
67
-
68
67
  const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g")
69
68
 
70
69
  // A UTF-8 character is at most four bytes, so these hold every character the result can show.
@@ -157,12 +156,7 @@ export async function collectTestResult(
157
156
  })
158
157
  let timedOut = false
159
158
  let cancelled = false
160
- let escalation: ReturnType<typeof setTimeout> | undefined
161
- // A test can ignore SIGTERM, so the group gets SIGKILL once the grace period runs out.
162
- const stop = (): void => {
163
- signalProcessGroup(proc, "SIGTERM")
164
- escalation ??= setTimeout(() => signalProcessGroup(proc, "SIGKILL"), KILL_GRACE_MS)
165
- }
159
+ const { stop, dispose } = escalatingStop((name) => signalProcessGroup(proc, name))
166
160
  const abort = (): void => {
167
161
  cancelled = true
168
162
  stop()
@@ -189,7 +183,7 @@ export async function collectTestResult(
189
183
  exitCode = result[2]
190
184
  } finally {
191
185
  clearTimeout(timer)
192
- clearTimeout(escalation)
186
+ dispose()
193
187
  opts.signal?.removeEventListener("abort", abort)
194
188
  }
195
189
  const stdout = clean(stdoutRaw)
package/src/upgrade.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * 1. **pin sweep** - set every matching dependency to the target version across the workspace's
7
7
  * package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
8
- * `overrides`, `resolutions` and `pnpm.overrides` are swept too, since they decide what installs;
9
- * a version pinned anywhere else (an MCP config, a Dockerfile, another lockfile) is listed only.
8
+ * `overrides`, `resolutions` and `pnpm.overrides` are swept too, since they decide what installs.
9
+ * The MCP launch pin in `.mcp.json`, `.cursor/mcp.json`, CLAUDE.md and AGENTS.md moves too, the
10
+ * edit `nifra init-agents --sync-mcp` makes; a version pinned anywhere else (a Dockerfile, a CI
11
+ * workflow, another lockfile) is listed only.
10
12
  * 2. **dependency moves** - replace removed packages with their supported successor without
11
13
  * reserializing package.json or leaving duplicate dependency keys.
12
14
  * 3. **import moves** - rewrite exact import specifiers to their updated module paths.
@@ -26,6 +28,7 @@
26
28
 
27
29
  import {
28
30
  existsSync,
31
+ lstatSync,
29
32
  mkdirSync,
30
33
  readdirSync,
31
34
  readFileSync,
@@ -45,6 +48,7 @@ import {
45
48
  parseHookReport,
46
49
  renderHookDiff,
47
50
  } from "./hooks-report.ts"
51
+ import { PIN_SITES } from "./init-agents.ts"
48
52
  import { getRecipe, listRecipeVersions, type UpgradeRecipe } from "./recipes/index.ts"
49
53
  import { markerTarget } from "./rules/comment-markers.ts"
50
54
 
@@ -97,6 +101,14 @@ export interface DependencyMoveChange {
97
101
  readonly action: "renamed" | "removed"
98
102
  }
99
103
 
104
+ /** An agent file whose MCP launch pins another `@nifrajs/cli`, re-pinned to the target. */
105
+ export interface AgentPinChange {
106
+ readonly file: string
107
+ /** The versions it pinned, in order of appearance. */
108
+ readonly from: readonly string[]
109
+ readonly to: string
110
+ }
111
+
100
112
  /** A retired comment in the project, and the line it applied to before the release retired it. */
101
113
  export interface RetiredMarkerSite {
102
114
  readonly file: string
@@ -110,6 +122,7 @@ export interface RetiredMarkerSite {
110
122
  export interface UpgradePlan {
111
123
  readonly version: string
112
124
  readonly pins: readonly PinChange[]
125
+ readonly agentPins: readonly AgentPinChange[]
113
126
  readonly dependencyMoves: readonly DependencyMoveChange[]
114
127
  readonly importMoves: readonly ImportChange[]
115
128
  /** Pins refused because the target is BELOW the installed version (a rollback). Empty when allowed. */
@@ -540,7 +553,7 @@ function scan(cwd: string, pattern: string, dot = false): string[] {
540
553
  }
541
554
 
542
555
  // The fixed version group: every package released together at one version.
543
- const isFixedGroup = (name: string): boolean =>
556
+ export const isFixedGroup = (name: string): boolean =>
544
557
  name.startsWith("@nifrajs/") || name === "nifra" || name === "create-nifra"
545
558
 
546
559
  const groupPins = (version: string): UpgradeRecipe["pins"] => [
@@ -549,6 +562,20 @@ const groupPins = (version: string): UpgradeRecipe["pins"] => [
549
562
  { match: "nifra", to: version },
550
563
  ]
551
564
 
565
+ /** Every fixed-group package a package.json under `cwd` names, whatever its spec. */
566
+ export function groupDeclaredNames(cwd: string): ReadonlySet<string> {
567
+ const names = new Set<string>()
568
+ for (const rel of scan(cwd, "**/package.json")) {
569
+ try {
570
+ const parsed = JSON.parse(readFileSync(join(cwd, rel), "utf8")) as Record<string, unknown>
571
+ for (const { target } of pinEntries(parsed)) if (isFixedGroup(target)) names.add(target)
572
+ } catch {
573
+ // Not JSON: it declares nothing.
574
+ }
575
+ }
576
+ return names
577
+ }
578
+
552
579
  /**
553
580
  * Every fixed-group version the package.json files under `cwd` declare with a plain semver spec, each
554
581
  * with the files that declare it. An override counts: it decides what installs. `workspace:`, `link:`
@@ -589,6 +616,7 @@ export interface StrayPin {
589
616
 
590
617
  const STRAY_PIN_FILES = [
591
618
  "**/{.mcp.json,mcp.json}",
619
+ "**/{AGENTS.md,CLAUDE.md}",
592
620
  "**/{Dockerfile,Dockerfile.*,*.Dockerfile,*.dockerfile,Containerfile}",
593
621
  "**/{docker-compose,compose}*.{yml,yaml}",
594
622
  ".github/workflows/*.{yml,yaml}",
@@ -601,10 +629,16 @@ const GROUP_PIN =
601
629
  /(?<![\w.@/-])(@nifrajs\/[a-z0-9._-]+|create-nifra|nifra)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g
602
630
 
603
631
  /**
604
- * Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs,
605
- * container files, CI workflows, and lockfiles besides the workspace's own (which install rewrites).
632
+ * Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs and
633
+ * agent guidance beyond the launch pin it moves, container files, CI workflows, and lockfiles besides
634
+ * the workspace's own (which install rewrites). `planned` holds a file's text after the upgrade's own
635
+ * edits, so a dry run lists what `--write` would leave.
606
636
  */
607
- export function strayPins(cwd: string, version: string): StrayPin[] {
637
+ export function strayPins(
638
+ cwd: string,
639
+ version: string,
640
+ planned: ReadonlyMap<string, string> = new Map(),
641
+ ): StrayPin[] {
608
642
  const found: StrayPin[] = []
609
643
  const files = new Set(STRAY_PIN_FILES.flatMap((pattern) => scan(cwd, pattern, true)))
610
644
  for (const rel of [...files].sort()) {
@@ -613,7 +647,7 @@ export function strayPins(cwd: string, version: string): StrayPin[] {
613
647
  let text: string
614
648
  try {
615
649
  if (statSync(join(cwd, rel)).size > 8_000_000) continue
616
- text = readFileSync(join(cwd, rel), "utf8")
650
+ text = planned.get(rel) ?? readFileSync(join(cwd, rel), "utf8")
617
651
  } catch {
618
652
  continue
619
653
  }
@@ -635,6 +669,43 @@ export function strayPins(cwd: string, version: string): StrayPin[] {
635
669
  return found
636
670
  }
637
671
 
672
+ /** Whether `rel` is a regular file under `cwd` reached through no symlink: the upgrade's write boundary. */
673
+ function plainFileAt(cwd: string, rel: string): boolean {
674
+ let path = cwd
675
+ const parts = rel.split("/")
676
+ for (const [index, part] of parts.entries()) {
677
+ path = join(path, part)
678
+ let stat: ReturnType<typeof lstatSync>
679
+ try {
680
+ stat = lstatSync(path)
681
+ } catch {
682
+ return false
683
+ }
684
+ if (stat.isSymbolicLink()) return false
685
+ if (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory()) return false
686
+ }
687
+ return true
688
+ }
689
+
690
+ /**
691
+ * The MCP launch pin in each agent file at `cwd`, moved to `version`: the files and the edit of
692
+ * `nifra init-agents --sync-mcp`, so an agent launches the nifra the project installs once it does.
693
+ */
694
+ function agentPinEdits(
695
+ cwd: string,
696
+ version: string,
697
+ ): Array<{ readonly change: AgentPinChange; readonly text: string }> {
698
+ const edits: Array<{ change: AgentPinChange; text: string }> = []
699
+ for (const site of PIN_SITES) {
700
+ if (!plainFileAt(cwd, site.path)) continue
701
+ // latin1 is one char per byte, so every byte outside a pin is written back as it was.
702
+ const scan = site.repin(readFileSync(join(cwd, site.path), "latin1"), version)
703
+ if (scan.stale.length === 0) continue
704
+ edits.push({ change: { file: site.path, from: scan.stale, to: version }, text: scan.text })
705
+ }
706
+ return edits
707
+ }
708
+
638
709
  /** An installed fixed-group package, followed to its real location. */
639
710
  export interface LinkedGroupInstall {
640
711
  readonly package: string
@@ -752,6 +823,8 @@ export function computeUpgrade(
752
823
  exact = false,
753
824
  ): UpgradePlan {
754
825
  const pins: PinChange[] = []
826
+ const agentPins: AgentPinChange[] = []
827
+ const planned = new Map<string, string>()
755
828
  const dependencyMoves: DependencyMoveChange[] = []
756
829
  const importMoves: ImportChange[] = []
757
830
  const downgrades: PinChange[] = []
@@ -773,6 +846,12 @@ export function computeUpgrade(
773
846
  }
774
847
  }
775
848
 
849
+ for (const { change, text } of agentPinEdits(cwd, recipe.version)) {
850
+ agentPins.push(change)
851
+ planned.set(change.file, text)
852
+ if (write) writeFileSync(join(cwd, change.file), Buffer.from(text, "latin1"))
853
+ }
854
+
776
855
  if (recipe.importMoves.length > 0) {
777
856
  for (const rel of scan(cwd, SOURCE_GLOB)) {
778
857
  const abs = join(cwd, rel)
@@ -808,12 +887,13 @@ export function computeUpgrade(
808
887
  return {
809
888
  version: recipe.version,
810
889
  pins,
890
+ agentPins,
811
891
  dependencyMoves,
812
892
  importMoves,
813
893
  downgrades,
814
894
  retiredMarkers,
815
895
  linkedOutside,
816
- strayPins: strayPins(cwd, recipe.version),
896
+ strayPins: strayPins(cwd, recipe.version, planned),
817
897
  notes: recipe.notes ?? [],
818
898
  }
819
899
  }
@@ -837,6 +917,7 @@ function renderPlan(
837
917
  lines.push("")
838
918
  if (
839
919
  plan.pins.length === 0 &&
920
+ plan.agentPins.length === 0 &&
840
921
  plan.dependencyMoves.length === 0 &&
841
922
  plan.importMoves.length === 0 &&
842
923
  plan.retiredMarkers.length === 0 &&
@@ -862,6 +943,13 @@ function renderPlan(
862
943
  for (const p of plan.pins) lines.push(` ${p.file} ${p.name}: ${p.from} → ${p.to}`)
863
944
  lines.push("")
864
945
  }
946
+ if (plan.agentPins.length > 0) {
947
+ lines.push(`${verb} ${plan.agentPins.length} MCP launch pin(s):`)
948
+ for (const p of plan.agentPins)
949
+ lines.push(` ${p.file} @nifrajs/cli: ${p.from.join(", ")} → ${p.to}`)
950
+ if (write) lines.push(" Restart your agent so it launches the new nifra MCP server.")
951
+ lines.push("")
952
+ }
865
953
  if (plan.importMoves.length > 0) {
866
954
  const total = plan.importMoves.reduce((n, m) => n + m.count, 0)
867
955
  lines.push(`${verb} ${total} import move(s) across ${plan.importMoves.length} file(s):`)
@@ -37,6 +37,11 @@ const GATES = Object.freeze([
37
37
  "Run `bun run typecheck` and fix the reported TypeScript errors.",
38
38
  ),
39
39
  gate("tests", [["run", "test"]], "Run `bun run test` and fix the first failing test."),
40
+ gate(
41
+ "rest-tests",
42
+ [["run", "test:rest"]],
43
+ "Run `bun run test:rest` and fix the first failing test.",
44
+ ),
40
45
  gate(
41
46
  "docs",
42
47
  [["run", "check:docs"]],
@@ -216,13 +221,15 @@ const DEFAULT_PLAN = Object.freeze([
216
221
  "changesets",
217
222
  ] as const)
218
223
 
224
+ // `coverage` fails on any failing test, so it is the release plan's run of the library suites;
225
+ // `rest-tests` runs the suites it leaves out, and `tests` would only repeat the two.
219
226
  const RELEASE_PLAN = Object.freeze([
220
227
  "build",
221
228
  "lint",
222
229
  "typecheck",
223
- "tests",
224
- "cli-isolation",
225
230
  "coverage",
231
+ "rest-tests",
232
+ "cli-isolation",
226
233
  "corpus",
227
234
  "sitemap",
228
235
  "docs",
@@ -1,10 +1,28 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { join, sep } from "node:path"
1
3
  import {
2
4
  compareSemverSpec,
5
+ groupDeclaredNames,
3
6
  groupPinVersions,
4
7
  installedGroupPackages,
8
+ isFixedGroup,
5
9
  type LinkedGroupInstall,
6
10
  } from "./upgrade.ts"
7
11
 
12
+ /** The fixed-group packages the package installed at `dir` depends on. */
13
+ function groupDependenciesAt(dir: string): string[] {
14
+ try {
15
+ const manifest: unknown = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
16
+ if (typeof manifest !== "object" || manifest === null) return []
17
+ return ["dependencies", "peerDependencies", "optionalDependencies"].flatMap((field) => {
18
+ const deps: unknown = Reflect.get(manifest, field)
19
+ return typeof deps === "object" && deps !== null ? Object.keys(deps).filter(isFixedGroup) : []
20
+ })
21
+ } catch {
22
+ return []
23
+ }
24
+ }
25
+
8
26
  /** One version of the fixed release group, and where the workspace carries it. */
9
27
  export interface GroupVersion {
10
28
  readonly version: string
@@ -35,7 +53,13 @@ export function collectVersionDrift(cwd: string, cliVersion?: string): VersionDr
35
53
  for (const file of files) add(version, `pinned in ${file}`)
36
54
  }
37
55
  const linkedOutside: LinkedGroupInstall[] = []
38
- for (const { package: name, path, version, inside } of installedGroupPackages(cwd)) {
56
+ const installs = installedGroupPackages(cwd)
57
+ // An install nothing declares, such as one an older install left behind, loads nowhere, and no
58
+ // upgrade re-pins it: counting it would report drift that never clears.
59
+ const declared = new Set(groupDeclaredNames(cwd))
60
+ for (const { path } of installs) for (const name of groupDependenciesAt(path)) declared.add(name)
61
+ for (const { package: name, path, version, inside } of installs) {
62
+ if (!declared.has(name)) continue
39
63
  add(version, `installed ${name}${inside ? "" : ` (linked from ${path})`}`)
40
64
  if (!inside) linkedOutside.push({ package: name, path, version })
41
65
  }
@@ -51,6 +75,33 @@ export function collectVersionDrift(cwd: string, cliVersion?: string): VersionDr
51
75
  }
52
76
  }
53
77
 
78
+ /** The install a package belongs to: the folder above its last `node_modules`, or the package itself. */
79
+ const installRoot = (path: string): string => {
80
+ const parts = path.split(sep)
81
+ const last = parts.lastIndexOf("node_modules")
82
+ return last <= 0 ? path : parts.slice(0, last).join(sep)
83
+ }
84
+
85
+ /** Linked installs, one phrase per install and version: a linked repository brings its whole group. */
86
+ function linkedGroups(installs: readonly LinkedGroupInstall[]): string[] {
87
+ const groups = new Map<string, LinkedGroupInstall[]>()
88
+ for (const install of installs) {
89
+ const key = `${installRoot(install.path)}\0${install.version}`
90
+ groups.set(key, [...(groups.get(key) ?? []), install])
91
+ }
92
+ return [...groups.values()].map((group) => {
93
+ const [first] = group
94
+ if (first === undefined) return ""
95
+ if (group.length === 1) return `${first.package} ${first.version} at ${first.path}`
96
+ const names = group.map((install) => install.package).sort()
97
+ const listed =
98
+ names.length > 3
99
+ ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`
100
+ : names.join(", ")
101
+ return `${group.length} packages at ${first.version} under ${installRoot(first.path)} (${listed})`
102
+ })
103
+ }
104
+
54
105
  /** The advisory both `nifra doctor` and `nifra check` print. */
55
106
  export function versionDriftMessage(drift: VersionDrift): string {
56
107
  const listed = drift.versions
@@ -62,6 +113,6 @@ export function versionDriftMessage(drift: VersionDrift): string {
62
113
  const linked =
63
114
  drift.linkedOutside.length === 0
64
115
  ? ""
65
- : ` It does not edit ${drift.linkedOutside.map((entry) => `${entry.package} ${entry.version} at ${entry.path}`).join(", ")}, linked from outside the workspace root; upgrade that repository on its own.`
116
+ : ` It does not edit ${linkedGroups(drift.linkedOutside).join(", ")}, linked from outside the workspace root; upgrade that repository on its own.`
66
117
  return `mixed nifra versions in this workspace: ${listed}. Run \`nifra upgrade ${drift.highest}\` (it runs each release's migration steps, then re-pins).${linked}`
67
118
  }