@nifrajs/cli 2.3.0 → 2.5.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 (47) hide show
  1. package/dist/check.d.ts +1 -1
  2. package/dist/check.d.ts.map +1 -1
  3. package/dist/check.js +28 -2
  4. package/dist/check.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +17 -4
  7. package/dist/cli.js.map +1 -1
  8. package/dist/doctor.d.ts +27 -0
  9. package/dist/doctor.d.ts.map +1 -1
  10. package/dist/doctor.js +158 -15
  11. package/dist/doctor.js.map +1 -1
  12. package/dist/learn.d.ts +32 -0
  13. package/dist/learn.d.ts.map +1 -0
  14. package/dist/learn.js +127 -0
  15. package/dist/learn.js.map +1 -0
  16. package/dist/mcp-app-tool.d.ts +14 -0
  17. package/dist/mcp-app-tool.d.ts.map +1 -0
  18. package/dist/mcp-app-tool.js +80 -0
  19. package/dist/mcp-app-tool.js.map +1 -0
  20. package/dist/mcp-docs-tools.d.ts +1 -1
  21. package/dist/mcp-docs-tools.d.ts.map +1 -1
  22. package/dist/mcp-docs-tools.js +17 -1
  23. package/dist/mcp-docs-tools.js.map +1 -1
  24. package/dist/mcp-http.d.ts +10 -6
  25. package/dist/mcp-http.d.ts.map +1 -1
  26. package/dist/mcp-http.js +30 -12
  27. package/dist/mcp-http.js.map +1 -1
  28. package/dist/mcp.d.ts +14 -0
  29. package/dist/mcp.d.ts.map +1 -1
  30. package/dist/mcp.js +188 -0
  31. package/dist/mcp.js.map +1 -1
  32. package/dist/upgrade.d.ts +13 -2
  33. package/dist/upgrade.d.ts.map +1 -1
  34. package/dist/upgrade.js +105 -7
  35. package/dist/upgrade.js.map +1 -1
  36. package/docs/llms-full.txt +104 -2
  37. package/docs/types.json +202 -26
  38. package/package.json +9 -9
  39. package/src/check.ts +30 -2
  40. package/src/cli.ts +21 -4
  41. package/src/doctor.ts +186 -3
  42. package/src/learn.ts +152 -0
  43. package/src/mcp-app-tool.ts +86 -0
  44. package/src/mcp-docs-tools.ts +18 -1
  45. package/src/mcp-http.ts +32 -14
  46. package/src/mcp.ts +237 -0
  47. package/src/upgrade.ts +125 -7
package/src/mcp.ts CHANGED
@@ -12,12 +12,18 @@
12
12
  * - `nifra_docs` - keyword-search the framework docs; returns only the matching sections.
