@nifrajs/cli 1.6.0 → 1.12.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/README.md +33 -1
- package/dist/assure.d.ts +11 -0
- package/dist/assure.d.ts.map +1 -0
- package/dist/assure.js +67 -0
- package/dist/assure.js.map +1 -0
- package/dist/capabilities-tool.d.ts +33 -0
- package/dist/capabilities-tool.d.ts.map +1 -0
- package/dist/capabilities-tool.js +320 -0
- package/dist/capabilities-tool.js.map +1 -0
- package/dist/check.d.ts +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +136 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +199 -12
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +146 -10
- package/dist/doctor.js.map +1 -1
- package/dist/levels-tool.d.ts +37 -0
- package/dist/levels-tool.d.ts.map +1 -0
- package/dist/levels-tool.js +183 -0
- package/dist/levels-tool.js.map +1 -0
- package/dist/manifest-tool.d.ts +15 -0
- package/dist/manifest-tool.d.ts.map +1 -0
- package/dist/manifest-tool.js +67 -0
- package/dist/manifest-tool.js.map +1 -0
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +31 -0
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/1.8.0.d.ts +12 -0
- package/dist/recipes/1.8.0.d.ts.map +1 -0
- package/dist/recipes/1.8.0.js +20 -0
- package/dist/recipes/1.8.0.js.map +1 -0
- package/dist/recipes/index.d.ts +28 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +27 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/upgrade.d.ts +82 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +220 -0
- package/dist/upgrade.js.map +1 -0
- package/docs/examples.json +12 -0
- package/docs/llms-full.txt +524 -15
- package/docs/types.json +2409 -50
- package/package.json +9 -8
- package/src/assure.ts +88 -0
- package/src/capabilities-tool.ts +406 -0
- package/src/check.ts +155 -12
- package/src/cli.ts +206 -17
- package/src/doctor.ts +179 -11
- package/src/levels-tool.ts +232 -0
- package/src/manifest-tool.ts +103 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +37 -0
- package/src/recipes/1.8.0.ts +21 -0
- package/src/recipes/index.ts +52 -0
- package/src/upgrade.ts +269 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-release upgrade recipes, as data. Each recipe declares the mechanical edits a target version
|
|
3
|
+
* needs — dependency pins and exact import-specifier moves — so `nifra upgrade <version>` can apply
|
|
4
|
+
* them deterministically. Recipes are static imports (they bundle) and keyed by target version.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { recipe as v1_8_0 } from "./1.8.0.ts"
|
|
8
|
+
|
|
9
|
+
/** A pin rule: every dependency whose name starts with `match` is set to version `to`. */
|
|
10
|
+
export interface PinRule {
|
|
11
|
+
/** Package-name prefix, e.g. `@nifrajs/`, or an exact package name. */
|
|
12
|
+
readonly match: string
|
|
13
|
+
/** Bare target version, e.g. `1.8.0`. The range operator (`^`/`~`/exact) is preserved per-spec. */
|
|
14
|
+
readonly to: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** An exact import-specifier rewrite (string-level, not AST): `from` → `to`. */
|
|
18
|
+
export interface ImportMove {
|
|
19
|
+
readonly from: string
|
|
20
|
+
readonly to: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UpgradeRecipe {
|
|
24
|
+
readonly version: string
|
|
25
|
+
readonly pins: readonly PinRule[]
|
|
26
|
+
readonly importMoves: readonly ImportMove[]
|
|
27
|
+
/** Human notes printed after the plan — e.g. structural changes the runner deliberately can't do. */
|
|
28
|
+
readonly notes?: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RECIPES: Record<string, UpgradeRecipe> = {
|
|
32
|
+
[v1_8_0.version]: v1_8_0,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getRecipe(version: string): UpgradeRecipe | undefined {
|
|
36
|
+
return RECIPES[version]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Available target versions, ascending. */
|
|
40
|
+
export function listRecipeVersions(): string[] {
|
|
41
|
+
return Object.keys(RECIPES).sort((a, b) => compareVersions(a, b))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compareVersions(a: string, b: string): number {
|
|
45
|
+
const pa = a.split(".").map(Number)
|
|
46
|
+
const pb = b.split(".").map(Number)
|
|
47
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
48
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
49
|
+
if (diff !== 0) return diff
|
|
50
|
+
}
|
|
51
|
+
return 0
|
|
52
|
+
}
|
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra upgrade <version>` — an executable, per-release upgrade runner. Framework and shared-package
|
|
3
|
+
* bumps otherwise spread the same mechanical edits across every consuming app by hand; a recipe turns
|
|
4
|
+
* them into `detect → transform → verify`:
|
|
5
|
+
*
|
|
6
|
+
* 1. **pin sweep** — set every matching dependency to the target version across the workspace's
|
|
7
|
+
* package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
|
|
8
|
+
* 2. **import moves** — rewrite exact import specifiers to their updated module paths.
|
|
9
|
+
* 3. **verify** — reuse the existing `nifra check` gate; no new verification surface.
|
|
10
|
+
*
|
|
11
|
+
* Dry-run by default (prints the plan, writes nothing); `--write` applies. Fail-closed on an unknown
|
|
12
|
+
* target version or a missing package.json, and deterministic (same repo + target → same edits).
|
|
13
|
+
*
|
|
14
|
+
* Deliberately NOT a codemod engine: transforms are string/specifier-level only. Structural (AST)
|
|
15
|
+
* transforms are a future addition — a recipe that needs one is the signal to add the engine, not before.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync } from "node:fs"
|
|
19
|
+
import { join, relative } from "node:path"
|
|
20
|
+
import { Glob } from "bun"
|
|
21
|
+
import { getRecipe, listRecipeVersions, type UpgradeRecipe } from "./recipes/index.ts"
|
|
22
|
+
|
|
23
|
+
export interface UpgradeOptions {
|
|
24
|
+
/** Target version, e.g. "1.8.0". Required unless `list` is set. */
|
|
25
|
+
readonly version?: string
|
|
26
|
+
/** Apply the edits. Without it, the run is a dry-run that only prints the plan. */
|
|
27
|
+
readonly write?: boolean
|
|
28
|
+
/** Emit a machine-readable plan/result instead of the human report. */
|
|
29
|
+
readonly json?: boolean
|
|
30
|
+
/** Print the available target versions and return. */
|
|
31
|
+
readonly list?: boolean
|
|
32
|
+
/** After `--write`, run `nifra check` and fail the command if it fails. Default true. */
|
|
33
|
+
readonly verify?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PinChange {
|
|
37
|
+
readonly file: string
|
|
38
|
+
readonly field: string
|
|
39
|
+
readonly name: string
|
|
40
|
+
readonly from: string
|
|
41
|
+
readonly to: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ImportChange {
|
|
45
|
+
readonly file: string
|
|
46
|
+
readonly from: string
|
|
47
|
+
readonly to: string
|
|
48
|
+
readonly count: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UpgradePlan {
|
|
52
|
+
readonly version: string
|
|
53
|
+
readonly pins: readonly PinChange[]
|
|
54
|
+
readonly importMoves: readonly ImportChange[]
|
|
55
|
+
readonly notes: readonly string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEP_FIELDS = [
|
|
59
|
+
"dependencies",
|
|
60
|
+
"devDependencies",
|
|
61
|
+
"peerDependencies",
|
|
62
|
+
"optionalDependencies",
|
|
63
|
+
] as const
|
|
64
|
+
|
|
65
|
+
// Only a bare semver spec is rewritten. Anything else — workspace:*, link:/file:, npm: aliases, git
|
|
66
|
+
// urls, "*", "latest", or a multi-part range — is intentionally left untouched (skipped, not guessed).
|
|
67
|
+
const SEMVER_SPEC =
|
|
68
|
+
/^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rewrite a dependency version spec to `toVersion`, preserving the range operator (`^`, `~`, …).
|
|
72
|
+
* Returns null when the spec is not a plain semver spec (→ skip it) or already equals the target.
|
|
73
|
+
*/
|
|
74
|
+
export function rewriteVersionSpec(spec: string, toVersion: string): string | null {
|
|
75
|
+
const match = SEMVER_SPEC.exec(spec.trim())
|
|
76
|
+
if (!match) return null
|
|
77
|
+
const operator = match[1] ?? ""
|
|
78
|
+
const next = `${operator}${toVersion}`
|
|
79
|
+
return next === spec ? null : next
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apply pin rules to one package.json's TEXT (format-preserving — targeted string replaces, never a
|
|
86
|
+
* JSON re-serialize that would reorder keys or drop comments-as-formatting). Returns the new text and
|
|
87
|
+
* the changes made.
|
|
88
|
+
*/
|
|
89
|
+
export function pinSweepText(
|
|
90
|
+
text: string,
|
|
91
|
+
rules: readonly { match: string; to: string }[],
|
|
92
|
+
): { text: string; changes: Array<Omit<PinChange, "file">> } {
|
|
93
|
+
let parsed: Record<string, unknown>
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(text) as Record<string, unknown>
|
|
96
|
+
} catch {
|
|
97
|
+
return { text, changes: [] }
|
|
98
|
+
}
|
|
99
|
+
let out = text
|
|
100
|
+
const changes: Array<Omit<PinChange, "file">> = []
|
|
101
|
+
for (const field of DEP_FIELDS) {
|
|
102
|
+
const deps = parsed[field]
|
|
103
|
+
if (typeof deps !== "object" || deps === null) continue
|
|
104
|
+
for (const [name, rawSpec] of Object.entries(deps as Record<string, unknown>)) {
|
|
105
|
+
if (typeof rawSpec !== "string") continue
|
|
106
|
+
const rule = rules.find((r) => name.startsWith(r.match))
|
|
107
|
+
if (!rule) continue
|
|
108
|
+
const next = rewriteVersionSpec(rawSpec, rule.to)
|
|
109
|
+
if (next === null) continue
|
|
110
|
+
// Replace exactly `"name"<ws>:<ws>"spec"`, preserving the surrounding whitespace.
|
|
111
|
+
const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`)
|
|
112
|
+
const replaced = out.replace(pattern, `$1${next}$2`)
|
|
113
|
+
if (replaced !== out) {
|
|
114
|
+
out = replaced
|
|
115
|
+
changes.push({ field, name, from: rawSpec, to: next })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { text: out, changes }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const IMPORT_SPEC = (from: string): RegExp =>
|
|
123
|
+
new RegExp(
|
|
124
|
+
`(\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|\\bimport\\s+)(['"])${escapeRegExp(from)}(['"])`,
|
|
125
|
+
"g",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
/** Rewrite exact import/export/require/dynamic-import specifiers in one source file's text. */
|
|
129
|
+
export function applyImportMoves(
|
|
130
|
+
text: string,
|
|
131
|
+
moves: readonly { from: string; to: string }[],
|
|
132
|
+
): { text: string; changes: Array<{ from: string; to: string; count: number }> } {
|
|
133
|
+
let out = text
|
|
134
|
+
const changes: Array<{ from: string; to: string; count: number }> = []
|
|
135
|
+
for (const move of moves) {
|
|
136
|
+
if (move.from === move.to) continue
|
|
137
|
+
let count = 0
|
|
138
|
+
out = out.replace(IMPORT_SPEC(move.from), (_full, prefix: string, q1: string, q2: string) => {
|
|
139
|
+
count += 1
|
|
140
|
+
return `${prefix}${q1}${move.to}${q2}`
|
|
141
|
+
})
|
|
142
|
+
if (count > 0) changes.push({ from: move.from, to: move.to, count })
|
|
143
|
+
}
|
|
144
|
+
return { text: out, changes }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const SOURCE_GLOB = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
|
|
148
|
+
const IGNORE_SEGMENTS = ["node_modules/", "/dist/", "/build/", "/.git/", "/coverage/", "/.next/"]
|
|
149
|
+
|
|
150
|
+
const isIgnored = (path: string): boolean =>
|
|
151
|
+
path.startsWith("node_modules/") || IGNORE_SEGMENTS.some((seg) => path.includes(seg))
|
|
152
|
+
|
|
153
|
+
function scan(cwd: string, pattern: string): string[] {
|
|
154
|
+
const glob = new Glob(pattern)
|
|
155
|
+
const out: string[] = []
|
|
156
|
+
for (const path of glob.scanSync({ cwd, dot: false })) {
|
|
157
|
+
if (!isIgnored(path)) out.push(path)
|
|
158
|
+
}
|
|
159
|
+
return out.sort() // deterministic order
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Compute the plan (and, when `write`, apply it) for a target recipe against `cwd`. */
|
|
163
|
+
export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolean): UpgradePlan {
|
|
164
|
+
const pins: PinChange[] = []
|
|
165
|
+
const importMoves: ImportChange[] = []
|
|
166
|
+
|
|
167
|
+
if (recipe.pins.length > 0) {
|
|
168
|
+
for (const rel of scan(cwd, "**/package.json")) {
|
|
169
|
+
const abs = join(cwd, rel)
|
|
170
|
+
const text = readFileSync(abs, "utf8")
|
|
171
|
+
const result = pinSweepText(text, recipe.pins)
|
|
172
|
+
if (result.changes.length === 0) continue
|
|
173
|
+
for (const change of result.changes) pins.push({ file: rel, ...change })
|
|
174
|
+
if (write) writeFileSync(abs, result.text)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (recipe.importMoves.length > 0) {
|
|
179
|
+
for (const rel of scan(cwd, SOURCE_GLOB)) {
|
|
180
|
+
const abs = join(cwd, rel)
|
|
181
|
+
const text = readFileSync(abs, "utf8")
|
|
182
|
+
const result = applyImportMoves(text, recipe.importMoves)
|
|
183
|
+
if (result.changes.length === 0) continue
|
|
184
|
+
for (const change of result.changes) importMoves.push({ file: rel, ...change })
|
|
185
|
+
if (write) writeFileSync(abs, result.text)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { version: recipe.version, pins, importMoves, notes: recipe.notes ?? [] }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderPlan(plan: UpgradePlan, write: boolean): string {
|
|
193
|
+
const lines: string[] = []
|
|
194
|
+
const verb = write ? "Applied" : "Planned"
|
|
195
|
+
lines.push(`nifra upgrade → ${plan.version} (${write ? "write" : "dry-run"})`)
|
|
196
|
+
lines.push("")
|
|
197
|
+
if (plan.pins.length === 0 && plan.importMoves.length === 0) {
|
|
198
|
+
lines.push("Already up to date — no changes.")
|
|
199
|
+
return lines.join("\n")
|
|
200
|
+
}
|
|
201
|
+
if (plan.pins.length > 0) {
|
|
202
|
+
lines.push(`${verb} ${plan.pins.length} dependency pin(s):`)
|
|
203
|
+
for (const p of plan.pins) lines.push(` ${p.file} ${p.name}: ${p.from} → ${p.to}`)
|
|
204
|
+
lines.push("")
|
|
205
|
+
}
|
|
206
|
+
if (plan.importMoves.length > 0) {
|
|
207
|
+
const total = plan.importMoves.reduce((n, m) => n + m.count, 0)
|
|
208
|
+
lines.push(`${verb} ${total} import move(s) across ${plan.importMoves.length} file(s):`)
|
|
209
|
+
for (const m of plan.importMoves) lines.push(` ${m.file} ${m.from} → ${m.to} (${m.count})`)
|
|
210
|
+
lines.push("")
|
|
211
|
+
}
|
|
212
|
+
for (const note of plan.notes) lines.push(`note: ${note}`)
|
|
213
|
+
if (!write) lines.push("\nRe-run with --write to apply, then nifra check verifies.")
|
|
214
|
+
return lines.join("\n").trimEnd()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** CLI entry. Returns false (→ non-zero exit) on an unknown version, no project, or a failed verify. */
|
|
218
|
+
export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<boolean> {
|
|
219
|
+
if (options.list) {
|
|
220
|
+
const versions = listRecipeVersions()
|
|
221
|
+
if (options.json) console.log(JSON.stringify({ versions }, null, 2))
|
|
222
|
+
else console.log(`available upgrade targets:\n${versions.map((v) => ` ${v}`).join("\n")}`)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { version } = options
|
|
227
|
+
if (version === undefined) {
|
|
228
|
+
console.error("[nifra] upgrade needs a target version, e.g. `nifra upgrade 1.8.0` (or --list)")
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
const recipe = getRecipe(version)
|
|
232
|
+
if (!recipe) {
|
|
233
|
+
console.error(
|
|
234
|
+
`[nifra] no upgrade recipe for ${version}. Available: ${listRecipeVersions().join(", ") || "(none)"}`,
|
|
235
|
+
)
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Detect: the cwd must be a project (or workspace) root.
|
|
240
|
+
try {
|
|
241
|
+
readFileSync(join(cwd, "package.json"), "utf8")
|
|
242
|
+
} catch {
|
|
243
|
+
console.error(
|
|
244
|
+
`[nifra] no package.json in ${relative(process.cwd(), cwd) || "."} — run from a project root`,
|
|
245
|
+
)
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const write = options.write === true
|
|
250
|
+
const plan = computeUpgrade(cwd, recipe, write)
|
|
251
|
+
|
|
252
|
+
if (options.json) {
|
|
253
|
+
console.log(JSON.stringify({ ...plan, write }, null, 2))
|
|
254
|
+
} else {
|
|
255
|
+
console.log(renderPlan(plan, write))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
|
|
259
|
+
if (write && options.verify !== false) {
|
|
260
|
+
const { runCheck } = await import("./check.ts")
|
|
261
|
+
const ok = await runCheck(cwd, { json: false })
|
|
262
|
+
if (!ok) {
|
|
263
|
+
if (!options.json)
|
|
264
|
+
console.error("\n[nifra] upgrade applied but `nifra check` failed — review above.")
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return true
|
|
269
|
+
}
|