@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.
- package/dist/check.d.ts +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +28 -2
- 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 +27 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +158 -15
- 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 +30 -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 +104 -2
- package/docs/types.json +202 -26
- package/package.json +9 -9
- package/src/check.ts +30 -2
- package/src/cli.ts +21 -4
- package/src/doctor.ts +186 -3
- 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 +32 -14
- package/src/mcp.ts +237 -0
- 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
|
-
|
|
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))
|