@nifrajs/cli 2.3.0 → 2.4.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 (45) hide show
  1. package/dist/check.d.ts.map +1 -1
  2. package/dist/check.js +4 -1
  3. package/dist/check.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +17 -4
  6. package/dist/cli.js.map +1 -1
  7. package/dist/doctor.d.ts.map +1 -1
  8. package/dist/doctor.js +4 -0
  9. package/dist/doctor.js.map +1 -1
  10. package/dist/learn.d.ts +32 -0
  11. package/dist/learn.d.ts.map +1 -0
  12. package/dist/learn.js +127 -0
  13. package/dist/learn.js.map +1 -0
  14. package/dist/mcp-app-tool.d.ts +14 -0
  15. package/dist/mcp-app-tool.d.ts.map +1 -0
  16. package/dist/mcp-app-tool.js +80 -0
  17. package/dist/mcp-app-tool.js.map +1 -0
  18. package/dist/mcp-docs-tools.d.ts +1 -1
  19. package/dist/mcp-docs-tools.d.ts.map +1 -1
  20. package/dist/mcp-docs-tools.js +17 -1
  21. package/dist/mcp-docs-tools.js.map +1 -1
  22. package/dist/mcp-http.d.ts +10 -6
  23. package/dist/mcp-http.d.ts.map +1 -1
  24. package/dist/mcp-http.js +26 -12
  25. package/dist/mcp-http.js.map +1 -1
  26. package/dist/mcp.d.ts +14 -0
  27. package/dist/mcp.d.ts.map +1 -1
  28. package/dist/mcp.js +188 -0
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/upgrade.d.ts +13 -2
  31. package/dist/upgrade.d.ts.map +1 -1
  32. package/dist/upgrade.js +105 -7
  33. package/dist/upgrade.js.map +1 -1
  34. package/docs/llms-full.txt +75 -1
  35. package/docs/types.json +162 -11
  36. package/package.json +9 -9
  37. package/src/check.ts +5 -1
  38. package/src/cli.ts +21 -4
  39. package/src/doctor.ts +8 -0
  40. package/src/learn.ts +152 -0
  41. package/src/mcp-app-tool.ts +86 -0
  42. package/src/mcp-docs-tools.ts +18 -1
  43. package/src/mcp-http.ts +27 -14
  44. package/src/mcp.ts +237 -0
  45. package/src/upgrade.ts +125 -7
package/src/upgrade.ts CHANGED
@@ -33,6 +33,8 @@ export interface UpgradeOptions {
33
33
  readonly list?: boolean
34
34
  /** After `--write`, run `nifra check` and fail the command if it fails. Default true. */
35
35
  readonly verify?: boolean
36
+ /** Permit a target BELOW the installed version (a rollback). Default false → fail-closed. */
37
+ readonly allowDowngrade?: boolean
36
38
  }
37
39
 
