@nifrajs/cli 2.3.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 +28 -2
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -4
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +27 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +158 -15
- package/dist/doctor.js.map +1 -1
- package/dist/learn.d.ts +32 -0
- package/dist/learn.d.ts.map +1 -0
- package/dist/learn.js +127 -0
- package/dist/learn.js.map +1 -0
- package/dist/mcp-app-tool.d.ts +14 -0
- package/dist/mcp-app-tool.d.ts.map +1 -0
- package/dist/mcp-app-tool.js +80 -0
- package/dist/mcp-app-tool.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +17 -1
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +10 -6
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +30 -12
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +14 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +188 -0
- package/dist/mcp.js.map +1 -1
- package/dist/upgrade.d.ts +13 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +105 -7
- package/dist/upgrade.js.map +1 -1
- package/docs/llms-full.txt +104 -2
- package/docs/types.json +202 -26
- package/package.json +9 -9
- package/src/check.ts +30 -2
- package/src/cli.ts +21 -4
- package/src/doctor.ts +186 -3
- package/src/learn.ts +152 -0
- package/src/mcp-app-tool.ts +86 -0
- package/src/mcp-docs-tools.ts +18 -1
- package/src/mcp-http.ts +32 -14
- package/src/mcp.ts +237 -0
- package/src/upgrade.ts +125 -7
package/src/cli.ts
CHANGED
|
@@ -89,9 +89,12 @@ Usage:
|
|
|
89
89
|
nifra_run (backend), nifra_render (SSR a page), nifra_docs,
|
|
90
90
|
nifra_example (verified snippets), nifra_scaffold (route→file),
|
|
91
91
|
nifra_check (drift gate + fixes), nifra_levels (verification
|
|
92
|
-
ladder), nifra_doctor (deps
|
|
92
|
+
ladder), nifra_doctor (deps), nifra_explain (structured errors),
|
|
93
|
+
nifra_inspect (request traces), nifra_learn (guided build path).
|
|
93
94
|
nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) -
|
|
94
95
|
self-host on a VPS so any remote agent can learn nifra. Default :8787.
|
|
96
|
+
nifra learn [<step>] Print the guided build-an-app path (the human view of nifra_learn):
|
|
97
|
+
no arg for the step index, a number for one step's goal/do/verify.
|
|
95
98
|
nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
|
|
96
99
|
server-only imports in routes/). Run as "done"; --json for agents;
|
|
97
100
|
--lints-only skips tsc for a near-instant inner-loop pass.
|
|
@@ -133,9 +136,10 @@ Usage:
|
|
|
133
136
|
nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
|
|
134
137
|
[--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
|
|
135
138
|
[--list] [--json] packages, apply exact imports, then verify with nifra check.
|
|
136
|
-
|
|
139
|
+
[--allow-downgrade] Dry-run by default; --write applies then verifies (--no-verify
|
|
137
140
|
to skip). --list shows available targets. Fail-closed on an
|
|
138
|
-
unknown version
|
|
141
|
+
unknown version or a rollback (--allow-downgrade overrides).
|
|
142
|
+
Deterministic + idempotent.
|
|
139
143
|
nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
|
|
140
144
|
[--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
|
|
141
145
|
globals, node: builtins) with file:line evidence. --target auto-
|
|
@@ -150,7 +154,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
150
154
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
151
155
|
|
|
152
156
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
153
|
-
const CLI_VERSION = "2.
|
|
157
|
+
const CLI_VERSION = "2.5.0"
|
|
154
158
|
|
|
155
159
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
156
160
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -532,6 +536,18 @@ async function main(): Promise<void> {
|
|
|
532
536
|
console.log(`nifra docs MCP (HTTP) → ${server.url}`)
|
|
533
537
|
return
|
|
534
538
|
}
|
|
539
|
+
// `learn` prints the guided build-an-app path (the human head of `nifra_learn`). Project-independent:
|
|
540
|
+
// the path is static, so no project needs to load. `nifra learn` for the index, `nifra learn 3` for a step.
|
|
541
|
+
if (command === "learn") {
|
|
542
|
+
const { renderLearnResult } = await import("./learn.ts")
|
|
543
|
+
const stepArg = argv[1]
|
|
544
|
+
console.log(
|
|
545
|
+
renderLearnResult(
|
|
546
|
+
stepArg !== undefined && /^\d+$/.test(stepArg) ? Number(stepArg) : undefined,
|
|
547
|
+
),
|
|
548
|
+
)
|
|
549
|
+
return
|
|
550
|
+
}
|
|
535
551
|
// `check` is a pure cwd-based gate (typecheck + lint) - it must run even when the project doesn't
|
|
536
552
|
// load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
|
|
537
553
|
if (command === "check") {
|
|
@@ -768,6 +784,7 @@ async function main(): Promise<void> {
|
|
|
768
784
|
json: argv.includes("--json"),
|
|
769
785
|
list: argv.includes("--list"),
|
|
770
786
|
verify: !argv.includes("--no-verify"),
|
|
787
|
+
allowDowngrade: argv.includes("--allow-downgrade"),
|
|
771
788
|
})
|
|
772
789
|
if (!ok) process.exitCode = 1
|
|
773
790
|
} catch (err) {
|
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",
|
|
@@ -668,6 +843,14 @@ export async function runDoctor(
|
|
|
668
843
|
for (const copy of finding.copies) {
|
|
669
844
|
console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
|
|
670
845
|
}
|
|
846
|
+
if (finding.package === "@nifrajs/core") {
|
|
847
|
+
console.log(
|
|
848
|
+
" ↳ a second @nifrajs/core breaks module identity: `typeof backend` collapses to `any`",
|
|
849
|
+
)
|
|
850
|
+
console.log(
|
|
851
|
+
" at `.merge()` and route-type inference silently degrades - usually that error's root cause.",
|
|
852
|
+
)
|
|
853
|
+
}
|
|
671
854
|
}
|
|
672
855
|
console.log(
|
|
673
856
|
"\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
|
package/src/learn.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided learn path - a SEQUENCE for building a nifra app end to end, as opposed to the random-access
|
|
3
|
+
* search of `nifra_docs`/`nifra_example`. It is deliberately thin on embedded code: each step points at the
|
|
4
|
+
* tool that produces the CORRECT, verified artifact (`nifra_scaffold` for a route, `nifra_example` for a
|
|
5
|
+
* verified snippet, `nifra_run`/`nifra_render` to see it work), so an agent walks the path by composing the
|
|
6
|
+
* existing tools rather than pasting code that can drift from the installed version.
|
|
7
|
+
*
|
|
8
|
+
* One source, two heads: `nifra_learn` (MCP) serves it to an agent as structured steps; `nifra learn` (CLI)
|
|
9
|
+
* prints the same path for a human. Add or reorder steps here and both update.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface LearnStep {
|
|
13
|
+
/** Stable slug, e.g. `page-route`. */
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly title: string
|
|
16
|
+
/** What you will have once this step is done. */
|
|
17
|
+
readonly goal: string
|
|
18
|
+
/** How to do it - prefer directing to the tool that emits the correct artifact over inline code. */
|
|
19
|
+
readonly do: string
|
|
20
|
+
/** How to confirm it worked. */
|
|
21
|
+
readonly verify: string
|
|
22
|
+
/** The `nifra_*` tools that carry out this step. */
|
|
23
|
+
readonly tools: readonly string[]
|
|
24
|
+
/** A `nifra_docs`/`nifra_example` query for going deeper. */
|
|
25
|
+
readonly seeAlso?: string | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const LEARN_PATH: readonly LearnStep[] = [
|
|
29
|
+
{
|
|
30
|
+
id: "create-app",
|
|
31
|
+
title: "Create the app",
|
|
32
|
+
goal: "A running nifra dev server with one page route.",
|
|
33
|
+
do: "Scaffold a new project with `npm create nifra@latest`, pick your framework adapter (React/Solid/Vue/Svelte/Preact), then `npm run dev`.",
|
|
34
|
+
verify: "The dev server prints a localhost URL and the default page renders.",
|
|
35
|
+
tools: ["nifra_context"],
|
|
36
|
+
seeAlso: "project structure routes backend",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "page-route",
|
|
40
|
+
title: "Add a page route",
|
|
41
|
+
goal: "A second page, server-rendered, reachable by URL.",
|
|
42
|
+
do: "Ask nifra_scaffold for the path (e.g. /about) - it returns the exact `routes/` file and a contract-correct page stub. File-based: `routes/about.tsx` serves `/about`.",
|
|
43
|
+
verify: "nifra_render /about returns the rendered HTML.",
|
|
44
|
+
tools: ["nifra_scaffold", "nifra_render"],
|
|
45
|
+
seeAlso: "page routes file-based routing layouts",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "loader",
|
|
49
|
+
title: "Load data for a page",
|
|
50
|
+
goal: "A page whose data is fetched on the server before render.",
|
|
51
|
+
do: "Export a `loader` from the route and read its result in the component. Get the exact shape from nifra_example ('loader').",
|
|
52
|
+
verify: "nifra_render the page and confirm the loaded data is in the HTML.",
|
|
53
|
+
tools: ["nifra_example", "nifra_render"],
|
|
54
|
+
seeAlso: "loader server data page",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "api-route",
|
|
58
|
+
title: "Add a typed API route",
|
|
59
|
+
goal: "A backend endpoint with a typed request/response contract.",
|
|
60
|
+
do: "Add the route to `backend.ts` with an @nifrajs/schema body/query/response schema. nifra_context shows the resulting contract and the exact typed-client call form.",
|
|
61
|
+
verify:
|
|
62
|
+
"nifra_run { method, path, body } and check the status + parsed body; nifra_check confirms the contract typechecks.",
|
|
63
|
+
tools: ["nifra_context", "nifra_run", "nifra_check"],
|
|
64
|
+
seeAlso: "api route schema validation backend",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "typed-client",
|
|
68
|
+
title: "Call the API, fully typed",
|
|
69
|
+
goal: "The page calls the API through the compile-time typed client - no hand-written client twin.",
|
|
70
|
+
do: "Use @nifrajs/client against the backend type. The call form is exactly what nifra_context prints for that route; a drifted call is a compile error, not a runtime surprise.",
|
|
71
|
+
verify: "nifra_check passes; nifra_run the page path and see the data flow end to end.",
|
|
72
|
+
tools: ["nifra_context", "nifra_check", "nifra_run"],
|
|
73
|
+
seeAlso: "typed client treaty end-to-end types",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "protect",
|
|
77
|
+
title: "Protect a route",
|
|
78
|
+
goal: "A route that requires an authenticated session.",
|
|
79
|
+
do: "Wire @nifrajs/auth (or @nifrajs/better-auth) and gate the route. nifra_example ('protected route') has the verified pattern; nifra_assure reports whether the gate is actually enforced.",
|
|
80
|
+
verify:
|
|
81
|
+
"nifra_run the route unauthenticated (expect 401/redirect) and authenticated (expect 200); nifra_assure shows the enforcement evidence.",
|
|
82
|
+
tools: ["nifra_example", "nifra_assure", "nifra_run"],
|
|
83
|
+
seeAlso: "auth session protected route better-auth",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "background-work",
|
|
87
|
+
title: "Do work in the background",
|
|
88
|
+
goal: "A request that enqueues a job instead of blocking on slow work.",
|
|
89
|
+
do: "Define a job with @nifrajs/jobs and enqueue it from a route. nifra_example ('job') has the shape; nifra_inspect shows the enqueue landing on the dev server.",
|
|
90
|
+
verify:
|
|
91
|
+
"nifra_run the route, then nifra_inspect the dev server to see the request completed fast and the effect fired.",
|
|
92
|
+
tools: ["nifra_example", "nifra_run", "nifra_inspect"],
|
|
93
|
+
seeAlso: "jobs queue background enqueue",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "ship",
|
|
97
|
+
title: "Build and deploy",
|
|
98
|
+
goal: "A production build served by your chosen runtime.",
|
|
99
|
+
do: "Pick an adapter (@nifrajs/node, @nifrajs/workers, @nifrajs/deno, or a static/Vercel target) and run the production build. nifra_levels shows what the app already proves before you ship.",
|
|
100
|
+
verify:
|
|
101
|
+
"The build completes and the artifact serves the app; nifra_check + nifra_levels are green.",
|
|
102
|
+
tools: ["nifra_levels", "nifra_check"],
|
|
103
|
+
seeAlso: "deploy build adapters node workers deno",
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
/** The compact index: numbered steps with their goal, plus how to drill into one. */
|
|
108
|
+
function renderIndex(): string {
|
|
109
|
+
const lines = LEARN_PATH.map((step, i) => `${i + 1}. ${step.title} - ${step.goal} [${step.id}]`)
|
|
110
|
+
return [
|
|
111
|
+
`nifra learn path (${LEARN_PATH.length} steps). Call nifra_learn with step: N for the detail of step N.`,
|
|
112
|
+
"",
|
|
113
|
+
...lines,
|
|
114
|
+
].join("\n")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One step's full detail, with a pointer to the next. */
|
|
118
|
+
function renderStep(step: LearnStep, index: number): string {
|
|
119
|
+
const parts = [
|
|
120
|
+
`Step ${index + 1}/${LEARN_PATH.length}: ${step.title} [${step.id}]`,
|
|
121
|
+
"",
|
|
122
|
+
`Goal: ${step.goal}`,
|
|
123
|
+
`Do: ${step.do}`,
|
|
124
|
+
`Verify: ${step.verify}`,
|
|
125
|
+
`Tools: ${step.tools.join(", ")}`,
|
|
126
|
+
]
|
|
127
|
+
if (step.seeAlso !== undefined) {
|
|
128
|
+
parts.push(`See: nifra_docs / nifra_example "${step.seeAlso}"`)
|
|
129
|
+
}
|
|
130
|
+
const next = LEARN_PATH[index + 1]
|
|
131
|
+
parts.push(
|
|
132
|
+
"",
|
|
133
|
+
next !== undefined
|
|
134
|
+
? `Next: step ${index + 2} (${next.title})`
|
|
135
|
+
: "Done - you have shipped a nifra app.",
|
|
136
|
+
)
|
|
137
|
+
return parts.join("\n")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Render the learn path. No `step` (or an out-of-range one) returns the index; a valid 1-based `step`
|
|
142
|
+
* returns that step's detail. Kept pure so `nifra_learn` and `nifra learn` render identically.
|
|
143
|
+
*/
|
|
144
|
+
export function renderLearnResult(step?: number): string {
|
|
145
|
+
if (step === undefined) return renderIndex()
|
|
146
|
+
const index = step - 1
|
|
147
|
+
const found = LEARN_PATH[index]
|
|
148
|
+
if (found === undefined) {
|
|
149
|
+
return `No step ${step}. The path has ${LEARN_PATH.length} steps.\n\n${renderIndex()}`
|
|
150
|
+
}
|
|
151
|
+
return renderStep(found, index)
|
|
152
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `nifra_examples_app` MCP tool: nifra's verified code examples as an MCP Apps widget. It returns the
|
|
3
|
+
* examples as `structuredContent`, rendered by the {@link examplesWidget} `ui://` widget in MCP Apps hosts
|
|
4
|
+
* (ChatGPT Apps, MCPJam, Goose); text-only hosts still get the example names as plain text. Part of the
|
|
5
|
+
* public docs-MCP tool set (served by `handleMcpHttp`), so every self-host gets it from one definition.
|
|
6
|
+
* `loadExamples` is injected, so the same tool reads the corpus from disk (CLI) or a cached fetch (edge).
|
|
7
|
+
*/
|
|
8
|
+
import { defineMcpTool, defineMcpWidget, type McpTool } from "@nifrajs/mcp"
|
|
9
|
+
import type { Example } from "./examples.ts"
|
|
10
|
+
|
|
11
|
+
/** The widget: renders `structuredContent.examples` as a filterable list of example cards. */
|
|
12
|
+
export const examplesWidget = defineMcpWidget({
|
|
13
|
+
uri: "ui://nifra/examples",
|
|
14
|
+
name: "nifra examples",
|
|
15
|
+
description: "Interactive browser of verified, copy-pasteable nifra code examples.",
|
|
16
|
+
head: `<style>
|
|
17
|
+
:root { color-scheme: light dark; font: 14px/1.5 system-ui, sans-serif; }
|
|
18
|
+
body { margin: 0; padding: 16px; }
|
|
19
|
+
h1 { font-size: 16px; margin: 0 0 4px; }
|
|
20
|
+
#q { width: 100%; box-sizing: border-box; padding: 6px 10px; margin: 8px 0 14px; border-radius: 6px; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); background: transparent; color: inherit; font: inherit; }
|
|
21
|
+
.card { border: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; }
|
|
22
|
+
.topic { font-size: 12px; opacity: .6; }
|
|
23
|
+
.name { font-weight: 600; margin: 2px 0 6px; }
|
|
24
|
+
pre { margin: 0; padding: 8px 10px; border-radius: 6px; background: color-mix(in srgb, currentColor 8%, transparent); overflow-x: auto; font: 12px/1.5 ui-monospace, monospace; }
|
|
25
|
+
</style>`,
|
|
26
|
+
html: `
|
|
27
|
+
<h1>nifra examples</h1>
|
|
28
|
+
<input id="q" placeholder="Filter examples…" />
|
|
29
|
+
<div id="list"></div>
|
|
30
|
+
<script>
|
|
31
|
+
var ALL = [];
|
|
32
|
+
function esc(s){ return String(s).replace(/[&<>]/g, function(c){ return c==="&"?"&":c==="<"?"<":">"; }); }
|
|
33
|
+
function paint(items){
|
|
34
|
+
document.getElementById("list").innerHTML = items.map(function(e){
|
|
35
|
+
var code = e.code.length > 600 ? e.code.slice(0, 600) + "\\n…" : e.code;
|
|
36
|
+
return "<div class='card'><div class='topic'>" + esc(e.topic) + "</div><div class='name'>" + esc(e.name) +
|
|
37
|
+
"</div><pre>" + esc(code) + "</pre></div>";
|
|
38
|
+
}).join("") || "<p style='opacity:.6'>No matching examples.</p>";
|
|
39
|
+
}
|
|
40
|
+
function render(data){ ALL = (data && data.examples) || []; paint(ALL); }
|
|
41
|
+
mcpApp.onData(render);
|
|
42
|
+
document.getElementById("q").addEventListener("input", function(ev){
|
|
43
|
+
var q = ev.target.value.toLowerCase();
|
|
44
|
+
paint(ALL.filter(function(e){ return (e.topic + e.name + e.slug).toLowerCase().indexOf(q) !== -1; }));
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
`,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** The widget-backed tool. `loadExamples` is injected (disk on the CLI, cached fetch on the edge). */
|
|
51
|
+
export function examplesAppTool(loadExamples: () => Promise<Example[] | undefined>): McpTool {
|
|
52
|
+
return defineMcpTool({
|
|
53
|
+
name: "nifra_examples_app",
|
|
54
|
+
description:
|
|
55
|
+
"Browse nifra's verified code examples as an interactive, filterable list (MCP Apps widget). Pass query to pre-filter; the widget also filters client-side.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: { query: { type: "string", description: "Pre-filter examples by keyword." } },
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
widget: examplesWidget,
|
|
62
|
+
handler: async (args) => {
|
|
63
|
+
const all = (await loadExamples()) ?? []
|
|
64
|
+
const q = ((args.query as string) ?? "").toLowerCase()
|
|
65
|
+
const matched = (
|
|
66
|
+
q
|
|
67
|
+
? all.filter((e) => `${e.topic} ${e.name} ${e.slug} ${e.code}`.toLowerCase().includes(q))
|
|
68
|
+
: all
|
|
69
|
+
).slice(0, 30)
|
|
70
|
+
return {
|
|
71
|
+
text:
|
|
72
|
+
matched.length === 0
|
|
73
|
+
? "No matching examples."
|
|
74
|
+
: `${matched.length} example(s): ${matched.map((e) => e.name).join(", ")}.`,
|
|
75
|
+
structuredContent: {
|
|
76
|
+
examples: matched.map((e) => ({
|
|
77
|
+
name: e.name,
|
|
78
|
+
topic: e.topic,
|
|
79
|
+
slug: e.slug,
|
|
80
|
+
code: e.code,
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
package/src/mcp-docs-tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The project-INDEPENDENT MCP tools - `nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` - as a single factory shared
|
|
3
3
|
* by every transport: the project stdio server ({@link projectTools}), the CLI HTTP server
|
|
4
4
|
* (`nifra docs-mcp`), and the site's Cloudflare-Pages worker (`/mcp`). The corpus source is injected as
|
|
5
5
|
* loaders, so the SAME tool definitions work whether the corpus comes from disk (`Bun.file`, on the
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { renderDocsResult } from "./docs-search.ts"
|
|
11
11
|
import { type Example, renderExamplesResult } from "./examples.ts"
|
|
12
|
+
import { renderLearnResult } from "./learn.ts"
|
|
12
13
|
import type { McpTool } from "./mcp-protocol.ts"
|
|
13
14
|
import { renderTypesResult, type TypeEntry } from "./types-search.ts"
|
|
14
15
|
|
|
@@ -113,5 +114,21 @@ export function docsTools(
|
|
|
113
114
|
return renderTypesResult(corpus, name, query, clamp(limit, 1, 8, 5), full === true)
|
|
114
115
|
},
|
|
115
116
|
},
|
|
117
|
+
{
|
|
118
|
+
name: "nifra_learn",
|
|
119
|
+
description:
|
|
120
|
+
"Walk the guided path to build a nifra app end to end - an ORDERED sequence (create → page route → loader → typed API → typed client → auth → background jobs → deploy), not the random-access search of nifra_docs/nifra_example. Call with no args for the step index; pass `step: N` for that step's goal, how to do it (which tool emits the correct artifact), and how to verify it. Use it when scaffolding a new app or learning nifra's flow - each step composes the other nifra_* tools.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
step: {
|
|
125
|
+
type: "number",
|
|
126
|
+
description: "1-based step number to expand (omit for the index).",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
},
|
|
131
|
+
handler: async (args) => renderLearnResult((args as { step?: number }).step),
|
|
132
|
+
},
|
|
116
133
|
]
|
|
117
134
|
}
|
package/src/mcp-http.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The PUBLIC nifra docs MCP - Streamable-HTTP transport exposing the project-INDEPENDENT tools
|
|
3
|
-
* (`nifra_docs`
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `nifra_examples_app` MCP Apps widget)
|
|
4
|
+
* so any remote AI agent can learn nifra without a local checkout. They read the bundled corpus
|
|
5
|
+
* (llms-full.txt / examples.json / types.json shipped in this package) and ignore `cwd`, so they reuse the
|
|
6
|
+
* exact definitions from {@link projectTools} - one source, no drift.
|
|
6
7
|
*
|
|
7
8
|
* The transport itself (body cap, CORS, JSON-RPC dispatch) lives in `@nifrajs/mcp/http`; this module is
|
|
8
9
|
* the docs-specific layer over it: it supplies the bundled corpus tools and the `nifra-docs` server info.
|
|
@@ -12,28 +13,37 @@
|
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { type McpHttpOptions, respondMcpHttp as respondMcpHttpCore } from "@nifrajs/mcp/http"
|
|
15
|
-
import type
|
|
16
|
+
import { type McpTool, UI_MIME } from "@nifrajs/mcp/protocol"
|
|
16
17
|
import { loadDocsCorpus } from "./docs-search.ts"
|
|
17
18
|
import { loadExamplesCorpus } from "./examples.ts"
|
|
19
|
+
import { examplesAppTool, examplesWidget } from "./mcp-app-tool.ts"
|
|
18
20
|
import { docsTools } from "./mcp-docs-tools.ts"
|
|
19
21
|
import { loadTypesCorpus } from "./types-search.ts"
|
|
20
22
|
|
|
21
23
|
export type { McpHttpOptions } from "@nifrajs/mcp/http"
|
|
22
24
|
export type { Example } from "./examples.ts"
|
|
23
|
-
// Re-exported so a self-host
|
|
24
|
-
//
|
|
25
|
+
// Re-exported so a self-host gets the corpus-injectable tool factories + the transport core from one
|
|
26
|
+
// entry: `import { respondMcpHttp, docsTools, examplesAppTool } from "@nifrajs/cli/mcp"`.
|
|
27
|
+
export { examplesAppTool, examplesWidget } from "./mcp-app-tool.ts"
|
|
25
28
|
export { docsTools } from "./mcp-docs-tools.ts"
|
|
26
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
27
30
|
|
|
28
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
29
|
-
const VERSION = "2.
|
|
32
|
+
const VERSION = "2.5.0"
|
|
30
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
|
+
const docsHealth = (tools: McpTool[]): string =>
|
|
36
|
+
`nifra docs MCP - POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: ${tools
|
|
37
|
+
.map((tool) => tool.name)
|
|
38
|
+
.join(", ")}.`
|
|
33
39
|
|
|
34
|
-
/** The
|
|
40
|
+
/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text
|
|
41
|
+
* docs tools plus the `nifra_examples_app` MCP Apps widget tool. */
|
|
35
42
|
export function publicDocsTools(): McpTool[] {
|
|
36
|
-
return
|
|
43
|
+
return [
|
|
44
|
+
...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
|
|
45
|
+
examplesAppTool(loadExamplesCorpus),
|
|
46
|
+
]
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/**
|
|
@@ -46,12 +56,20 @@ export function respondMcpHttp(
|
|
|
46
56
|
tools: McpTool[],
|
|
47
57
|
options: McpHttpOptions = {},
|
|
48
58
|
): Promise<Response> {
|
|
49
|
-
return respondMcpHttpCore(request, tools, SERVER_INFO, { health:
|
|
59
|
+
return respondMcpHttpCore(request, tools, SERVER_INFO, { health: docsHealth(tools), ...options })
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
/** The CLI HTTP handler: serves the disk-backed corpus tools
|
|
62
|
+
/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`
|
|
63
|
+
* resource so MCP Apps hosts can render `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */
|
|
53
64
|
export function handleMcpHttp(request: Request): Promise<Response> {
|
|
54
|
-
return respondMcpHttp(request, publicDocsTools()
|
|
65
|
+
return respondMcpHttp(request, publicDocsTools(), {
|
|
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
|
+
},
|
|
72
|
+
})
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
/**
|