@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.
Files changed (47) hide show
  1. package/README.md +17 -2
  2. package/dist/check.d.ts +17 -6
  3. package/dist/check.d.ts.map +1 -1
  4. package/dist/check.js +256 -15
  5. package/dist/check.js.map +1 -1
  6. package/dist/cli.d.ts +2 -3
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +58 -71
  9. package/dist/cli.js.map +1 -1
  10. package/dist/doctor.d.ts.map +1 -1
  11. package/dist/doctor.js +131 -61
  12. package/dist/doctor.js.map +1 -1
  13. package/dist/mcp-http.js +1 -1
  14. package/dist/mcp-http.js.map +1 -1
  15. package/dist/mcp.d.ts +2 -0
  16. package/dist/mcp.d.ts.map +1 -1
  17. package/dist/mcp.js +42 -0
  18. package/dist/mcp.js.map +1 -1
  19. package/dist/recipes/2.0.0.d.ts +9 -0
  20. package/dist/recipes/2.0.0.d.ts.map +1 -0
  21. package/dist/recipes/2.0.0.js +25 -0
  22. package/dist/recipes/2.0.0.js.map +1 -0
  23. package/dist/recipes/index.d.ts +7 -0
  24. package/dist/recipes/index.d.ts.map +1 -1
  25. package/dist/recipes/index.js +2 -0
  26. package/dist/recipes/index.js.map +1 -1
  27. package/dist/sync-manifest.d.ts +33 -0
  28. package/dist/sync-manifest.d.ts.map +1 -0
  29. package/dist/sync-manifest.js +97 -0
  30. package/dist/sync-manifest.js.map +1 -0
  31. package/dist/upgrade.d.ts +23 -2
  32. package/dist/upgrade.d.ts.map +1 -1
  33. package/dist/upgrade.js +144 -13
  34. package/dist/upgrade.js.map +1 -1
  35. package/docs/examples.json +69 -21
  36. package/docs/llms-full.txt +1065 -104
  37. package/docs/types.json +933 -181
  38. package/package.json +9 -9
  39. package/src/check.ts +251 -18
  40. package/src/cli.ts +57 -73
  41. package/src/doctor.ts +160 -61
  42. package/src/mcp-http.ts +1 -1
  43. package/src/mcp.ts +52 -0
  44. package/src/recipes/2.0.0.ts +26 -0
  45. package/src/recipes/index.ts +10 -0
  46. package/src/sync-manifest.ts +111 -0
  47. package/src/upgrade.ts +163 -11
@@ -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. **import moves** — rewrite exact import specifiers to their updated module paths.
9
- * 3. **verify** — reuse the existing `nifra check` gate; no new verification surface.
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((r) => name.startsWith(r.match))
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 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)
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 { version: recipe.version, pins, importMoves, notes: recipe.notes ?? [] }
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 (plan.pins.length === 0 && plan.importMoves.length === 0) {
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}`)