@nifrajs/cli 1.12.0 → 2.0.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 +17 -2
- package/dist/check.d.ts +17 -6
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +256 -15
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +58 -71
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +131 -61
- package/dist/doctor.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +42 -0
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/2.0.0.d.ts +9 -0
- package/dist/recipes/2.0.0.d.ts.map +1 -0
- package/dist/recipes/2.0.0.js +25 -0
- package/dist/recipes/2.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/sync-manifest.d.ts +33 -0
- package/dist/sync-manifest.d.ts.map +1 -0
- package/dist/sync-manifest.js +97 -0
- package/dist/sync-manifest.js.map +1 -0
- package/dist/upgrade.d.ts +23 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +144 -13
- package/dist/upgrade.js.map +1 -1
- package/docs/examples.json +69 -21
- package/docs/llms-full.txt +1065 -104
- package/docs/types.json +933 -181
- package/package.json +9 -9
- package/src/check.ts +251 -18
- package/src/cli.ts +57 -73
- package/src/doctor.ts +160 -61
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +52 -0
- package/src/recipes/2.0.0.ts +26 -0
- package/src/recipes/index.ts +10 -0
- package/src/sync-manifest.ts +111 -0
- package/src/upgrade.ts +163 -11
package/src/recipes/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { recipe as v1_8_0 } from "./1.8.0.ts"
|
|
8
|
+
import { recipe as v2_0_0 } from "./2.0.0.ts"
|
|
8
9
|
|
|
9
10
|
/** A pin rule: every dependency whose name starts with `match` is set to version `to`. */
|
|
10
11
|
export interface PinRule {
|
|
@@ -14,6 +15,13 @@ export interface PinRule {
|
|
|
14
15
|
readonly to: string
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** Move a removed package dependency to its replacement, preserving the consumer's range style. */
|
|
19
|
+
export interface DependencyMove {
|
|
20
|
+
readonly from: string
|
|
21
|
+
readonly to: string
|
|
22
|
+
readonly toVersion: string
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/** An exact import-specifier rewrite (string-level, not AST): `from` → `to`. */
|
|
18
26
|
export interface ImportMove {
|
|
19
27
|
readonly from: string
|
|
@@ -23,6 +31,7 @@ export interface ImportMove {
|
|
|
23
31
|
export interface UpgradeRecipe {
|
|
24
32
|
readonly version: string
|
|
25
33
|
readonly pins: readonly PinRule[]
|
|
34
|
+
readonly dependencyMoves?: readonly DependencyMove[]
|
|
26
35
|
readonly importMoves: readonly ImportMove[]
|
|
27
36
|
/** Human notes printed after the plan — e.g. structural changes the runner deliberately can't do. */
|
|
28
37
|
readonly notes?: readonly string[]
|
|
@@ -30,6 +39,7 @@ export interface UpgradeRecipe {
|
|
|
30
39
|
|
|
31
40
|
const RECIPES: Record<string, UpgradeRecipe> = {
|
|
32
41
|
[v1_8_0.version]: v1_8_0,
|
|
42
|
+
[v2_0_0.version]: v2_0_0,
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export function getRecipe(version: string): UpgradeRecipe | undefined {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra sync-manifest` - regenerate a committed web `server-manifest.ts` from the current `routes/` tree
|
|
3
|
+
* WITHOUT a full build. The manifest is a generated file that bakes the route table (+ client-asset hashes)
|
|
4
|
+
* for a disk-less edge worker; after adding/renaming/removing a route it drifts, and `nifra check` flags it.
|
|
5
|
+
* Regenerating it used to require the whole `build` (server + worker + migrate bundles). This re-scans
|
|
6
|
+
* `routes/` and rewrites just the manifest's route table, preserving the baked client-asset references.
|
|
7
|
+
*
|
|
8
|
+
* It does NOT rebuild the client bundle: a brand-new HYDRATING route still needs a full build so its client
|
|
9
|
+
* chunk exists. This clears route-table drift for the common case (a loader edit, an SSR-only route, a
|
|
10
|
+
* rename) in milliseconds, and prints that caveat for the case it can't cover.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { Glob } from "bun"
|
|
15
|
+
|
|
16
|
+
// The marker `generateServerManifest` writes at the top of the file - so a user file merely named
|
|
17
|
+
// `server-manifest.ts` is never mistaken for a generated one. Mirrors `nifra check`'s drift scan.
|
|
18
|
+
const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
|
|
19
|
+
// The first route-import specifier's prefix up to `routes/` (`./`, `../`, `./src/`, …) - locates the
|
|
20
|
+
// routes dir relative to the manifest and strips imports to route-relative keys.
|
|
21
|
+
const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
|
|
22
|
+
const IGNORED = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
|
|
23
|
+
|
|
24
|
+
export interface SyncManifestResult {
|
|
25
|
+
/** The committed server-manifest file (relative to cwd). */
|
|
26
|
+
readonly file: string
|
|
27
|
+
/** True when the file's route table changed and it was rewritten. */
|
|
28
|
+
readonly changed: boolean
|
|
29
|
+
/** Route files now imported that weren't before (added). */
|
|
30
|
+
readonly added: readonly string[]
|
|
31
|
+
/** Route files no longer imported (removed/renamed away). */
|
|
32
|
+
readonly removed: readonly string[]
|
|
33
|
+
/** Set when the sync was REFUSED because discovery found zero routes for a manifest that had some
|
|
34
|
+
* (`routes/` likely emptied by mistake) - the manifest was left untouched rather than wiped. */
|
|
35
|
+
readonly refusedEmpty?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Discover every generated `server-manifest.ts` under `cwd` and re-sync its route table from the live
|
|
40
|
+
* `routes/` tree, preserving baked client assets. Returns one result per generated manifest. Never throws
|
|
41
|
+
* on a per-manifest miss (an unreadable/absent routes dir is skipped).
|
|
42
|
+
*/
|
|
43
|
+
export async function syncServerManifests(cwd: string): Promise<SyncManifestResult[]> {
|
|
44
|
+
const { discoverRoutes } = await import("@nifrajs/web/fs")
|
|
45
|
+
const { parseManifestRouteFiles, diffManifestRoutes, resyncServerManifestSource } = await import(
|
|
46
|
+
"@nifrajs/web/build"
|
|
47
|
+
)
|
|
48
|
+
const results: SyncManifestResult[] = []
|
|
49
|
+
for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
|
|
50
|
+
if (IGNORED.test(rel)) continue
|
|
51
|
+
const abs = join(cwd, rel)
|
|
52
|
+
const source = await Bun.file(abs).text()
|
|
53
|
+
if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest - skip
|
|
54
|
+
const routesPrefix = ROUTES_PREFIX.exec(source)?.[1]
|
|
55
|
+
if (routesPrefix === undefined) continue // no routes/ imports -> nothing to sync
|
|
56
|
+
const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
57
|
+
const routesDir = join(cwd, manifestDir, routesPrefix)
|
|
58
|
+
let next: string
|
|
59
|
+
try {
|
|
60
|
+
next = resyncServerManifestSource(source, discoverRoutes(routesDir), routesPrefix)
|
|
61
|
+
} catch {
|
|
62
|
+
continue // routes dir gone/unreadable - not a manifest we can sync
|
|
63
|
+
}
|
|
64
|
+
const before = parseManifestRouteFiles(source, routesPrefix)
|
|
65
|
+
const after = parseManifestRouteFiles(next, routesPrefix)
|
|
66
|
+
// Floor guard: refuse to overwrite a populated manifest with an empty one. Discovery yielding zero
|
|
67
|
+
// routes for a manifest that had some means `routes/` was likely emptied by mistake - writing the wipe
|
|
68
|
+
// would silently un-serve every route. Leave the file untouched and surface it instead.
|
|
69
|
+
if (after.length === 0 && before.length > 0) {
|
|
70
|
+
results.push({ file: rel, changed: false, added: [], removed: [], refusedEmpty: true })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const changed = next !== source
|
|
74
|
+
if (changed) await Bun.write(abs, next)
|
|
75
|
+
// `diff(before, after)`: `missing` = files now present (added), `extra` = files gone (removed).
|
|
76
|
+
const drift = diffManifestRoutes(before, after)
|
|
77
|
+
results.push({ file: rel, changed, added: drift.missing, removed: drift.extra })
|
|
78
|
+
}
|
|
79
|
+
return results.sort((a, b) => a.file.localeCompare(b.file))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** CLI entry: sync every generated manifest, print a per-file summary + the client-rebuild caveat. */
|
|
83
|
+
export async function runSyncManifest(cwd: string): Promise<boolean> {
|
|
84
|
+
const results = await syncServerManifests(cwd)
|
|
85
|
+
if (results.length === 0) {
|
|
86
|
+
console.log("nifra sync-manifest: no generated server-manifest.ts found under this directory.")
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
let anyChanged = false
|
|
90
|
+
for (const r of results) {
|
|
91
|
+
if (r.refusedEmpty) {
|
|
92
|
+
console.log(
|
|
93
|
+
`⚠ ${r.file}: refused - routes/ has zero routes but the manifest had some; left untouched (delete the manifest yourself if this is intentional).`,
|
|
94
|
+
)
|
|
95
|
+
} else if (r.changed) {
|
|
96
|
+
anyChanged = true
|
|
97
|
+
console.log(`✓ synced ${r.file}`)
|
|
98
|
+
if (r.added.length > 0) console.log(` added: ${r.added.join(", ")}`)
|
|
99
|
+
if (r.removed.length > 0) console.log(` removed: ${r.removed.join(", ")}`)
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`• ${r.file} already in sync`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (anyChanged) {
|
|
105
|
+
console.log(
|
|
106
|
+
"\nRe-synced the server manifest's route table. If you added or changed a HYDRATING route\n" +
|
|
107
|
+
"component (not just a loader / SSR-only route), also run a full build so the client bundle matches.",
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return true
|
|
111
|
+
}
|
package/src/upgrade.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. **pin sweep** — set every matching dependency to the target version across the workspace's
|
|
7
7
|
* package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
|
|
8
|
-
* 2. **
|
|
9
|
-
*
|
|
8
|
+
* 2. **dependency moves** — replace removed packages with their supported successor without
|
|
9
|
+
* reserializing package.json or leaving duplicate dependency keys.
|
|
10
|
+
* 3. **import moves** — rewrite exact import specifiers to their updated module paths.
|
|
11
|
+
* 4. **verify** — reuse the existing `nifra check` gate; no new verification surface.
|
|
10
12
|
*
|
|
11
13
|
* Dry-run by default (prints the plan, writes nothing); `--write` applies. Fail-closed on an unknown
|
|
12
14
|
* target version or a missing package.json, and deterministic (same repo + target → same edits).
|
|
@@ -48,9 +50,20 @@ export interface ImportChange {
|
|
|
48
50
|
readonly count: number
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
export interface DependencyMoveChange {
|
|
54
|
+
readonly file: string
|
|
55
|
+
readonly field: string
|
|
56
|
+
readonly from: string
|
|
57
|
+
readonly to: string
|
|
58
|
+
readonly fromVersion: string
|
|
59
|
+
readonly toVersion: string
|
|
60
|
+
readonly action: "renamed" | "removed"
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
export interface UpgradePlan {
|
|
52
64
|
readonly version: string
|
|
53
65
|
readonly pins: readonly PinChange[]
|
|
66
|
+
readonly dependencyMoves: readonly DependencyMoveChange[]
|
|
54
67
|
readonly importMoves: readonly ImportChange[]
|
|
55
68
|
readonly notes: readonly string[]
|
|
56
69
|
}
|
|
@@ -103,7 +116,9 @@ export function pinSweepText(
|
|
|
103
116
|
if (typeof deps !== "object" || deps === null) continue
|
|
104
117
|
for (const [name, rawSpec] of Object.entries(deps as Record<string, unknown>)) {
|
|
105
118
|
if (typeof rawSpec !== "string") continue
|
|
106
|
-
const rule = rules.find(
|
|
119
|
+
const rule = rules.find(
|
|
120
|
+
(r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)),
|
|
121
|
+
)
|
|
107
122
|
if (!rule) continue
|
|
108
123
|
const next = rewriteVersionSpec(rawSpec, rule.to)
|
|
109
124
|
if (next === null) continue
|
|
@@ -119,6 +134,120 @@ export function pinSweepText(
|
|
|
119
134
|
return { text: out, changes }
|
|
120
135
|
}
|
|
121
136
|
|
|
137
|
+
function dependencyObjectRange(
|
|
138
|
+
text: string,
|
|
139
|
+
field: (typeof DEP_FIELDS)[number],
|
|
140
|
+
): { start: number; end: number } | undefined {
|
|
141
|
+
const match = new RegExp(`"${field}"\\s*:\\s*\\{`).exec(text)
|
|
142
|
+
if (match === null) return undefined
|
|
143
|
+
const open = text.indexOf("{", match.index)
|
|
144
|
+
let depth = 0
|
|
145
|
+
let quoted = false
|
|
146
|
+
let escaped = false
|
|
147
|
+
for (let index = open; index < text.length; index++) {
|
|
148
|
+
const char = text[index]
|
|
149
|
+
if (quoted) {
|
|
150
|
+
if (escaped) escaped = false
|
|
151
|
+
else if (char === "\\") escaped = true
|
|
152
|
+
else if (char === '"') quoted = false
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (char === '"') quoted = true
|
|
156
|
+
else if (char === "{") depth++
|
|
157
|
+
else if (char === "}" && --depth === 0) return { start: open + 1, end: index }
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function rewriteDependencyEntry(
|
|
163
|
+
text: string,
|
|
164
|
+
field: (typeof DEP_FIELDS)[number],
|
|
165
|
+
from: string,
|
|
166
|
+
fromVersion: string,
|
|
167
|
+
to: string | undefined,
|
|
168
|
+
toVersion: string | undefined,
|
|
169
|
+
): string {
|
|
170
|
+
const range = dependencyObjectRange(text, field)
|
|
171
|
+
if (range === undefined) return text
|
|
172
|
+
const body = text.slice(range.start, range.end)
|
|
173
|
+
const key = escapeRegExp(from)
|
|
174
|
+
const version = escapeRegExp(fromVersion)
|
|
175
|
+
const entry = new RegExp(`"${key}"(\\s*:\\s*)"${version}"`)
|
|
176
|
+
const match = entry.exec(body)
|
|
177
|
+
if (match === null) return text
|
|
178
|
+
|
|
179
|
+
let nextBody: string
|
|
180
|
+
if (to !== undefined && toVersion !== undefined) {
|
|
181
|
+
nextBody =
|
|
182
|
+
body.slice(0, match.index) +
|
|
183
|
+
`"${to}"${match[1]}"${toVersion}"` +
|
|
184
|
+
body.slice(match.index + match[0].length)
|
|
185
|
+
} else {
|
|
186
|
+
let start = match.index
|
|
187
|
+
let end = match.index + match[0].length
|
|
188
|
+
let after = end
|
|
189
|
+
while (/\\s/.test(body[after] ?? "")) after++
|
|
190
|
+
if (body[after] === ",") {
|
|
191
|
+
end = after + 1
|
|
192
|
+
} else {
|
|
193
|
+
let before = start - 1
|
|
194
|
+
while (before >= 0 && /\\s/.test(body[before] ?? "")) before--
|
|
195
|
+
if (body[before] === ",") start = before
|
|
196
|
+
}
|
|
197
|
+
nextBody = body.slice(0, start) + body.slice(end)
|
|
198
|
+
}
|
|
199
|
+
return text.slice(0, range.start) + nextBody + text.slice(range.end)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Move removed package dependencies without reserializing or reordering package.json. */
|
|
203
|
+
export function moveDependenciesText(
|
|
204
|
+
text: string,
|
|
205
|
+
moves: readonly { from: string; to: string; toVersion: string }[],
|
|
206
|
+
): { text: string; changes: Array<Omit<DependencyMoveChange, "file">> } {
|
|
207
|
+
let out = text
|
|
208
|
+
const changes: Array<Omit<DependencyMoveChange, "file">> = []
|
|
209
|
+
for (const move of moves) {
|
|
210
|
+
let parsed: Record<string, unknown>
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(out) as Record<string, unknown>
|
|
213
|
+
} catch {
|
|
214
|
+
return { text, changes: [] }
|
|
215
|
+
}
|
|
216
|
+
for (const field of DEP_FIELDS) {
|
|
217
|
+
const deps = parsed[field]
|
|
218
|
+
if (typeof deps !== "object" || deps === null) continue
|
|
219
|
+
const dependencyMap = deps as Record<string, unknown>
|
|
220
|
+
if (!Object.hasOwn(dependencyMap, move.from)) continue
|
|
221
|
+
const fromVersion = dependencyMap[move.from]
|
|
222
|
+
if (typeof fromVersion !== "string") continue
|
|
223
|
+
const movedVersion = rewriteVersionSpec(fromVersion, move.toVersion) ?? fromVersion
|
|
224
|
+
// Dependency fields have different install semantics. A devDependency does not satisfy a runtime
|
|
225
|
+
// dependency, so only deduplicate the successor inside the same field.
|
|
226
|
+
const targetExists = Object.hasOwn(dependencyMap, move.to)
|
|
227
|
+
const action = targetExists ? "removed" : "renamed"
|
|
228
|
+
const next = rewriteDependencyEntry(
|
|
229
|
+
out,
|
|
230
|
+
field,
|
|
231
|
+
move.from,
|
|
232
|
+
fromVersion,
|
|
233
|
+
targetExists ? undefined : move.to,
|
|
234
|
+
targetExists ? undefined : movedVersion,
|
|
235
|
+
)
|
|
236
|
+
if (next === out) continue
|
|
237
|
+
out = next
|
|
238
|
+
changes.push({
|
|
239
|
+
field,
|
|
240
|
+
from: move.from,
|
|
241
|
+
to: move.to,
|
|
242
|
+
fromVersion,
|
|
243
|
+
toVersion: movedVersion,
|
|
244
|
+
action,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { text: out, changes }
|
|
249
|
+
}
|
|
250
|
+
|
|
122
251
|
const IMPORT_SPEC = (from: string): RegExp =>
|
|
123
252
|
new RegExp(
|
|
124
253
|
`(\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|\\bimport\\s+)(['"])${escapeRegExp(from)}(['"])`,
|
|
@@ -162,16 +291,18 @@ function scan(cwd: string, pattern: string): string[] {
|
|
|
162
291
|
/** Compute the plan (and, when `write`, apply it) for a target recipe against `cwd`. */
|
|
163
292
|
export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolean): UpgradePlan {
|
|
164
293
|
const pins: PinChange[] = []
|
|
294
|
+
const dependencyMoves: DependencyMoveChange[] = []
|
|
165
295
|
const importMoves: ImportChange[] = []
|
|
166
296
|
|
|
167
|
-
if (recipe.pins.length > 0) {
|
|
297
|
+
if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
|
|
168
298
|
for (const rel of scan(cwd, "**/package.json")) {
|
|
169
299
|
const abs = join(cwd, rel)
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
300
|
+
const original = readFileSync(abs, "utf8")
|
|
301
|
+
const moved = moveDependenciesText(original, recipe.dependencyMoves ?? [])
|
|
302
|
+
for (const change of moved.changes) dependencyMoves.push({ file: rel, ...change })
|
|
303
|
+
const pinned = pinSweepText(moved.text, recipe.pins)
|
|
304
|
+
for (const change of pinned.changes) pins.push({ file: rel, ...change })
|
|
305
|
+
if (write && pinned.text !== original) writeFileSync(abs, pinned.text)
|
|
175
306
|
}
|
|
176
307
|
}
|
|
177
308
|
|
|
@@ -186,7 +317,13 @@ export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolea
|
|
|
186
317
|
}
|
|
187
318
|
}
|
|
188
319
|
|
|
189
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
version: recipe.version,
|
|
322
|
+
pins,
|
|
323
|
+
dependencyMoves,
|
|
324
|
+
importMoves,
|
|
325
|
+
notes: recipe.notes ?? [],
|
|
326
|
+
}
|
|
190
327
|
}
|
|
191
328
|
|
|
192
329
|
function renderPlan(plan: UpgradePlan, write: boolean): string {
|
|
@@ -194,10 +331,25 @@ function renderPlan(plan: UpgradePlan, write: boolean): string {
|
|
|
194
331
|
const verb = write ? "Applied" : "Planned"
|
|
195
332
|
lines.push(`nifra upgrade → ${plan.version} (${write ? "write" : "dry-run"})`)
|
|
196
333
|
lines.push("")
|
|
197
|
-
if (
|
|
334
|
+
if (
|
|
335
|
+
plan.pins.length === 0 &&
|
|
336
|
+
plan.dependencyMoves.length === 0 &&
|
|
337
|
+
plan.importMoves.length === 0
|
|
338
|
+
) {
|
|
198
339
|
lines.push("Already up to date — no changes.")
|
|
199
340
|
return lines.join("\n")
|
|
200
341
|
}
|
|
342
|
+
if (plan.dependencyMoves.length > 0) {
|
|
343
|
+
lines.push(`${verb} ${plan.dependencyMoves.length} dependency move(s):`)
|
|
344
|
+
for (const move of plan.dependencyMoves) {
|
|
345
|
+
const detail =
|
|
346
|
+
move.action === "renamed"
|
|
347
|
+
? `${move.from}@${move.fromVersion} → ${move.to}@${move.toVersion}`
|
|
348
|
+
: `removed ${move.from}@${move.fromVersion}; ${move.to} is already declared`
|
|
349
|
+
lines.push(` ${move.file} ${detail}`)
|
|
350
|
+
}
|
|
351
|
+
lines.push("")
|
|
352
|
+
}
|
|
201
353
|
if (plan.pins.length > 0) {
|
|
202
354
|
lines.push(`${verb} ${plan.pins.length} dependency pin(s):`)
|
|
203
355
|
for (const p of plan.pins) lines.push(` ${p.file} ${p.name}: ${p.from} → ${p.to}`)
|