38
40
  export interface PinChange {
@@ -65,6 +67,8 @@ export interface UpgradePlan {
65
67
  readonly pins: readonly PinChange[]
66
68
  readonly dependencyMoves: readonly DependencyMoveChange[]
67
69
  readonly importMoves: readonly ImportChange[]
70
+ /** Pins refused because the target is BELOW the installed version (a rollback). Empty when allowed. */
71
+ readonly downgrades: readonly PinChange[]
68
72
  readonly notes: readonly string[]
69
73
  }
70
74
 
@@ -78,7 +82,7 @@ const DEP_FIELDS = [
78
82
  // Only a bare semver spec is rewritten. Anything else - workspace:*, link:/file:, npm: aliases, git
79
83
  // urls, "*", "latest", or a multi-part range - is intentionally left untouched (skipped, not guessed).
80
84
  const SEMVER_SPEC =
81
- /^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
85
+ /^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
82
86
 
83
87
  /**
84
88
  * Rewrite a dependency version spec to `toVersion`, preserving the range operator (`^`, `~`, …).
@@ -92,6 +96,67 @@ export function rewriteVersionSpec(spec: string, toVersion: string): string | nu
92
96
  return next === spec ? null : next
93
97
  }
94
98
 
99
+ /** Numeric core (major, minor, patch) of a semver spec, ignoring the range operator + prerelease/build. */
100
+ export function specVersionTuple(spec: string): readonly [number, number, number] | null {
101
+ const match = SEMVER_SPEC.exec(spec.trim())
102
+ if (!match) return null
103
+ return [Number(match[2]), Number(match[3]), Number(match[4])]
104
+ }
105
+
106
+ interface ParsedSpecVersion {
107
+ readonly core: readonly [number, number, number]
108
+ readonly prerelease: readonly (number | string)[]
109
+ }
110
+
111
+ function parseSpecVersion(spec: string): ParsedSpecVersion | null {
112
+ const match = SEMVER_SPEC.exec(spec.trim())
113
+ if (!match) return null
114
+ const prerelease = (match[5] ?? "").split(".")
115
+ return {
116
+ core: [Number(match[2]), Number(match[3]), Number(match[4])],
117
+ prerelease:
118
+ match[5] === undefined
119
+ ? []
120
+ : prerelease.map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
121
+ }
122
+ }
123
+
124
+ /** Compare two semver cores: <0 when a<b, 0 equal, >0 when a>b. */
125
+ export function compareSemverCore(
126
+ a: readonly [number, number, number],
127
+ b: readonly [number, number, number],
128
+ ): number {
129
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
130
+ }
131
+
132
+ /** Compare full semver precedence, including prereleases (`2.3.0-beta` < `2.3.0`). */
133
+ export function compareSemverSpec(a: string, b: string): number {
134
+ const left = parseSpecVersion(a)
135
+ const right = parseSpecVersion(b)
136
+ if (left === null || right === null) return 0
137
+ const core = compareSemverCore(left.core, right.core)
138
+ if (core !== 0) return core
139
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
140
+ return left.prerelease.length === right.prerelease.length
141
+ ? 0
142
+ : left.prerelease.length === 0
143
+ ? 1
144
+ : -1
145
+ }
146
+ const length = Math.max(left.prerelease.length, right.prerelease.length)
147
+ for (let i = 0; i < length; i++) {
148
+ const x = left.prerelease[i]
149
+ const y = right.prerelease[i]
150
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1
151
+ if (typeof x === "number" && typeof y === "number") {
152
+ if (x !== y) return x - y
153
+ } else if (typeof x === "number") return -1
154
+ else if (typeof y === "number") return 1
155
+ else if (x !== y) return x < y ? -1 : 1
156
+ }
157
+ return 0
158
+ }
159
+
95
160
  const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
96
161
 
97
162
  /**
@@ -102,15 +167,21 @@ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\
102
167
  export function pinSweepText(
103
168
  text: string,
104
169
  rules: readonly { match: string; to: string }[],
105
- ): { text: string; changes: Array<Omit<PinChange, "file">> } {
170
+ allowDowngrade = false,
171
+ ): {
172
+ text: string
173
+ changes: Array<Omit<PinChange, "file">>
174
+ downgrades: Array<Omit<PinChange, "file">>
175
+ } {
106
176
  let parsed: Record<string, unknown>
107
177
  try {
108
178
  parsed = JSON.parse(text) as Record<string, unknown>
109
179
  } catch {
110
- return { text, changes: [] }
180
+ return { text, changes: [], downgrades: [] }
111
181
  }
112
182
  let out = text
113
183
  const changes: Array<Omit<PinChange, "file">> = []
184
+ const downgrades: Array<Omit<PinChange, "file">> = []
114
185
  for (const field of DEP_FIELDS) {
115
186
  const deps = parsed[field]
116
187
  if (typeof deps !== "object" || deps === null) continue
@@ -122,6 +193,19 @@ export function pinSweepText(
122
193
  if (!rule) continue
123
194
  const next = rewriteVersionSpec(rawSpec, rule.to)
124
195
  if (next === null) continue
196
+ // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
197
+ // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
198
+ const current = specVersionTuple(rawSpec)
199
+ const target = specVersionTuple(rule.to)
200
+ if (
201
+ !allowDowngrade &&
202
+ current !== null &&
203
+ target !== null &&
204
+ compareSemverSpec(rawSpec, rule.to) > 0
205
+ ) {
206
+ downgrades.push({ field, name, from: rawSpec, to: next })
207
+ continue
208
+ }
125
209
  // Replace exactly `"name"<ws>:<ws>"spec"`, preserving the surrounding whitespace.
126
210
  const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`)
127
211
  const replaced = out.replace(pattern, `$1${next}$2`)
@@ -131,7 +215,7 @@ export function pinSweepText(
131
215
  }
132
216
  }
133
217
  }
134
- return { text: out, changes }
218
+ return { text: out, changes, downgrades }
135
219
  }
136
220
 
137
221
  function dependencyObjectRange(
@@ -289,10 +373,16 @@ function scan(cwd: string, pattern: string): string[] {
289
373
  }
290
374
 
291
375
  /** Compute the plan (and, when `write`, apply it) for a target recipe against `cwd`. */
292
- export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolean): UpgradePlan {
376
+ export function computeUpgrade(
377
+ cwd: string,
378
+ recipe: UpgradeRecipe,
379
+ write: boolean,
380
+ allowDowngrade = false,
381
+ ): UpgradePlan {
293
382
  const pins: PinChange[] = []
294
383
  const dependencyMoves: DependencyMoveChange[] = []
295
384
  const importMoves: ImportChange[] = []
385
+ const downgrades: PinChange[] = []
296
386
 
297
387
  if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
298
388
  for (const rel of scan(cwd, "**/package.json")) {
@@ -300,8 +390,9 @@ export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolea
300
390
  const original = readFileSync(abs, "utf8")
301
391
  const moved = moveDependenciesText(original, recipe.dependencyMoves ?? [])
302
392
  for (const change of moved.changes) dependencyMoves.push({ file: rel, ...change })
303
- const pinned = pinSweepText(moved.text, recipe.pins)
393
+ const pinned = pinSweepText(moved.text, recipe.pins, allowDowngrade)
304
394
  for (const change of pinned.changes) pins.push({ file: rel, ...change })
395
+ for (const d of pinned.downgrades) downgrades.push({ file: rel, ...d })
305
396
  if (write && pinned.text !== original) writeFileSync(abs, pinned.text)
306
397
  }
307
398
  }
@@ -322,6 +413,7 @@ export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolea
322
413
  pins,
323
414
  dependencyMoves,
324
415
  importMoves,
416
+ downgrades,
325
417
  notes: recipe.notes ?? [],
326
418
  }
