@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.
Files changed (62) hide show
  1. package/README.md +33 -1
  2. package/dist/assure.d.ts +11 -0
  3. package/dist/assure.d.ts.map +1 -0
  4. package/dist/assure.js +67 -0
  5. package/dist/assure.js.map +1 -0
  6. package/dist/capabilities-tool.d.ts +33 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -0
  8. package/dist/capabilities-tool.js +320 -0
  9. package/dist/capabilities-tool.js.map +1 -0
  10. package/dist/check.d.ts +1 -1
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +136 -11
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +199 -12
  16. package/dist/cli.js.map +1 -1
  17. package/dist/doctor.d.ts +17 -0
  18. package/dist/doctor.d.ts.map +1 -1
  19. package/dist/doctor.js +146 -10
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/levels-tool.d.ts +37 -0
  22. package/dist/levels-tool.d.ts.map +1 -0
  23. package/dist/levels-tool.js +183 -0
  24. package/dist/levels-tool.js.map +1 -0
  25. package/dist/manifest-tool.d.ts +15 -0
  26. package/dist/manifest-tool.d.ts.map +1 -0
  27. package/dist/manifest-tool.js +67 -0
  28. package/dist/manifest-tool.js.map +1 -0
  29. package/dist/mcp-http.js +1 -1
  30. package/dist/mcp-http.js.map +1 -1
  31. package/dist/mcp.d.ts +1 -0
  32. package/dist/mcp.d.ts.map +1 -1
  33. package/dist/mcp.js +31 -0
  34. package/dist/mcp.js.map +1 -1
  35. package/dist/recipes/1.8.0.d.ts +12 -0
  36. package/dist/recipes/1.8.0.d.ts.map +1 -0
  37. package/dist/recipes/1.8.0.js +20 -0
  38. package/dist/recipes/1.8.0.js.map +1 -0
  39. package/dist/recipes/index.d.ts +28 -0
  40. package/dist/recipes/index.d.ts.map +1 -0
  41. package/dist/recipes/index.js +27 -0
  42. package/dist/recipes/index.js.map +1 -0
  43. package/dist/upgrade.d.ts +82 -0
  44. package/dist/upgrade.d.ts.map +1 -0
  45. package/dist/upgrade.js +220 -0
  46. package/dist/upgrade.js.map +1 -0
  47. package/docs/examples.json +12 -0
  48. package/docs/llms-full.txt +524 -15
  49. package/docs/types.json +2409 -50
  50. package/package.json +9 -8
  51. package/src/assure.ts +88 -0
  52. package/src/capabilities-tool.ts +406 -0
  53. package/src/check.ts +155 -12
  54. package/src/cli.ts +206 -17
  55. package/src/doctor.ts +179 -11
  56. package/src/levels-tool.ts +232 -0
  57. package/src/manifest-tool.ts +103 -0
  58. package/src/mcp-http.ts +1 -1
  59. package/src/mcp.ts +37 -0
  60. package/src/recipes/1.8.0.ts +21 -0
  61. package/src/recipes/index.ts +52 -0
  62. 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
+ }