@nifrajs/cli 1.5.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.
- package/README.md +25 -0
- 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 +117 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +196 -9
- package/dist/cli.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 +180 -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 +419 -13
- package/docs/types.json +1924 -33
- package/package.json +8 -8
- package/src/assure.ts +88 -0
- package/src/capabilities-tool.ts +406 -0
- package/src/check.ts +135 -12
- package/src/cli.ts +203 -14
- package/src/levels-tool.ts +222 -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,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.
|
|
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
|
+
}
|