327
419
  }
@@ -366,6 +458,19 @@ function renderPlan(plan: UpgradePlan, write: boolean): string {
366
458
  return lines.join("\n").trimEnd()
367
459
  }
368
460
 
461
+ /** Explain a refused rollback: which pins would roll back, why it's blocked, and the escape hatch. */
462
+ function renderDowngradeRefusal(version: string, downgrades: readonly PinChange[]): string {
463
+ return [
464
+ `nifra upgrade → ${version}: refusing to DOWNGRADE ${downgrades.length} pin(s) below what's installed.`,
465
+ "",
466
+ ...downgrades.map((d) => ` ${d.file} ${d.name}: ${d.from} → ${d.to} (would roll back)`),
467
+ "",
468
+ "`nifra upgrade <version>` sets each pin to exactly <version>; an older target on a newer install",
469
+ "rolls dependencies backward and can violate a shared-package peer range. Target a version >= the",
470
+ "installed one, or pass --allow-downgrade if the rollback is intended.",
471
+ ].join("\n")
472
+ }
473
+
369
474
  /** CLI entry. Returns false (→ non-zero exit) on an unknown version, no project, or a failed verify. */
370
475
  export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<boolean> {
371
476
  if (options.list) {
@@ -399,7 +504,20 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
399
504
  }
400
505
 
401
506
  const write = options.write === true
402
- const plan = computeUpgrade(cwd, recipe, write)
507
+ const allowDowngrade = options.allowDowngrade === true
508
+
509
+ // Fail-closed on rollback: a dry pass writes nothing, so we can refuse before any package.json is touched.
510
+ const preview = computeUpgrade(cwd, recipe, false, allowDowngrade)
511
+ if (preview.downgrades.length > 0) {
512
+ if (options.json) {
513
+ console.log(JSON.stringify({ ...preview, write: false, refused: "downgrade" }, null, 2))
514
+ } else {
515
+ console.error(renderDowngradeRefusal(recipe.version, preview.downgrades))
516
+ }
517
+ return false
518
+ }
519
+
520
+ const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade) : preview
403
521
 
404
522
  if (options.json) {
405
523
  console.log(JSON.stringify({ ...plan, write }, null, 2))