@nifrajs/cli 1.6.0 → 1.11.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 (57) hide show
  1. package/README.md +25 -0
  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 +117 -11
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +196 -9
  16. package/dist/cli.js.map +1 -1
  17. package/dist/levels-tool.d.ts +37 -0
  18. package/dist/levels-tool.d.ts.map +1 -0
  19. package/dist/levels-tool.js +180 -0
  20. package/dist/levels-tool.js.map +1 -0
  21. package/dist/manifest-tool.d.ts +15 -0
  22. package/dist/manifest-tool.d.ts.map +1 -0
  23. package/dist/manifest-tool.js +67 -0
  24. package/dist/manifest-tool.js.map +1 -0
  25. package/dist/mcp-http.js +1 -1
  26. package/dist/mcp-http.js.map +1 -1
  27. package/dist/mcp.d.ts +1 -0
  28. package/dist/mcp.d.ts.map +1 -1
  29. package/dist/mcp.js +31 -0
  30. package/dist/mcp.js.map +1 -1
  31. package/dist/recipes/1.8.0.d.ts +12 -0
  32. package/dist/recipes/1.8.0.d.ts.map +1 -0
  33. package/dist/recipes/1.8.0.js +20 -0
  34. package/dist/recipes/1.8.0.js.map +1 -0
  35. package/dist/recipes/index.d.ts +28 -0
  36. package/dist/recipes/index.d.ts.map +1 -0
  37. package/dist/recipes/index.js +27 -0
  38. package/dist/recipes/index.js.map +1 -0
  39. package/dist/upgrade.d.ts +82 -0
  40. package/dist/upgrade.d.ts.map +1 -0
  41. package/dist/upgrade.js +220 -0
  42. package/dist/upgrade.js.map +1 -0
  43. package/docs/examples.json +12 -0
  44. package/docs/llms-full.txt +419 -13
  45. package/docs/types.json +1923 -32
  46. package/package.json +8 -8
  47. package/src/assure.ts +88 -0
  48. package/src/capabilities-tool.ts +406 -0
  49. package/src/check.ts +135 -12
  50. package/src/cli.ts +203 -14
  51. package/src/levels-tool.ts +222 -0
  52. package/src/manifest-tool.ts +103 -0
  53. package/src/mcp-http.ts +1 -1
  54. package/src/mcp.ts +37 -0
  55. package/src/recipes/1.8.0.ts +21 -0
  56. package/src/recipes/index.ts +52 -0
  57. package/src/upgrade.ts +269 -0
@@ -0,0 +1,103 @@
1
+ /** `nifra manifest` — emit/sign/diff the deployable route trust artifact. */
2
+
3
+ import { existsSync } from "node:fs"
4
+ import { resolve } from "node:path"
5
+ import { evaluateRouteAssurance } from "@nifrajs/core/assurance"
6
+ import {
7
+ buildNifraManifest,
8
+ diffNifraManifests,
9
+ type NifraManifestDiff,
10
+ parseNifraManifest,
11
+ serializeNifraManifest,
12
+ serializeNifraManifestSignature,
13
+ signNifraManifest,
14
+ } from "@nifrajs/core/manifest"
15
+ import { loadAssuranceConfig } from "./assure.ts"
16
+ import { collectCapabilityProjectReport } from "./capabilities-tool.ts"
17
+
18
+ export const DEFAULT_MANIFEST_FILE = "nifra.manifest.json"
19
+
20
+ export function formatManifestDiff(diff: NifraManifestDiff): string {
21
+ if (diff.changes.length === 0) return "No manifest changes."
22
+ const weight = { breaking: 0, compatible: 1, info: 2 } as const
23
+ const lines = [...diff.changes]
24
+ .sort(
25
+ (a, b) =>
26
+ weight[a.severity] - weight[b.severity] ||
27
+ a.path.localeCompare(b.path) ||
28
+ a.method.localeCompare(b.method),
29
+ )
30
+ .map(
31
+ (change) =>
32
+ `${change.severity === "breaking" ? "✖" : change.severity === "compatible" ? "✓" : "•"} ${change.method} ${change.path} [${change.section}] ${change.message}`,
33
+ )
34
+ const breaking = diff.changes.filter((change) => change.severity === "breaking").length
35
+ lines.push(
36
+ diff.hasBreaking
37
+ ? `${breaking} breaking manifest change${breaking === 1 ? "" : "s"}.`
38
+ : "No breaking manifest changes.",
39
+ )
40
+ return lines.join("\n")
41
+ }
42
+
43
+ /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
44
+ export async function runManifestEmit(
45
+ cwd: string,
46
+ options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
47
+ ): Promise<boolean> {
48
+ const config = await loadAssuranceConfig(cwd, options.config)
49
+ const assurance = evaluateRouteAssurance(config.source, config.policy)
50
+ const capabilityProject =
51
+ config.capabilities === undefined
52
+ ? undefined
53
+ : await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
54
+ if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
55
+ console.error("[nifra] refusing to emit a manifest from failing assurance")
56
+ return false
57
+ }
58
+ const manifest = await buildNifraManifest({
59
+ source: config.source,
60
+ assurance,
61
+ ...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
62
+ })
63
+ const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
64
+ await Bun.write(path, `${serializeNifraManifest(manifest)}\n`)
65
+
66
+ if (options.sign !== undefined) {
67
+ if (config.manifest?.signer === undefined) {
68
+ throw new Error(
69
+ "[nifra] --sign requires manifest.signer in nifra.assurance.ts; keep private keys behind that KMS/HSM callback.",
70
+ )
71
+ }
72
+ const signer = await config.manifest.signer(options.sign)
73
+ const signature = await signNifraManifest(manifest, signer)
74
+ await Bun.write(`${path}.sig`, `${serializeNifraManifestSignature(signature)}\n`)
75
+ }
76
+ console.log(
77
+ `[nifra] wrote manifest to ${path}${options.sign === undefined ? "" : ` and ${path}.sig`}`,
78
+ )
79
+ return true
80
+ }
81
+
82
+ /** Diff two already-emitted, hash-verified artifacts. Suitable for deploy promotion gates. */
83
+ export async function runManifestDiff(
84
+ cwd: string,
85
+ beforePath: string,
86
+ afterPath: string,
87
+ options: { readonly json?: boolean } = {},
88
+ ): Promise<boolean> {
89
+ const before = resolve(cwd, beforePath)
90
+ const after = resolve(cwd, afterPath)
91
+ if (!existsSync(before)) throw new Error(`[nifra] manifest not found: ${before}`)
92
+ if (!existsSync(after)) throw new Error(`[nifra] manifest not found: ${after}`)
93
+ const diff = diffNifraManifests(
94
+ await parseNifraManifest(await Bun.file(before).text(), before),
95
+ await parseNifraManifest(await Bun.file(after).text(), after),
96
+ )
97
+ console.log(
98
+ options.json === true
99
+ ? JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2)
100
+ : formatManifestDiff(diff),
101
+ )
102
+ return !diff.hasBreaking
103
+ }
package/src/mcp-http.ts CHANGED
@@ -26,7 +26,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
26
26
  export type { TypeEntry } from "./types-search.ts"
