@nifrajs/cli 2.9.1 → 2.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 +2 -1
- package/dist/check.d.ts +9 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +38 -6
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -18
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -66
- package/dist/cli.js.map +1 -1
- package/dist/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +17 -1
- package/dist/doctor.js.map +1 -1
- package/dist/introspect.d.ts +26 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -2
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +4 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- package/dist/load.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- package/dist/sdk.d.ts +10 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +229 -0
- package/dist/sdk.js.map +1 -0
- package/docs/llms-full.txt +246 -17
- package/docs/types.json +725 -16
- package/package.json +9 -9
- package/src/check.ts +54 -5
- package/src/cli.ts +131 -67
- package/src/dev-bun-config.ts +57 -14
- package/src/doctor.ts +28 -1
- package/src/introspect.ts +124 -4
- package/src/load.ts +5 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +2 -2
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
- package/src/sdk.ts +260 -0
package/src/doctor.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { readdir, realpath, stat } from "node:fs/promises"
|
|
|
21
21
|
import { builtinModules } from "node:module"
|
|
22
22
|
import { dirname, join, relative, sep } from "node:path"
|
|
23
23
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
24
|
+
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
24
25
|
|
|
25
26
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
26
27
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -135,6 +136,15 @@ export interface DoctorResult {
|
|
|
135
136
|
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
136
137
|
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
137
138
|
readonly staleDists: readonly StaleDistFinding[]
|
|
139
|
+
/**
|
|
140
|
+
* Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
|
|
141
|
+
* that exist only because there are two. Absent when the directory is not a nifra app.
|
|
142
|
+
*
|
|
143
|
+
* Reported by doctor rather than only by `dev`/`build` because the answer governs how the rest of a
|
|
144
|
+
* project is read - which plugin slot is live, whether `conditions` reach the client bundle, which
|
|
145
|
+
* toolchain compiles a component - and asking for it should not require starting a server.
|
|
146
|
+
*/
|
|
147
|
+
readonly pipeline?: PipelineReport
|
|
138
148
|
/** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
|
|
139
149
|
readonly fixed?: readonly DoctorAppliedFix[]
|
|
140
150
|
/** Findings that were safe to report but not safe to write automatically. */
|
|
@@ -701,13 +711,16 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
|
701
711
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
|
|
702
712
|
const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
|
|
703
713
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
714
|
+
const pipeline = await collectPipelineReport(cwd)
|
|
715
|
+
const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
|
|
704
716
|
// `staleDists` is advisory (see DoctorResult): it never fails `ok`.
|
|
705
717
|
return {
|
|
706
|
-
ok: findings.length === 0 && duplicateInstalls.length === 0,
|
|
718
|
+
ok: findings.length === 0 && duplicateInstalls.length === 0 && pipelineErrors.length === 0,
|
|
707
719
|
ran: true,
|
|
708
720
|
findings,
|
|
709
721
|
duplicateInstalls,
|
|
710
722
|
staleDists,
|
|
723
|
+
...(pipeline.ran ? { pipeline } : {}),
|
|
711
724
|
}
|
|
712
725
|
}
|
|
713
726
|
|
|
@@ -811,6 +824,20 @@ export async function runDoctor(
|
|
|
811
824
|
}
|
|
812
825
|
console.log("")
|
|
813
826
|
}
|
|
827
|
+
// Printed before the findings and regardless of ok: which bundler owns this app is the frame the
|
|
828
|
+
// rest of the report is read in, not a footnote to it.
|
|
829
|
+
if (result.pipeline !== undefined) {
|
|
830
|
+
const p = result.pipeline
|
|
831
|
+
console.log(
|
|
832
|
+
p.pipeline === "unknown"
|
|
833
|
+
? `• bundler: could not be read from ${p.configFile} - ${p.reason}\n`
|
|
834
|
+
: `• bundler: ${p.pipeline} (${p.reason})\n`,
|
|
835
|
+
)
|
|
836
|
+
for (const f of p.findings) {
|
|
837
|
+
console.log(`${f.severity === "error" ? "✗" : "⚠"} ${f.file}:${f.line ?? 0} ${f.message}`)
|
|
838
|
+
console.log(` fix: ${f.fix}\n`)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
814
841
|
// Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
|
|
815
842
|
// it costs an hour of misdiagnosis when the dev server 500s inside the package.
|
|
816
843
|
if (result.staleDists.length > 0) {
|
package/src/introspect.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "@nifr
|
|
|
9
9
|
import type { Manifest } from "@nifrajs/web"
|
|
10
10
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
11
11
|
import type { LoadedApp } from "./load.ts"
|
|
12
|
+
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
12
13
|
|
|
13
14
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
14
15
|
export function backendRoutes(backend: unknown): ReflectedRoute[] {
|
|
@@ -232,6 +233,11 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
|
|
|
232
233
|
// Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
|
|
233
234
|
// wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
|
|
234
235
|
"> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
|
|
236
|
+
// The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
|
|
237
|
+
// the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
|
|
238
|
+
// index because it governs where a plugin goes and which toolchain compiles a component - an edit
|
|
239
|
+
// put in the other pipeline's slot is accepted and then silently never runs.
|
|
240
|
+
`**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
|
|
235
241
|
)
|
|
236
242
|
}
|
|
237
243
|
if (filter?.kind !== "pages") {
|
|
@@ -430,6 +436,106 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
|
|
|
430
436
|
}
|
|
431
437
|
}
|
|
432
438
|
|
|
439
|
+
export interface RouteGraphNode {
|
|
440
|
+
readonly id: string
|
|
441
|
+
readonly kind: "root" | RouteKind
|
|
442
|
+
readonly path: string
|
|
443
|
+
readonly methods?: readonly string[]
|
|
444
|
+
readonly file?: string
|
|
445
|
+
readonly autoMounted?: boolean
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface RouteGraphEdge {
|
|
449
|
+
readonly from: string
|
|
450
|
+
readonly to: string
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface RouteGraph {
|
|
454
|
+
readonly nodes: readonly RouteGraphNode[]
|
|
455
|
+
readonly edges: readonly RouteGraphEdge[]
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function parentPath(path: string): string | undefined {
|
|
459
|
+
if (path === "/") return undefined
|
|
460
|
+
const trimmed = path.replace(/\/$/, "")
|
|
461
|
+
const slash = trimmed.lastIndexOf("/")
|
|
462
|
+
return slash <= 0 ? "/" : trimmed.slice(0, slash)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Build a stable parent/child graph from the public route table. Pure and topology-neutral. */
|
|
466
|
+
export function buildRouteGraph(rows: readonly RouteTableEntry[]): RouteGraph {
|
|
467
|
+
const sorted = [...rows].sort(
|
|
468
|
+
(a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind),
|
|
469
|
+
)
|
|
470
|
+
const nodes: RouteGraphNode[] = [
|
|
471
|
+
{ id: "root", kind: "root", path: "/" },
|
|
472
|
+
...sorted.map((row) => ({
|
|
473
|
+
id: `${row.kind}:${row.path}`,
|
|
474
|
+
kind: row.kind,
|
|
475
|
+
path: row.path,
|
|
476
|
+
methods: row.methods,
|
|
477
|
+
...(row.file === undefined ? {} : { file: row.file }),
|
|
478
|
+
...(row.kind === "api" ? { autoMounted: row.autoMounted === true } : {}),
|
|
479
|
+
})),
|
|
480
|
+
]
|
|
481
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
482
|
+
const edges: RouteGraphEdge[] = []
|
|
483
|
+
for (const row of sorted) {
|
|
484
|
+
let parent = parentPath(row.path)
|
|
485
|
+
let from = "root"
|
|
486
|
+
while (parent !== undefined) {
|
|
487
|
+
const candidate = `${row.kind}:${parent}`
|
|
488
|
+
if (nodeIds.has(candidate)) {
|
|
489
|
+
from = candidate
|
|
490
|
+
break
|
|
491
|
+
}
|
|
492
|
+
parent = parentPath(parent)
|
|
493
|
+
}
|
|
494
|
+
edges.push({ from, to: `${row.kind}:${row.path}` })
|
|
495
|
+
}
|
|
496
|
+
return { nodes, edges }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Stable JSON shape for agents and contract explorers. */
|
|
500
|
+
export function routeGraphToJson(graph: RouteGraph): RouteGraph {
|
|
501
|
+
return {
|
|
502
|
+
nodes: graph.nodes.map((node) => ({
|
|
503
|
+
...node,
|
|
504
|
+
...(node.methods === undefined ? {} : { methods: [...node.methods] }),
|
|
505
|
+
})),
|
|
506
|
+
edges: graph.edges.map((edge) => ({ ...edge })),
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function mermaidText(value: string): string {
|
|
511
|
+
return value
|
|
512
|
+
.replaceAll("&", "&")
|
|
513
|
+
.replaceAll('"', """)
|
|
514
|
+
.replaceAll("<", "<")
|
|
515
|
+
.replaceAll(">", ">")
|
|
516
|
+
.replaceAll(/[\r\n]/g, " ")
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function mermaidLabel(node: RouteGraphNode): string {
|
|
520
|
+
if (node.kind === "root") return "routes"
|
|
521
|
+
const methods = node.methods?.join(", ") ?? ""
|
|
522
|
+
const mounted = node.autoMounted === true ? " (auto-mounted)" : ""
|
|
523
|
+
return mermaidText(`${node.kind}: ${methods} ${node.path}${mounted}`)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Render a route graph as Mermaid for terminals, docs, and agent-facing previews. */
|
|
527
|
+
export function renderRouteGraph(graph: RouteGraph): string {
|
|
528
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]))
|
|
529
|
+
const lines = ["flowchart TD"]
|
|
530
|
+
for (const node of graph.nodes) {
|
|
531
|
+
lines.push(` ${ids.get(node.id)}["${mermaidLabel(node)}"]`)
|
|
532
|
+
}
|
|
533
|
+
for (const edge of graph.edges) {
|
|
534
|
+
lines.push(` ${ids.get(edge.from)} --> ${ids.get(edge.to)}`)
|
|
535
|
+
}
|
|
536
|
+
return lines.join("\n")
|
|
537
|
+
}
|
|
538
|
+
|
|
433
539
|
/** A route module that may export an `action` (→ the page serves POST). */
|
|
434
540
|
interface MaybeActionModule {
|
|
435
541
|
readonly action?: unknown
|
|
@@ -441,10 +547,7 @@ interface MaybeActionModule {
|
|
|
441
547
|
* text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
|
|
442
548
|
* degrades to GET-only for that route (never throws the whole command).
|
|
443
549
|
*/
|
|
444
|
-
|
|
445
|
-
app: LoadedApp,
|
|
446
|
-
opts: { readonly json?: boolean } = {},
|
|
447
|
-
): Promise<string> {
|
|
550
|
+
async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
|
|
448
551
|
let manifest: Manifest | undefined
|
|
449
552
|
try {
|
|
450
553
|
manifest = discoverRoutes(app.routesDir)
|
|
@@ -463,5 +566,22 @@ export async function describeRoutes(
|
|
|
463
566
|
pages.push({ pattern: route.pattern, file: route.file, hasAction })
|
|
464
567
|
}
|
|
465
568
|
const rows = buildRouteTable({ pages, api: backendRoutes(app.backend) })
|
|
569
|
+
return rows
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function describeRoutes(
|
|
573
|
+
app: LoadedApp,
|
|
574
|
+
opts: { readonly json?: boolean } = {},
|
|
575
|
+
): Promise<string> {
|
|
576
|
+
const rows = await collectRouteTable(app)
|
|
466
577
|
return opts.json ? JSON.stringify(routeTableToJson(rows), null, 2) : renderRouteTable(rows)
|
|
467
578
|
}
|
|
579
|
+
|
|
580
|
+
/** Gather and render the public route graph for `nifra routes --graph`. */
|
|
581
|
+
export async function describeRouteGraph(
|
|
582
|
+
app: LoadedApp,
|
|
583
|
+
opts: { readonly json?: boolean } = {},
|
|
584
|
+
): Promise<string> {
|
|
585
|
+
const graph = buildRouteGraph(await collectRouteTable(app))
|
|
586
|
+
return opts.json ? JSON.stringify(routeGraphToJson(graph), null, 2) : renderRouteGraph(graph)
|
|
587
|
+
}
|
package/src/load.ts
CHANGED
|
@@ -93,6 +93,10 @@ export interface ResolvedPlugins {
|
|
|
93
93
|
|
|
94
94
|
export interface LoadedApp {
|
|
95
95
|
readonly cwd: string
|
|
96
|
+
/** Absolute path of the config this app was loaded from - `nifra.config.ts`, or `framework.ts` as the
|
|
97
|
+
* fallback. `nifra dev`'s Bun pipeline re-imports it from a GENERATED module so the app's own
|
|
98
|
+
* `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
|
|
99
|
+
readonly configPath: string
|
|
96
100
|
readonly routesDir: string
|
|
97
101
|
/** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
|
|
98
102
|
readonly outDir: string
|
|
@@ -222,6 +226,7 @@ export async function loadApp(
|
|
|
222
226
|
|
|
223
227
|
return {
|
|
224
228
|
cwd,
|
|
229
|
+
configPath,
|
|
225
230
|
routesDir: resolve(cwd, "routes"),
|
|
226
231
|
outDir: resolve(cwd, outDirName),
|
|
227
232
|
framework: { ...fw, clientModule } as NifraFramework,
|
package/src/mcp-http.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
29
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
30
30
|
|
|
31
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
32
|
-
const VERSION = "2.
|
|
32
|
+
const VERSION = "2.11.0"
|
|
33
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
34
34
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
35
|
const docsHealth = (tools: McpTool[]): string =>
|
package/src/mcp.ts
CHANGED
|
@@ -1124,7 +1124,7 @@ export function projectTools(
|
|
|
1124
1124
|
{
|
|
1125
1125
|
name: "nifra_check",
|
|
1126
1126
|
description:
|
|
1127
|
-
'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
|
|
1127
|
+
'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[], pipeline? }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. `pipeline` answers "which bundler does this app run on" (bun|vite, for dev AND build alike) without starting a server, and its `pipeline` diagnostics catch the hazards of having two: a plugin in the slot the other bundler reads (accepted, then never called), a dev toolchain imported by the file `nifra build` bundles into the production server, and `conditions` that cannot reach Bun\'s dev client bundle. Read it before adding a plugin or a compiler. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
|
|
1128
1128
|
inputSchema: {
|
|
1129
1129
|
type: "object",
|
|
1130
1130
|
properties: {
|
|
@@ -1323,7 +1323,7 @@ export function projectTools(
|
|
|
1323
1323
|
{
|
|
1324
1324
|
name: "nifra_doctor",
|
|
1325
1325
|
description:
|
|
1326
|
-
"Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
|
|
1326
|
+
"Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Also reports `pipeline`: which bundler (bun|vite) this app's dev AND build phases run on and why, plus config hazards that only exist because there are two - a plugin sitting in the slot the other bundler reads (accepted, then never called), a dev toolchain imported by the file `nifra build` bundles into the production server (builds clean, dies at startup), and `conditions` that cannot reach the client bundle Bun's dev server serves. Returns { ok, ran, findings[], pipeline?, fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
|
|
1327
1327
|
inputSchema: {
|
|
1328
1328
|
type: "object",
|
|
1329
1329
|
properties: {
|
package/src/pipeline-guard.ts
CHANGED
|
@@ -116,6 +116,13 @@ export type BuildPipeline = "bun" | "vite"
|
|
|
116
116
|
|
|
117
117
|
export interface PipelineDecision {
|
|
118
118
|
readonly pipeline: BuildPipeline
|
|
119
|
+
/**
|
|
120
|
+
* How the pipeline was arrived at. Always set, because "which bundler am I on" is a question every
|
|
121
|
+
* `dev`/`build` run should answer without the user reading this source: `default` is the plain Bun
|
|
122
|
+
* default, `auto` means the config forced nifra's hand (see {@link reason}), `forced` is `--vite` /
|
|
123
|
+
* `--bun`.
|
|
124
|
+
*/
|
|
125
|
+
readonly why: "default" | "auto" | "forced"
|
|
119
126
|
/**
|
|
120
127
|
* Why this pipeline, for the `nifra build` log line - so an auto-selected Vite build never looks
|
|
121
128
|
* like the user got the default. `undefined` when the choice was the plain default or was forced.
|
|
@@ -139,16 +146,14 @@ const pluginNames = (plugins: readonly unknown[]): string =>
|
|
|
139
146
|
.join(", ")
|
|
140
147
|
|
|
141
148
|
/**
|
|
142
|
-
* Pick the bundler for `nifra build`, per app rather than per global default.
|
|
149
|
+
* Pick the bundler for `nifra build` and `nifra dev`, per app rather than per global default.
|
|
143
150
|
*
|
|
144
|
-
* The phase defaults are
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* the same failure {@link checkPipelineSeparation} exists to prevent, reached by crossing PHASES instead
|
|
151
|
-
* of slots, and the slot check cannot see it because the plugins are correctly placed.
|
|
151
|
+
* The phase defaults are Bun (faster and Bun-native). For an app with no transforms that is direct and
|
|
152
|
+
* predictable. For an app whose ONLY transforms are Vite plugins it is not: those plugins are required in
|
|
153
|
+
* both dev and production, while the Bun build reads `clientPlugins`/`serverPlugins`, never `vitePlugins`.
|
|
154
|
+
* Selecting Bun would make the build succeed with the transform omitted. That is the same failure
|
|
155
|
+
* {@link checkPipelineSeparation} exists to prevent, reached by crossing PHASES instead of slots, and the
|
|
156
|
+
* slot check cannot see it because the plugins are correctly placed.
|
|
152
157
|
*
|
|
153
158
|
* So the default follows the app: Vite plugins with no Bun counterpart means the app has exactly one
|
|
154
159
|
* pipeline that can build it, and that is the one used. An app declaring BOTH has supplied the Bun
|
|
@@ -161,26 +166,51 @@ const pluginNames = (plugins: readonly unknown[]): string =>
|
|
|
161
166
|
export function chooseBuildPipeline(
|
|
162
167
|
slots: PipelineSlots,
|
|
163
168
|
forced?: BuildPipeline,
|
|
169
|
+
command: "build" | "dev" = "build",
|
|
164
170
|
): PipelineDecision {
|
|
165
171
|
const vitePlugins = slots.vitePlugins ?? []
|
|
166
172
|
const bunPlugins = [...(slots.clientPlugins ?? []), ...(slots.serverPlugins ?? [])]
|
|
167
173
|
const viteOnly = vitePlugins.length > 0 && bunPlugins.length === 0
|
|
168
174
|
|
|
169
175
|
if (forced === "bun" && viteOnly) {
|
|
176
|
+
// Named for the command the user actually typed - a `nifra dev --bun` failure that talks about
|
|
177
|
+
// `nifra build` sends them looking at the wrong command.
|
|
178
|
+
const phase = command === "dev" ? "dev server" : "build"
|
|
170
179
|
throw new Error(
|
|
171
|
-
|
|
172
|
-
|
|
180
|
+
`[nifra] \`nifra ${command} --bun\` would silently drop this app's only transforms. \`vitePlugins\` are ` +
|
|
181
|
+
`read by the Vite ${phase}; the Bun ${phase} reads \`clientPlugins\`/\`serverPlugins\`, so these would ` +
|
|
173
182
|
"never run and the output would look plausible without them.\n" +
|
|
174
183
|
` Would be dropped: ${pluginNames(vitePlugins)}\n` +
|
|
175
|
-
|
|
184
|
+
` Drop the flag to use Vite, or add the Bun equivalents to \`clientPlugins\`/\`serverPlugins\`.`,
|
|
176
185
|
)
|
|
177
186
|
}
|
|
178
|
-
if (forced !== undefined) return { pipeline: forced }
|
|
187
|
+
if (forced !== undefined) return { pipeline: forced, why: "forced" }
|
|
179
188
|
if (viteOnly) {
|
|
180
189
|
return {
|
|
181
190
|
pipeline: "vite",
|
|
191
|
+
why: "auto",
|
|
182
192
|
reason: `auto: this app's only transforms are \`vitePlugins\` (${pluginNames(vitePlugins)}), which the Bun build cannot run`,
|
|
183
193
|
}
|
|
184
194
|
}
|
|
185
|
-
return { pipeline: "bun" }
|
|
195
|
+
return { pipeline: "bun", why: "default" }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The one-line "which bundler, and why" note `nifra dev` and `nifra build` print under their banner.
|
|
200
|
+
*
|
|
201
|
+
* Printed on EVERY run, not only the surprising ones. A pipeline that announces itself only when it
|
|
202
|
+
* deviates teaches nobody which pipeline they are normally on, and every gotcha in the dev docs -
|
|
203
|
+
* plugin slots, resolve conditions, which compiler owns a `.svelte` file - is answerable only once you
|
|
204
|
+
* know that. The line also names the flag that switches, so the answer to "can I try the other one"
|
|
205
|
+
* is on screen rather than in the docs.
|
|
206
|
+
*/
|
|
207
|
+
export function describePipeline(decision: PipelineDecision): string {
|
|
208
|
+
const other = decision.pipeline === "bun" ? "--vite" : "--bun"
|
|
209
|
+
const detail =
|
|
210
|
+
decision.why === "forced"
|
|
211
|
+
? `forced by --${decision.pipeline}`
|
|
212
|
+
: decision.why === "auto"
|
|
213
|
+
? (decision.reason ?? "auto")
|
|
214
|
+
: `default; ${other} to switch`
|
|
215
|
+
return `bundler: ${decision.pipeline} (${detail})`
|
|
186
216
|
}
|