13
13
  * - `nifra_example` - a verified, copy-pasteable snippet for a task (typechecked against the live API,
14
14
  * so it can't hallucinate a drifted nifra API).
15
+ * - `nifra_learn` - the guided, ordered path to build a nifra app end to end (create → deploy), each
16
+ * step naming the tool that emits the correct artifact and how to verify it.
15
17
  * - `nifra_scaffold`- map a URL path to the correct `routes/` file + a contract-correct page stub.
16
18
  * - `nifra_check` - the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
19
  * - `nifra_assure` - route classification + enforcement-evidence gate.
18
20
  * - `nifra_levels` - the cumulative verification ladder (L0 contract → L4 invariants): what the
19
21
  * project actually proves, and why each level it misses does not hold.
20
22
  * - `nifra_doctor` - package.json dependency drift detector, with safe local-version auto-fix.
23
+ * - `nifra_explain` - resolve an error (pasted, or the dev server's last) into a structured
24
+ * diagnostic: stable code, a codeframe in the user's source, and the recognised cause + fix.
25
+ * - `nifra_inspect` - read the running dev server's recent request traces (method/path/status/
26
+ * duration/ISR) from the DevTools plugin: what your requests ACTUALLY did, not a guess.
21
27
  *
22
28
  * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
23
29
  * project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
@@ -75,6 +81,67 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
75
81
  */
76
82
  const CHILD_TIMEOUT_MS = 30_000
77
83
 
84
+ /** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
85
+ * buffer an unrelated loopback service just because a caller supplied its port. */
86
+ export const LOCAL_TOOL_FETCH_TIMEOUT_MS = 2_000
87
+ export const LOCAL_TOOL_MAX_RESPONSE_BYTES = 1_048_576
88
+
89
+ /** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
90
+ export function validateLocalPort(port: unknown): number | undefined {
91
+ return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535
92
+ ? port
93
+ : undefined
94
+ }
95
+
96
+ /** Read a dev-tool response without allowing an untrusted localhost service to allocate unbounded memory. */
97
+ export async function readBoundedResponse(
98
+ response: Response,
99
+ maxBytes = LOCAL_TOOL_MAX_RESPONSE_BYTES,
100
+ ): Promise<string> {
101
+ const declared = response.headers.get("content-length")
102
+ if (declared !== null) {
103
+ const length = Number(declared)
104
+ if (Number.isFinite(length) && length > maxBytes)
105
+ throw new Error("response exceeded the size limit")
106
+ }
107
+ if (response.body === null) return ""
108
+ const reader = response.body.getReader()
109
+ const chunks: Uint8Array[] = []
110
+ let total = 0
111
+ try {
112
+ for (;;) {
113
+ const { done, value } = await reader.read()
114
+ if (done) break
115
+ if (value === undefined) continue
116
+ total += value.byteLength
117
+ if (total > maxBytes) {
118
+ await reader.cancel()
119
+ throw new Error("response exceeded the size limit")
120
+ }
121
+ chunks.push(value)
122
+ }
123
+ } finally {
124
+ reader.releaseLock()
125
+ }
126
+ const bytes = new Uint8Array(total)
127
+ let offset = 0
128
+ for (const chunk of chunks) {
129
+ bytes.set(chunk, offset)
130
+ offset += chunk.byteLength
131
+ }
132
+ return new TextDecoder().decode(bytes)
133
+ }
134
+
135
+ const notNifraResponse = (service: string): string =>
136
+ JSON.stringify(
137
+ {
138
+ code: "NIFRA_NOT_DEV_SERVER",
139
+ message: `The service on this port is not a Nifra ${service} endpoint.`,
140
+ },
141
+ null,
142
+ 2,
143
+ )
144
+
78
145
  const timeoutMessage = (label: string, ms: number): string =>
79
146
  `${label} timed out after ${ms / 1000}s and was terminated.\n` +
80
147
  `The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
@@ -668,6 +735,176 @@ export function projectTools(
668
735
  return describeProject(await loadAppCached(), filter)
669
736
  },
670
737
  },
738
+ {
739
+ name: "nifra_explain",
740
+ description:
741
+ "Turn a nifra error into a STRUCTURED diagnostic instead of eyeballing a stack trace: a stable `code`, the top frame in YOUR source, a codeframe around the offending line, and - when nifra recognises the failure - the plain-language `cause` + `fix` + docs anchor. Pass `error` (and `stack` if you have it, e.g. from nifra_run/nifra_test output or a failing build) to explain a specific failure; or pass `port` to fetch the running dev server's most recent SSR failure from `/__nifra/last-error`. Returns the same JSON the dev overlay renders.",
742
+ inputSchema: {
743
+ type: "object",
744
+ properties: {
745
+ error: {
746
+ type: "string",
747
+ description:
748
+ "The error message to explain (copy it from nifra_run/nifra_test output or a failing build).",
749
+ },
750
+ stack: {
751
+ type: "string",
752
+ description:
753
+ "The error's stack trace, if you have it - enables the source codeframe and the top user frame.",
754
+ },
755
+ name: {
756
+ type: "string",
757
+ description:
758
+ "The error's class name (e.g. TypeError, SchemaError), if known - sharpens classification.",
759
+ },
760
+ port: {
761
+ type: "number",
762
+ description:
763
+ "Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
764
+ },
765
+ },
766
+ additionalProperties: false,
767
+ },
768
+ handler: async (args) => {
769
+ const { error, stack, name, port } = args as {
770
+ error?: string
771
+ stack?: string
772
+ name?: string
773
+ port?: number
774
+ }
775
+ if (error !== undefined || stack !== undefined) {
776
+ const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
777
+ const e = new Error(error ?? "")
778
+ if (name !== undefined) e.name = name
779
+ if (stack !== undefined) e.stack = stack
780
+ return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2)
781
+ }
782
+ if (port !== undefined) {
783
+ const validPort = validateLocalPort(port)
784
+ if (validPort === undefined) {
785
+ return JSON.stringify(
786
+ { code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
787
+ null,
788
+ 2,
789
+ )
790
+ }
791
+ const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic")
792
+ try {
793
+ const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
794
+ signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
795
+ })
796
+ if (res.headers.get("x-nifra-diagnostic") !== "true")
797
+ return notNifraResponse("diagnostic")
798
+ if (!res.ok) {
799
+ return JSON.stringify(
800
+ {
801
+ code: "NIFRA_NONE",
802
+ message: `dev server at :${validPort} returned ${res.status}`,
803
+ },
804
+ null,
805
+ 2,
806
+ )
807
+ }
808
+ return await readBoundedResponse(res)
809
+ } catch (cause) {
810
+ return JSON.stringify(
811
+ {
812
+ code: "NIFRA_NONE",
813
+ message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
814
+ },
815
+ null,
816
+ 2,
817
+ )
818
+ }
819
+ }
820
+ return JSON.stringify(
821
+ {
822
+ code: "NIFRA_NONE",
823
+ message:
824
+ "Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
825
+ },
826
+ null,
827
+ 2,
828
+ )
829
+ },
830
+ },
831
+ {
832
+ name: "nifra_inspect",
833
+ description:
834
+ "Observe what your requests ACTUALLY did on the running dev server - the recent request traces the DevTools plugin records: `{ method, path, status, durationMs, isrStatus, bodyBytes }` per request. The read no other tool gives you: after nifra_run or a real browser request, call this to SEE the outcome (which route answered, the status, how long, ISR hit/miss) instead of guessing. Pass `port` (the running dev server); narrow with `path` (a path prefix) or `limit` (most recent N). Requires the app to mount `@nifrajs/web`'s `devtools()` plugin (which auto-enables in development).",
835
+ inputSchema: {
836
+ type: "object",
837
+ properties: {
838
+ port: { type: "number", description: "The running dev server's port." },
839
+ path: { type: "string", description: "Only traces whose path starts with this prefix." },
840
+ limit: { type: "number", description: "Return only the most recent N traces." },
841
+ },
842
+ required: ["port"],
843
+ additionalProperties: false,
844
+ },
845
+ handler: async (args) => {
846
+ const { port, path, limit } = args as { port?: number; path?: string; limit?: number }
847
+ if (port === undefined) {
848
+ return JSON.stringify(
849
+ {
850
+ events: [],
851
+ note: "Pass `port` - the running dev server whose request traces to read.",
852
+ },
853
+ null,
854
+ 2,
855
+ )
856
+ }
857
+ try {
858
+ const validPort = validateLocalPort(port)
859
+ if (validPort === undefined) {
860
+ return JSON.stringify(
861
+ {
862
+ events: [],
863
+ code: "NIFRA_INVALID_PORT",
864
+ note: "port must be an integer from 1 to 65535.",
865
+ },
866
+ null,
867
+ 2,
868
+ )
869
+ }
870
+ const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`)
871
+ if (path !== undefined) url.searchParams.set("path", path)
872
+ if (limit !== undefined) url.searchParams.set("limit", String(limit))
873
+ const res = await fetch(url, {
874
+ signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
875
+ })
876
+ if (res.headers.get("x-nifra-devtools") !== "true") return notNifraResponse("DevTools")
877
+ if (res.status === 404) {
878
+ return JSON.stringify(
879
+ {
880
+ events: [],
881
+ note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
882
+ },
883
+ null,
884
+ 2,
885
+ )
886
+ }
887
+ if (!res.ok) {
888
+ return JSON.stringify(
889
+ { events: [], note: `DevTools state returned ${res.status}.` },
890
+ null,
891
+ 2,
892
+ )
893
+ }
894
+ return await readBoundedResponse(res)
895
+ } catch (cause) {
896
+ const validPort = validateLocalPort(port)
897
+ return JSON.stringify(
898
+ {
899
+ events: [],
900
+ note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
901
+ },
902
+ null,
903
+ 2,
904
+ )
905
+ }
906
+ },
907
+ },
671
908
  {
672
909
  name: "nifra_routes",
673
910
  description:
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))