27
27
 
28
28
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
29
- const VERSION = "1.6.0"
29
+ const VERSION = "1.11.0"
30
30
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
31
31
  const DOCS_HEALTH =
32
32
  "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
package/src/mcp.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * so it can't hallucinate a drifted nifra API).
15
15
  * - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
16
16
  * - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
+ * - `nifra_assure` — route classification + enforcement-evidence gate.
17
18
  * - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
18
19
  *
19
20
  * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
@@ -951,6 +952,42 @@ export function projectTools(
951
952
  )
952
953
  },
953
954
  },
955
+ {
956
+ name: "nifra_assure",
957
+ description:
958
+ "Evaluate nifra.assurance.ts and return the complete route-assurance report: every reflected route's first matching policy rule, enforcement evidence, missing/forbidden evidence, and the fail-closed ok bit. Use after adding or changing routes/security middleware; fix every finding before finishing.",
959
+ inputSchema: {
960
+ type: "object",
961
+ properties: {
962
+ config: {
963
+ type: "string",
964
+ description:
965
+ "Config path relative to the selected project directory. Default: nifra.assurance.ts.",
966
+ },
967
+ dir: {
968
+ type: "string",
969
+ description:
970
+ 'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
971
+ },
972
+ },
973
+ additionalProperties: false,
974
+ },
975
+ handler: async (args) => {
976
+ const opts = args as { config?: string; dir?: string }
977
+ const target = resolveProjectDir(cwd, opts.dir)
978
+ if (target === null) return dirError(opts.dir)
979
+ const config = opts.config === undefined ? undefined : resolve(target, opts.config)
980
+ if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
981
+ return JSON.stringify(
982
+ { ok: false, error: "config must stay inside the selected project directory" },
983
+ null,
984
+ 2,
985
+ )
986
+ }
987
+ const { collectAssuranceReport } = await import("./assure.ts")
988
+ return JSON.stringify(await collectAssuranceReport(target, config), null, 2)
989
+ },
990
+ },
954
991
  {
955
992
  name: "nifra_doctor",
956
993
  description:
@@ -0,0 +1,21 @@
1
+ import type { UpgradeRecipe } from "./index.ts"
2
+
3
+ /**
4
+ * Upgrade recipe for nifra 1.8.0.
5
+ *
6
+ * 1.8.0 is a fixed-group bump: every `@nifrajs/*` package moves in lockstep. The pin sweep sets them
7
+ * all to 1.8.0 (preserving each spec's `^`/`~`/exact style). 1.8.0 introduced no breaking import moves
8
+ * in the public packages — the new surfaces (route assurance, adversarial testing, `@nifrajs/events`)
9
+ * are additive — so `importMoves` is empty. The notes flag the additive follow-ups a bump can't do for
10
+ * you (wiring the new assurance config), keeping the runner honest about what it did and didn't touch.
11
+ */
12
+ export const recipe: UpgradeRecipe = {
13
+ version: "1.8.0",
14
+ pins: [{ match: "@nifrajs/", to: "1.8.0" }],
15
+ importMoves: [],
16
+ notes: [
17
+ "1.8.0 adds route assurance (@nifrajs/core + middleware) — optional. To enforce it in CI, add a `nifra.assurance.ts` policy and run `nifra assure`.",
18
+ "New package @nifrajs/events (portable versioned event contracts) — add it to package.json only if you adopt it; the pin sweep won't introduce a dependency you don't already declare.",
19
+ "@nifrajs/testing gained the adversarial contract lab — no migration needed; existing tests are unchanged.",
20
+ ],
21
+ }
@@ -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
+ }