@nifrajs/cli 2.4.0 → 2.5.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/dist/check.d.ts +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +24 -1
- package/dist/check.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/doctor.d.ts +27 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +154 -15
- package/dist/doctor.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +6 -2
- package/dist/mcp-http.js.map +1 -1
- package/docs/llms-full.txt +29 -1
- package/docs/types.json +44 -19
- package/package.json +9 -9
- package/src/check.ts +25 -1
- package/src/cli.ts +1 -1
- package/src/doctor.ts +178 -3
- package/src/mcp-http.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@nifrajs/client": "2.
|
|
45
|
-
"@nifrajs/core": "2.
|
|
46
|
-
"@nifrajs/mcp": "2.
|
|
47
|
-
"@nifrajs/runner": "2.
|
|
48
|
-
"@nifrajs/schema": "2.
|
|
49
|
-
"@nifrajs/testing": "2.
|
|
50
|
-
"@nifrajs/web": "2.
|
|
51
|
-
"create-nifra": "2.
|
|
44
|
+
"@nifrajs/client": "2.5.0",
|
|
45
|
+
"@nifrajs/core": "2.5.0",
|
|
46
|
+
"@nifrajs/mcp": "2.5.0",
|
|
47
|
+
"@nifrajs/runner": "2.5.0",
|
|
48
|
+
"@nifrajs/schema": "2.5.0",
|
|
49
|
+
"@nifrajs/testing": "2.5.0",
|
|
50
|
+
"@nifrajs/web": "2.5.0",
|
|
51
|
+
"create-nifra": "2.5.0"
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|
package/src/check.ts
CHANGED
|
@@ -1204,6 +1204,7 @@ export interface CheckDiagnostic {
|
|
|
1204
1204
|
| "response-route"
|
|
1205
1205
|
| "undeclared-dependency"
|
|
1206
1206
|
| "duplicate-install"
|
|
1207
|
+
| "stale-workspace-dist"
|
|
1207
1208
|
| "server-manifest-drift"
|
|
1208
1209
|
| "manifest-drift"
|
|
1209
1210
|
| "capability-assurance"
|
|
@@ -1729,6 +1730,28 @@ export async function collectCheckResult(
|
|
|
1729
1730
|
},
|
|
1730
1731
|
})
|
|
1731
1732
|
}
|
|
1733
|
+
// Advisory (never fails the gate): while actively editing a linked package its dist is always
|
|
1734
|
+
// momentarily behind. The finding earns its keep when a dev server is about to start against it -
|
|
1735
|
+
// Bun reads live `src` while Vite's SSR runner reads the artifact, so a stale one 500s inside
|
|
1736
|
+
// framework/shared-package code and reads exactly like an upstream regression.
|
|
1737
|
+
for (const f of dr.staleDists) {
|
|
1738
|
+
diagnostics.push({
|
|
1739
|
+
rule: "stale-workspace-dist",
|
|
1740
|
+
severity: "warning",
|
|
1741
|
+
message: f.missing
|
|
1742
|
+
? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - rebuild ${f.package}`
|
|
1743
|
+
: `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - rebuild ${f.package}`,
|
|
1744
|
+
fix: `rebuild ${f.package}`,
|
|
1745
|
+
suggestion: {
|
|
1746
|
+
kind: "manual",
|
|
1747
|
+
title: `Rebuild ${f.package}`,
|
|
1748
|
+
steps: [
|
|
1749
|
+
`Run the package's build (usually \`bun run build\` in its directory) so ${f.distFile} matches its source again.`,
|
|
1750
|
+
"Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
|
|
1751
|
+
],
|
|
1752
|
+
},
|
|
1753
|
+
})
|
|
1754
|
+
}
|
|
1732
1755
|
}
|
|
1733
1756
|
// #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
|
|
1734
1757
|
for (const f of manifestDrift) {
|
|
@@ -1948,6 +1971,7 @@ export async function runCheck(
|
|
|
1948
1971
|
["response-route", "route returns a raw Response (typed client → data: never)"],
|
|
1949
1972
|
["undeclared-dependency", "undeclared dependency in package.json"],
|
|
1950
1973
|
["duplicate-install", "duplicate identity-sensitive dependency install"],
|
|
1974
|
+
["stale-workspace-dist", "workspace-linked dist older than its source"],
|
|
1951
1975
|
["server-manifest-drift", "server-manifest.ts drifted from routes/"],
|
|
1952
1976
|
["manifest-drift", "versioned trust manifest drift"],
|
|
1953
1977
|
["capability-assurance", "effect/capability assurance"],
|
|
@@ -1955,7 +1979,7 @@ export async function runCheck(
|
|
|
1955
1979
|
["check-config", "nifra.check.json"],
|
|
1956
1980
|
] as const) {
|
|
1957
1981
|
const ds = counts(rule)
|
|
1958
|
-
if (rule === "response-route") {
|
|
1982
|
+
if (rule === "response-route" || rule === "stale-workspace-dist") {
|
|
1959
1983
|
// Advisory: surfaced with ⚠, never folded into pass/fail.
|
|
1960
1984
|
console.log(ds.length === 0 ? `✓ ${label}: none` : `⚠ ${label}: ${ds.length} (advisory)`)
|
|
1961
1985
|
} else if (rule !== "typecheck") {
|
package/src/cli.ts
CHANGED
|
@@ -154,7 +154,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
154
154
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
155
155
|
|
|
156
156
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
157
|
-
const CLI_VERSION = "2.
|
|
157
|
+
const CLI_VERSION = "2.5.0"
|
|
158
158
|
|
|
159
159
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
160
160
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
package/src/doctor.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
|
|
11
11
|
* the package's own name, and tsconfig `paths` aliases are excluded - none of them are npm deps.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import type { Dirent } from "node:fs"
|
|
14
|
+
import { readdir, realpath, stat } from "node:fs/promises"
|
|
14
15
|
import { builtinModules } from "node:module"
|
|
15
|
-
import { dirname, join, relative } from "node:path"
|
|
16
|
+
import { dirname, join, relative, sep } from "node:path"
|
|
16
17
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
17
18
|
|
|
18
19
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
@@ -124,6 +125,10 @@ export interface DoctorResult {
|
|
|
124
125
|
readonly findings: readonly DoctorFinding[]
|
|
125
126
|
/** Packages that resolve to more than one physical install across this workspace. */
|
|
126
127
|
readonly duplicateInstalls: readonly DuplicateInstallFinding[]
|
|
128
|
+
/** Workspace-linked deps whose `default`-condition artifact lags their `bun`-condition source.
|
|
129
|
+
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
130
|
+
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
131
|
+
readonly staleDists: readonly StaleDistFinding[]
|
|
127
132
|
/** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
|
|
128
133
|
readonly fixed?: readonly DoctorAppliedFix[]
|
|
129
134
|
/** Findings that were safe to report but not safe to write automatically. */
|
|
@@ -460,6 +465,156 @@ export async function collectDuplicateInstalls(
|
|
|
460
465
|
return findings
|
|
461
466
|
}
|
|
462
467
|
|
|
468
|
+
/** A workspace-linked dependency whose build artifact is older than its source. */
|
|
469
|
+
export interface StaleDistFinding {
|
|
470
|
+
readonly package: string
|
|
471
|
+
/** The `default`-condition artifact (what Vite's SSR runner, vitest, and node consumers load),
|
|
472
|
+
* relative to the doctor root when possible. `missing: true` means it was never built at all. */
|
|
473
|
+
readonly distFile: string
|
|
474
|
+
readonly missing: boolean
|
|
475
|
+
/** The newest source file under the package's `bun`-condition tree - the proof of staleness. */
|
|
476
|
+
readonly sourceFile: string
|
|
477
|
+
/** How far the artifact lags the newest source, in whole seconds (0 when `missing`). */
|
|
478
|
+
readonly behindSeconds: number
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Resolve one condition through an export-map entry: a string is terminal; an object takes the first
|
|
483
|
+
* key that is the wanted condition or `default` (insertion order, like the runtime), skipping `types`.
|
|
484
|
+
*/
|
|
485
|
+
function exportTarget(entry: unknown, condition: "bun" | "default"): string | undefined {
|
|
486
|
+
if (typeof entry === "string") return entry
|
|
487
|
+
if (!isRecord(entry)) return undefined
|
|
488
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
489
|
+
if (key === "types") continue
|
|
490
|
+
if (key === condition || key === "default") return exportTarget(value, condition)
|
|
491
|
+
}
|
|
492
|
+
return undefined
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Bounded recursive walk for the newest file under a package's source tree. The cap is a runaway
|
|
496
|
+
// guard, not a tuning knob - a real `src/` is a few hundred files.
|
|
497
|
+
const MAX_SOURCE_FILES = 4_096
|
|
498
|
+
async function newestFileUnder(
|
|
499
|
+
dir: string,
|
|
500
|
+
): Promise<{ readonly file: string; readonly mtimeMs: number } | undefined> {
|
|
501
|
+
let newest: { file: string; mtimeMs: number } | undefined
|
|
502
|
+
let seen = 0
|
|
503
|
+
const walk = async (current: string): Promise<void> => {
|
|
504
|
+
const entries: Dirent[] = await readdir(current, { withFileTypes: true }).catch(
|
|
505
|
+
() => [] as Dirent[],
|
|
506
|
+
)
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
if (seen >= MAX_SOURCE_FILES) return
|
|
509
|
+
const path = join(current, entry.name)
|
|
510
|
+
if (entry.isDirectory()) {
|
|
511
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith("."))
|
|
512
|
+
continue
|
|
513
|
+
await walk(path)
|
|
514
|
+
continue
|
|
515
|
+
}
|
|
516
|
+
if (!entry.isFile()) continue
|
|
517
|
+
seen++
|
|
518
|
+
const s = await stat(path).catch(() => undefined)
|
|
519
|
+
if (s !== undefined && (newest === undefined || s.mtimeMs > newest.mtimeMs))
|
|
520
|
+
newest = { file: path, mtimeMs: s.mtimeMs }
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
await walk(dir)
|
|
524
|
+
return newest
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Find workspace-linked dependencies whose `default`-condition artifact (`dist/…`) has fallen behind
|
|
529
|
+
* their `bun`-condition source (`src/…`).
|
|
530
|
+
*
|
|
531
|
+
* The split export map is what makes this a trap: Bun (the build, the tests, the prod binary) reads
|
|
532
|
+
* live source, while Vite's SSR runner and any node consumer read a build artifact nothing in the dev
|
|
533
|
+
* loop regenerates. `dist/` is gitignored, so the drift never shows in a diff - it surfaces as a 500
|
|
534
|
+
* INSIDE framework/shared-package code and reads like an upstream regression. npm-installed copies are
|
|
535
|
+
* immutable and skipped; only symlinked (workspace) installs can drift.
|
|
536
|
+
*/
|
|
537
|
+
export async function collectStaleWorkspaceDists(
|
|
538
|
+
cwd: string,
|
|
539
|
+
rootPackage: Record<string, unknown>,
|
|
540
|
+
): Promise<StaleDistFinding[]> {
|
|
541
|
+
const findings: StaleDistFinding[] = []
|
|
542
|
+
const seen = new Set<string>()
|
|
543
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
544
|
+
for (const name of depNames(rootPackage, field)) {
|
|
545
|
+
if (seen.has(name)) continue
|
|
546
|
+
seen.add(name)
|
|
547
|
+
// Boundary "" - never matches a real dir, so the upward walk runs to the filesystem root and
|
|
548
|
+
// finds a hoisted copy wherever the install put it.
|
|
549
|
+
const copy = await resolvedInstalledCopy(cwd, "", name)
|
|
550
|
+
// A real (tarball) install lives under node_modules and cannot drift; only a symlink out of the
|
|
551
|
+
// tree - a workspace/link install whose realpath escapes node_modules - has a live `src`.
|
|
552
|
+
if (copy === undefined || copy.path.includes(`${sep}node_modules${sep}`)) continue
|
|
553
|
+
const meta = await readJson(join(copy.path, "package.json"))
|
|
554
|
+
const exportsMap = meta?.exports
|
|
555
|
+
if (!isRecord(exportsMap)) continue
|
|
556
|
+
// Every subpath with a bun→src / default→dist split; the root "." alone would miss the common
|
|
557
|
+
// "deep subpaths only" layout.
|
|
558
|
+
const pairs: Array<{ readonly bun: string; readonly dist: string }> = []
|
|
559
|
+
const entries = Object.keys(exportsMap).some((k) => k.startsWith("."))
|
|
560
|
+
? Object.entries(exportsMap)
|
|
561
|
+
: ([[".", exportsMap]] as Array<[string, unknown]>)
|
|
562
|
+
for (const [, entry] of entries) {
|
|
563
|
+
const bun = exportTarget(entry, "bun")
|
|
564
|
+
const dist = exportTarget(entry, "default")
|
|
565
|
+
if (bun !== undefined && dist !== undefined && bun !== dist) pairs.push({ bun, dist })
|
|
566
|
+
}
|
|
567
|
+
if (pairs.length === 0) continue
|
|
568
|
+
// One newest-source probe per package: the bun targets share a source tree (its top directory).
|
|
569
|
+
const sourceDirs = new Set(
|
|
570
|
+
pairs
|
|
571
|
+
.map((p) => p.bun.replace(/^\.\//, "").split("/")[0])
|
|
572
|
+
.filter((d): d is string => d !== undefined && d.length > 0),
|
|
573
|
+
)
|
|
574
|
+
let newest: { file: string; mtimeMs: number } | undefined
|
|
575
|
+
for (const dir of sourceDirs) {
|
|
576
|
+
const candidate = await newestFileUnder(join(copy.path, dir))
|
|
577
|
+
if (candidate !== undefined && (newest === undefined || candidate.mtimeMs > newest.mtimeMs))
|
|
578
|
+
newest = candidate
|
|
579
|
+
}
|
|
580
|
+
if (newest === undefined) continue
|
|
581
|
+
// Report the package once, anchored on its stalest (or missing) artifact.
|
|
582
|
+
let worst: StaleDistFinding | undefined
|
|
583
|
+
for (const pair of pairs) {
|
|
584
|
+
const distPath = join(copy.path, pair.dist.replace(/^\.\//, ""))
|
|
585
|
+
const distStat = await stat(distPath).catch(() => undefined)
|
|
586
|
+
const finding: StaleDistFinding | undefined =
|
|
587
|
+
distStat === undefined
|
|
588
|
+
? {
|
|
589
|
+
package: name,
|
|
590
|
+
distFile: displayPath(cwd, distPath),
|
|
591
|
+
missing: true,
|
|
592
|
+
sourceFile: displayPath(cwd, newest.file),
|
|
593
|
+
behindSeconds: 0,
|
|
594
|
+
}
|
|
595
|
+
: distStat.mtimeMs < newest.mtimeMs
|
|
596
|
+
? {
|
|
597
|
+
package: name,
|
|
598
|
+
distFile: displayPath(cwd, distPath),
|
|
599
|
+
missing: false,
|
|
600
|
+
sourceFile: displayPath(cwd, newest.file),
|
|
601
|
+
behindSeconds: Math.round((newest.mtimeMs - distStat.mtimeMs) / 1000),
|
|
602
|
+
}
|
|
603
|
+
: undefined
|
|
604
|
+
if (finding === undefined) continue
|
|
605
|
+
if (
|
|
606
|
+
worst === undefined ||
|
|
607
|
+
(finding.missing && !worst.missing) ||
|
|
608
|
+
finding.behindSeconds > worst.behindSeconds
|
|
609
|
+
)
|
|
610
|
+
worst = finding
|
|
611
|
+
}
|
|
612
|
+
if (worst !== undefined) findings.push(worst)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return findings.sort((a, b) => a.package.localeCompare(b.package))
|
|
616
|
+
}
|
|
617
|
+
|
|
463
618
|
function depRecord(
|
|
464
619
|
pkg: Record<string, unknown>,
|
|
465
620
|
field: string,
|
|
@@ -518,7 +673,8 @@ async function inferDependencyFix(
|
|
|
518
673
|
/** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
|
|
519
674
|
export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
520
675
|
const pkg = await readJson(join(cwd, "package.json"))
|
|
521
|
-
if (pkg === undefined)
|
|
676
|
+
if (pkg === undefined)
|
|
677
|
+
return { ok: true, ran: false, findings: [], duplicateInstalls: [], staleDists: [] }
|
|
522
678
|
|
|
523
679
|
const scopes = await doctorPackageScopes(cwd, pkg)
|
|
524
680
|
|
|
@@ -531,11 +687,14 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
|
531
687
|
})
|
|
532
688
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
|
|
533
689
|
const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
|
|
690
|
+
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
691
|
+
// `staleDists` is advisory (see DoctorResult): it never fails `ok`.
|
|
534
692
|
return {
|
|
535
693
|
ok: findings.length === 0 && duplicateInstalls.length === 0,
|
|
536
694
|
ran: true,
|
|
537
695
|
findings,
|
|
538
696
|
duplicateInstalls,
|
|
697
|
+
staleDists,
|
|
539
698
|
}
|
|
540
699
|
}
|
|
541
700
|
|
|
@@ -639,6 +798,22 @@ export async function runDoctor(
|
|
|
639
798
|
}
|
|
640
799
|
console.log("")
|
|
641
800
|
}
|
|
801
|
+
// Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
|
|
802
|
+
// it costs an hour of misdiagnosis when the dev server 500s inside the package.
|
|
803
|
+
if (result.staleDists.length > 0) {
|
|
804
|
+
console.log(
|
|
805
|
+
`⚠ ${result.staleDists.length} workspace-linked package(s) with a build artifact older than their source (Bun reads src, Vite SSR/node consumers read dist):\n`,
|
|
806
|
+
)
|
|
807
|
+
for (const f of result.staleDists) {
|
|
808
|
+
console.log(
|
|
809
|
+
f.missing
|
|
810
|
+
? ` ${f.package} - ${f.distFile} was never built`
|
|
811
|
+
: ` ${f.package} - ${f.distFile} is ${f.behindSeconds}s behind ${f.sourceFile}`,
|
|
812
|
+
)
|
|
813
|
+
console.log(` rebuild ${f.package}`)
|
|
814
|
+
}
|
|
815
|
+
console.log("")
|
|
816
|
+
}
|
|
642
817
|
if (result.ok) {
|
|
643
818
|
console.log(
|
|
644
819
|
"✓ every imported package is declared and identity-sensitive installs are deduplicated",
|
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.5.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 =>
|
|
@@ -63,7 +63,12 @@ export function respondMcpHttp(
|
|
|
63
63
|
* resource so MCP Apps hosts can render `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */
|
|
64
64
|
export function handleMcpHttp(request: Request): Promise<Response> {
|
|
65
65
|
return respondMcpHttp(request, publicDocsTools(), {
|
|
66
|
-
features: {
|
|
66
|
+
features: {
|
|
67
|
+
resources: [examplesWidget.resource],
|
|
68
|
+
ui: { mimeTypes: [UI_MIME] },
|
|
69
|
+
instructions:
|
|
70
|
+
"nifra's own documentation, runnable examples, and API types - for building with the nifra framework. Call nifra_docs / nifra_example / nifra_types / nifra_learn to learn it without a local checkout.",
|
|
71
|
+
},
|
|
67
72
|
})
|
|
68
73
|
}
|
|
69
74
|
|