@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.
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +4 -1
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -4
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +4 -0
- package/dist/doctor.js.map +1 -1
- package/dist/learn.d.ts +32 -0
- package/dist/learn.d.ts.map +1 -0
- package/dist/learn.js +127 -0
- package/dist/learn.js.map +1 -0
- package/dist/mcp-app-tool.d.ts +14 -0
- package/dist/mcp-app-tool.d.ts.map +1 -0
- package/dist/mcp-app-tool.js +80 -0
- package/dist/mcp-app-tool.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +17 -1
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +10 -6
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +26 -12
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +14 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +188 -0
- package/dist/mcp.js.map +1 -1
- package/dist/upgrade.d.ts +13 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +105 -7
- package/dist/upgrade.js.map +1 -1
- package/docs/llms-full.txt +75 -1
- package/docs/types.json +162 -11
- package/package.json +9 -9
- package/src/check.ts +5 -1
- package/src/cli.ts +21 -4
- package/src/doctor.ts +8 -0
- package/src/learn.ts +152 -0
- package/src/mcp-app-tool.ts +86 -0
- package/src/mcp-docs-tools.ts +18 -1
- package/src/mcp-http.ts +27 -14
- package/src/mcp.ts +237 -0
- 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
|
-
|
|
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(
|
|
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
|
|
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))
|