@nifrajs/cli 2.14.1 → 3.1.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.map +1 -1
- package/dist/check.js +27 -7
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -2
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +6 -0
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +20 -2
- package/dist/command-catalog.js.map +1 -1
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -7
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +68 -6
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +17 -30
- package/dist/rules/routes.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +933 -46
- package/package.json +9 -9
- package/src/check.ts +28 -7
- package/src/cli.ts +41 -2
- package/src/command-catalog.ts +22 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +81 -6
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/routes.ts +17 -30
- package/src/workspace-link.ts +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.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",
|
|
@@ -66,14 +66,14 @@
|
|
|
66
66
|
"access": "public"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@nifrajs/client": "
|
|
70
|
-
"@nifrajs/core": "
|
|
71
|
-
"@nifrajs/mcp": "
|
|
72
|
-
"@nifrajs/runner": "
|
|
73
|
-
"@nifrajs/schema": "
|
|
74
|
-
"@nifrajs/testing": "
|
|
75
|
-
"@nifrajs/web": "
|
|
76
|
-
"create-nifra": "
|
|
69
|
+
"@nifrajs/client": "3.1.0",
|
|
70
|
+
"@nifrajs/core": "3.1.0",
|
|
71
|
+
"@nifrajs/mcp": "3.1.0",
|
|
72
|
+
"@nifrajs/runner": "3.1.0",
|
|
73
|
+
"@nifrajs/schema": "3.1.0",
|
|
74
|
+
"@nifrajs/testing": "3.1.0",
|
|
75
|
+
"@nifrajs/web": "3.1.0",
|
|
76
|
+
"create-nifra": "3.1.0"
|
|
77
77
|
},
|
|
78
78
|
"repository": {
|
|
79
79
|
"type": "git",
|
package/src/check.ts
CHANGED
|
@@ -2585,7 +2585,7 @@ export async function collectCheckResult(
|
|
|
2585
2585
|
diagnostics.push({
|
|
2586
2586
|
rule: "duplicate-install",
|
|
2587
2587
|
severity: "error",
|
|
2588
|
-
message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
|
|
2588
|
+
message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}${finding.topology === undefined ? "" : `. Topology: ${finding.topology}`}${finding.scope === undefined ? "" : `. Scope: ${finding.scope}`}`,
|
|
2589
2589
|
fix: finding.remediation,
|
|
2590
2590
|
suggestion: {
|
|
2591
2591
|
kind: "manual",
|
|
@@ -2593,29 +2593,50 @@ export async function collectCheckResult(
|
|
|
2593
2593
|
steps: [
|
|
2594
2594
|
"Align workspace dependency and peer ranges on one compatible version.",
|
|
2595
2595
|
"Remove stale nested installs and run the package manager from the workspace root.",
|
|
2596
|
-
|
|
2596
|
+
'If the copies come from a linked sibling repository and collapsing them is not an option, declare the package: "nifra": { "singleCopy": ["<package>"] } in package.json, plus the bunfig.toml preload.',
|
|
2597
|
+
"Run `nifra doctor` again; do not suppress an undeclared same-version duplicate path.",
|
|
2597
2598
|
],
|
|
2598
2599
|
},
|
|
2599
2600
|
})
|
|
2600
2601
|
}
|
|
2602
|
+
// Declared single-copy: the resolver collapses these before anything loads, so the build is sound
|
|
2603
|
+
// and the gate stays green. It is still worth a line - the guarantee lives in a declaration now,
|
|
2604
|
+
// and a reader who deletes it gets a null hook dispatcher with no obvious cause.
|
|
2605
|
+
for (const finding of dr.deduplicatedInstalls ?? []) {
|
|
2606
|
+
const copies = finding.copies.map((copy) => copy.path).join("; ")
|
|
2607
|
+
diagnostics.push({
|
|
2608
|
+
rule: "duplicate-install",
|
|
2609
|
+
severity: "warning",
|
|
2610
|
+
message: `${finding.package} is installed at ${finding.copies.length} paths (${copies}) and is declared single-copy - nifra resolves every duplicate to this app's copy`,
|
|
2611
|
+
fix: finding.remediation,
|
|
2612
|
+
})
|
|
2613
|
+
}
|
|
2601
2614
|
// Advisory (never fails the gate): while actively editing a linked package its dist is always
|
|
2602
2615
|
// momentarily behind. The finding earns its keep when a dev server is about to start against it -
|
|
2603
2616
|
// Bun reads live `src` while Vite's SSR runner reads the artifact, so a stale one 500s inside
|
|
2604
2617
|
// framework/shared-package code and reads exactly like an upstream regression.
|
|
2605
2618
|
for (const f of dr.staleDists) {
|
|
2619
|
+
// The rebuild is only actionable with the LOCATION: a workspace link routinely points into a
|
|
2620
|
+
// sibling repo, so "rebuild @nifrajs/core" left the developer to go find the checkout and guess
|
|
2621
|
+
// the script. Name the directory and the package's own script, or say plainly that it declares
|
|
2622
|
+
// none - never suggest a command that would fail.
|
|
2623
|
+
const rebuild =
|
|
2624
|
+
f.buildScript === undefined
|
|
2625
|
+
? `${f.packageDir} declares no build script - build it the way that package expects`
|
|
2626
|
+
: `cd ${f.packageDir} && bun run ${f.buildScript} (or \`nifra fix --code NF-C010\`)`
|
|
2606
2627
|
diagnostics.push({
|
|
2607
2628
|
rule: "stale-workspace-dist",
|
|
2608
2629
|
severity: "warning",
|
|
2609
2630
|
message: f.missing
|
|
2610
|
-
? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src -
|
|
2611
|
-
: `${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 -
|
|
2612
|
-
fix:
|
|
2613
|
-
evidence: [f.package, f.distFile, f.sourceFile],
|
|
2631
|
+
? `${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}`
|
|
2632
|
+
: `${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}`,
|
|
2633
|
+
fix: rebuild,
|
|
2634
|
+
evidence: [f.package, f.distFile, f.sourceFile, f.packageDir],
|
|
2614
2635
|
suggestion: {
|
|
2615
2636
|
kind: "manual",
|
|
2616
2637
|
title: `Rebuild ${f.package}`,
|
|
2617
2638
|
steps: [
|
|
2618
|
-
|
|
2639
|
+
rebuild,
|
|
2619
2640
|
"Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
|
|
2620
2641
|
],
|
|
2621
2642
|
},
|
package/src/cli.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
22
22
|
import type { BunPlugin } from "bun"
|
|
23
23
|
import { bindCommandArgv, findCommandSpec, renderCommandCatalogHelp } from "./command-catalog.ts"
|
|
24
|
+
import { applyEnvFiles, takeEnvFileFlags } from "./env-file.ts"
|
|
24
25
|
import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
|
|
25
26
|
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
26
27
|
|
|
@@ -188,10 +189,16 @@ routes/ from the current directory. Run from your project root.
|
|
|
188
189
|
Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
|
|
189
190
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).
|
|
190
191
|
|
|
192
|
+
Env: \`--env-file <path>\` works on EVERY command and may be repeated (later files win). Commands that
|
|
193
|
+
reflect your app import it, so an app that validates its environment at module scope needs that
|
|
194
|
+
environment present - without it the app aborts before nifra runs a single check. A variable already
|
|
195
|
+
set in the process environment is never overwritten by a file.
|
|
196
|
+
e.g. \`nifra check --env-file .env.local\`
|
|
197
|
+
|
|
191
198
|
${renderCommandCatalogHelp()}`
|
|
192
199
|
|
|
193
200
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
194
|
-
const CLI_VERSION = "
|
|
201
|
+
const CLI_VERSION = "3.1.0"
|
|
195
202
|
|
|
196
203
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
197
204
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -661,8 +668,34 @@ export function assureBundleRequested(argv: readonly string[]): boolean {
|
|
|
661
668
|
)
|
|
662
669
|
}
|
|
663
670
|
|
|
671
|
+
/**
|
|
672
|
+
* A command that reflects the project imports it, and an app that validates its environment at module
|
|
673
|
+
* scope calls `process.exit` from inside that import. Nothing is throwable at that point, so the whole
|
|
674
|
+
* output is the app's own abort message with no hint that a nifra command triggered it. Name the cause
|
|
675
|
+
* on the way out and point at the flag that supplies the environment.
|
|
676
|
+
*/
|
|
677
|
+
function installReflectionExitHint(): (command: string | undefined) => void {
|
|
678
|
+
let inFlight: string | undefined
|
|
679
|
+
process.on("exit", (code) => {
|
|
680
|
+
if (inFlight === undefined || code === 0) return
|
|
681
|
+
process.stderr.write(
|
|
682
|
+
`\n[nifra] \`nifra ${inFlight}\` reflects this project by importing it, and the import exited ` +
|
|
683
|
+
`with code ${code} before the command produced a result - the message above came from your app, not nifra.\n` +
|
|
684
|
+
` If it is the app's environment validation, supply the environment and re-run:\n` +
|
|
685
|
+
` nifra ${inFlight} --env-file .env.local\n`,
|
|
686
|
+
)
|
|
687
|
+
})
|
|
688
|
+
return (command) => {
|
|
689
|
+
inFlight = command
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
664
693
|
async function main(): Promise<void> {
|
|
665
|
-
const argv = Bun.argv.slice(2)
|
|
694
|
+
const { argv: rawArgv, files: envFiles } = takeEnvFileFlags(Bun.argv.slice(2))
|
|
695
|
+
// Applied before any command runs: a reflecting command imports the app on its first await, and the
|
|
696
|
+
// app reads its environment at module scope, so the variables must already be in place by then.
|
|
697
|
+
if (envFiles.length > 0) await applyEnvFiles(process.cwd(), envFiles)
|
|
698
|
+
const argv = rawArgv
|
|
666
699
|
const command = argv[0]
|
|
667
700
|
if (command === undefined || command === "--help" || command === "-h" || command === "help") {
|
|
668
701
|
console.log(HELP)
|
|
@@ -709,9 +742,12 @@ async function main(): Promise<void> {
|
|
|
709
742
|
}
|
|
710
743
|
const catalogSpec = command === undefined ? undefined : findCommandSpec(command)
|
|
711
744
|
if (catalogSpec?.transports.includes("cli")) {
|
|
745
|
+
const markReflecting = installReflectionExitHint()
|
|
712
746
|
try {
|
|
713
747
|
const input = bindCommandArgv(catalogSpec, argv.slice(1))
|
|
748
|
+
markReflecting(command)
|
|
714
749
|
const output = await catalogSpec.run(input, { cwd: process.cwd(), cliVersion: CLI_VERSION })
|
|
750
|
+
markReflecting(undefined)
|
|
715
751
|
const asRecord = output as unknown as Record<string, unknown>
|
|
716
752
|
const wantsJson = asRecord.json === true || argv.includes("--json")
|
|
717
753
|
if (wantsJson) {
|
|
@@ -723,6 +759,9 @@ async function main(): Promise<void> {
|
|
|
723
759
|
if (catalogSpec.success !== undefined && !catalogSpec.success(output, input))
|
|
724
760
|
process.exitCode = 1
|
|
725
761
|
} catch (err) {
|
|
762
|
+
// A thrown error is nifra's own reporting path, not a silent abort: clear the sentinel so the
|
|
763
|
+
// hint stays reserved for the case where the process died inside the app import.
|
|
764
|
+
markReflecting(undefined)
|
|
726
765
|
console.error(formatCliError(err))
|
|
727
766
|
process.exitCode = 1
|
|
728
767
|
}
|
package/src/command-catalog.ts
CHANGED
|
@@ -376,6 +376,9 @@ interface ContextCommandOutput {
|
|
|
376
376
|
interface FixCommandOutput {
|
|
377
377
|
readonly ok: boolean
|
|
378
378
|
readonly changed: readonly string[]
|
|
379
|
+
/** Recipes that refused, with the reason. A recipe that cannot act says so here instead of
|
|
380
|
+
* aborting the run or - worse - reporting nothing at all. */
|
|
381
|
+
readonly failed: readonly { readonly code: string; readonly reason: string }[]
|
|
379
382
|
readonly diagnostics: readonly unknown[]
|
|
380
383
|
}
|
|
381
384
|
interface SnapshotCommandOutput {
|
|
@@ -1071,6 +1074,7 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
|
|
|
1071
1074
|
properties: {
|
|
1072
1075
|
ok: { type: "boolean" },
|
|
1073
1076
|
changed: { type: "array" },
|
|
1077
|
+
failed: { type: "array" },
|
|
1074
1078
|
diagnostics: { type: "array" },
|
|
1075
1079
|
},
|
|
1076
1080
|
required: ["ok", "diagnostics"],
|
|
@@ -1091,18 +1095,34 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
|
|
|
1091
1095
|
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
1092
1096
|
})
|
|
1093
1097
|
const changed: string[] = []
|
|
1098
|
+
const failed: { code: string; reason: string }[] = []
|
|
1094
1099
|
for (const diagnostic of result.structuredDiagnostics ?? [])
|
|
1095
1100
|
if (value.code === undefined || diagnostic.code === value.code)
|
|
1096
|
-
|
|
1101
|
+
// One recipe that cannot act must not cancel the others, and must not pass silently either:
|
|
1102
|
+
// record why and keep going, so a run over many diagnostics still reports every outcome.
|
|
1103
|
+
try {
|
|
1104
|
+
changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
failed.push({
|
|
1107
|
+
code: diagnostic.code,
|
|
1108
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1097
1111
|
const final = await collectCheckResult(ctx.cwd, {
|
|
1098
1112
|
lintsOnly: true,
|
|
1099
1113
|
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
1100
1114
|
})
|
|
1101
|
-
return {
|
|
1115
|
+
return {
|
|
1116
|
+
ok: final.ok,
|
|
1117
|
+
changed,
|
|
1118
|
+
failed,
|
|
1119
|
+
diagnostics: final.structuredDiagnostics ?? final.diagnostics,
|
|
1120
|
+
}
|
|
1102
1121
|
},
|
|
1103
1122
|
render: (out) => [
|
|
1104
1123
|
out.ok ? "✓ fixes applied" : "✖ unresolved diagnostics remain",
|
|
1105
1124
|
...out.changed.map((file) => ` changed ${file}`),
|
|
1125
|
+
...out.failed.map((f) => ` could not fix ${f.code}: ${f.reason}`),
|
|
1106
1126
|
...out.diagnostics.map((d) => ` ${String((d as { code?: unknown }).code ?? "diagnostic")}`),
|
|
1107
1127
|
],
|
|
1108
1128
|
success: (out) => out.ok,
|
package/src/doctor.ts
CHANGED
|
@@ -25,12 +25,14 @@ import {
|
|
|
25
25
|
displayPath,
|
|
26
26
|
type IdentityParityCopy,
|
|
27
27
|
type IdentityParityFinding,
|
|
28
|
+
identityParityBasis,
|
|
28
29
|
resolvedInstalledCopy,
|
|
29
30
|
} from "@nifrajs/web/internal/parity"
|
|
30
31
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
31
32
|
import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
|
|
32
33
|
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
33
34
|
import { type ResolvedTarget, resolveTarget } from "./port.ts"
|
|
35
|
+
import { buildScriptName } from "./workspace-link.ts"
|
|
34
36
|
|
|
35
37
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
36
38
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -141,6 +143,25 @@ export interface DoctorResult {
|
|
|
141
143
|
readonly findings: readonly DoctorFinding[]
|
|
142
144
|
/** Packages that resolve to more than one physical install across this workspace. */
|
|
143
145
|
readonly duplicateInstalls: readonly DuplicateInstallFinding[]
|
|
146
|
+
/**
|
|
147
|
+
* Duplicate installs the app declared single-copy (`"nifra": { "singleCopy": [...] }`), which nifra
|
|
148
|
+
* collapses to one copy at resolve time. Reported so the topology stays visible, never folded into
|
|
149
|
+
* `ok`: the copies exist on disk but cannot both load. Absent on an older result shape.
|
|
150
|
+
*/
|
|
151
|
+
readonly deduplicatedInstalls?: readonly DuplicateInstallFinding[]
|
|
152
|
+
/**
|
|
153
|
+
* Which tree the identity scan actually looked at, in the same words the build/dev preflight uses.
|
|
154
|
+
* Both run the same seam now; printing the basis is what lets a reader confirm that instead of
|
|
155
|
+
* having to trust it. Absent on an older result shape.
|
|
156
|
+
*/
|
|
157
|
+
readonly identityBasis?: string
|
|
158
|
+
/**
|
|
159
|
+
* The identity scan stopped at the workspace-enumeration cap, so "no duplicates" means "none in the
|
|
160
|
+
* part that was scanned". Never folded into `ok` - a huge workspace is not a defect - but it is
|
|
161
|
+
* always printed, because an unqualified clean bill from a partial scan is the failure mode this
|
|
162
|
+
* whole report exists to avoid.
|
|
163
|
+
*/
|
|
164
|
+
readonly identityScanTruncated?: boolean
|
|
144
165
|
/** Workspace-linked deps whose `default`-condition artifact lags their `bun`-condition source.
|
|
145
166
|
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
146
167
|
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
@@ -360,18 +381,52 @@ export async function collectDuplicateInstalls(
|
|
|
360
381
|
cwd: string,
|
|
361
382
|
rootPackage: Record<string, unknown>,
|
|
362
383
|
): Promise<DuplicateInstallFinding[]> {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
384
|
+
return (await collectAllDuplicateInstalls(cwd, rootPackage)).duplicates
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const asDuplicateInstall = (finding: IdentityParityFinding): DuplicateInstallFinding => ({
|
|
388
|
+
...finding,
|
|
389
|
+
copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Both halves of the identity scan: what still breaks, and what a declaration already collapses -
|
|
394
|
+
* plus the basis they were decided on.
|
|
395
|
+
*
|
|
396
|
+
* The basis travels with the findings because doctor and the build/dev preflight answer the same
|
|
397
|
+
* question, and a reader who gets two answers needs to see which tree each one looked at. They now
|
|
398
|
+
* scan identically (one shared seam, one workspace resolution), so the line is corroboration rather
|
|
399
|
+
* than an excuse - and it is the only way a TRUNCATED scan can be told apart from a clean one.
|
|
400
|
+
*/
|
|
401
|
+
export async function collectAllDuplicateInstalls(
|
|
402
|
+
cwd: string,
|
|
403
|
+
rootPackage: Record<string, unknown>,
|
|
404
|
+
): Promise<{
|
|
405
|
+
readonly duplicates: DuplicateInstallFinding[]
|
|
406
|
+
readonly deduplicated: DuplicateInstallFinding[]
|
|
407
|
+
readonly basis: string
|
|
408
|
+
readonly truncated: boolean
|
|
409
|
+
}> {
|
|
410
|
+
const result = await collectIdentityParity(cwd, rootPackage)
|
|
411
|
+
return {
|
|
412
|
+
duplicates: result.findings.map(asDuplicateInstall),
|
|
413
|
+
deduplicated: result.deduplicated.map(asDuplicateInstall),
|
|
414
|
+
basis: identityParityBasis(result),
|
|
415
|
+
truncated: result.truncated,
|
|
416
|
+
}
|
|
370
417
|
}
|
|
371
418
|
|
|
372
419
|
/** A workspace-linked dependency whose build artifact is older than its source. */
|
|
373
420
|
export interface StaleDistFinding {
|
|
374
421
|
readonly package: string
|
|
422
|
+
/** Where the linked package actually lives, relative to the doctor root when possible. A workspace
|
|
423
|
+
* link routinely points into a SIBLING repo, so "rebuild it" is not actionable without the path -
|
|
424
|
+
* this is the directory the rebuild runs in. */
|
|
425
|
+
readonly packageDir: string
|
|
426
|
+
/** The package's own build script name (`scripts.build` etc.), or `undefined` when it declares none -
|
|
427
|
+
* then the rebuild cannot be automated and the report says so instead of suggesting a command that
|
|
428
|
+
* would fail. */
|
|
429
|
+
readonly buildScript: string | undefined
|
|
375
430
|
/** The `default`-condition artifact (what Vite's SSR runner, vitest, and node consumers load),
|
|
376
431
|
* relative to the doctor root when possible. `missing: true` means it was never built at all. */
|
|
377
432
|
readonly distFile: string
|
|
@@ -482,6 +537,8 @@ export async function collectStaleWorkspaceDists(
|
|
|
482
537
|
newest = candidate
|
|
483
538
|
}
|
|
484
539
|
if (newest === undefined) continue
|
|
540
|
+
const packageDir = displayPath(cwd, copy.path)
|
|
541
|
+
const buildScript = buildScriptName(meta)
|
|
485
542
|
// Report the package once, anchored on its stalest (or missing) artifact.
|
|
486
543
|
let worst: StaleDistFinding | undefined
|
|
487
544
|
for (const pair of pairs) {
|
|
@@ -491,6 +548,8 @@ export async function collectStaleWorkspaceDists(
|
|
|
491
548
|
distStat === undefined
|
|
492
549
|
? {
|
|
493
550
|
package: name,
|
|
551
|
+
packageDir,
|
|
552
|
+
buildScript,
|
|
494
553
|
distFile: displayPath(cwd, distPath),
|
|
495
554
|
missing: true,
|
|
496
555
|
sourceFile: displayPath(cwd, newest.file),
|
|
@@ -499,6 +558,8 @@ export async function collectStaleWorkspaceDists(
|
|
|
499
558
|
: distStat.mtimeMs < newest.mtimeMs
|
|
500
559
|
? {
|
|
501
560
|
package: name,
|
|
561
|
+
packageDir,
|
|
562
|
+
buildScript,
|
|
502
563
|
distFile: displayPath(cwd, distPath),
|
|
503
564
|
missing: false,
|
|
504
565
|
sourceFile: displayPath(cwd, newest.file),
|
|
@@ -704,7 +765,8 @@ export async function collectDoctorResult(
|
|
|
704
765
|
{ includeTests: true },
|
|
705
766
|
)
|
|
706
767
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
|
|
707
|
-
const
|
|
768
|
+
const identity = await collectAllDuplicateInstalls(cwd, pkg)
|
|
769
|
+
const duplicateInstalls = identity.duplicates
|
|
708
770
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
709
771
|
const pipeline = await collectPipelineReport(cwd)
|
|
710
772
|
const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
|
|
@@ -724,6 +786,9 @@ export async function collectDoctorResult(
|
|
|
724
786
|
ran: true,
|
|
725
787
|
findings,
|
|
726
788
|
duplicateInstalls,
|
|
789
|
+
deduplicatedInstalls: identity.deduplicated,
|
|
790
|
+
identityBasis: identity.basis,
|
|
791
|
+
...(identity.truncated ? { identityScanTruncated: true } : {}),
|
|
727
792
|
staleDists,
|
|
728
793
|
readiness,
|
|
729
794
|
...(toolingDrift !== undefined ? { toolingDrift } : {}),
|
|
@@ -906,6 +971,14 @@ export async function runDoctor(
|
|
|
906
971
|
console.log(
|
|
907
972
|
"✓ every imported package is declared and identity-sensitive installs are deduplicated",
|
|
908
973
|
)
|
|
974
|
+
if (result.identityBasis !== undefined) console.log(` identity scan: ${result.identityBasis}`)
|
|
975
|
+
// A partial scan that prints a bare ✓ is a lie by omission - the packages past the cap were never
|
|
976
|
+
// looked at, and the reader has no way to know that from the tick alone.
|
|
977
|
+
if (result.identityScanTruncated === true) {
|
|
978
|
+
console.log(
|
|
979
|
+
" ! the identity scan was truncated, so this is not a clean bill for the whole workspace",
|
|
980
|
+
)
|
|
981
|
+
}
|
|
909
982
|
return true
|
|
910
983
|
}
|
|
911
984
|
// Group by package so the fix ("add X to dependencies") is stated once with its import sites.
|
|
@@ -926,6 +999,7 @@ export async function runDoctor(
|
|
|
926
999
|
console.log(
|
|
927
1000
|
`${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
|
|
928
1001
|
)
|
|
1002
|
+
if (result.identityBasis !== undefined) console.log(` scan: ${result.identityBasis}\n`)
|
|
929
1003
|
for (const finding of result.duplicateInstalls) {
|
|
930
1004
|
console.log(` ${finding.package} [${finding.cause}]`)
|
|
931
1005
|
console.log(` versions: ${finding.versions.join(", ")}`)
|
|
@@ -933,9 +1007,26 @@ export async function runDoctor(
|
|
|
933
1007
|
console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
|
|
934
1008
|
}
|
|
935
1009
|
console.log(` ${finding.explanation}`)
|
|
1010
|
+
// The shape of the split decides which fix can work, so it is printed above the fix itself.
|
|
1011
|
+
if (finding.topology !== undefined) console.log(` topology: ${finding.topology}`)
|
|
1012
|
+
// Why a copy this directory never imports still counts. Without it, a workspace-wide answer
|
|
1013
|
+
// read from inside one app looks like the tool checking the wrong project.
|
|
1014
|
+
if (finding.scope !== undefined) console.log(` scope: ${finding.scope}`)
|
|
936
1015
|
console.log(` fix: ${finding.remediation}`)
|
|
937
1016
|
}
|
|
938
1017
|
}
|
|
1018
|
+
// Not a failure and not silence: the copies are real, and whoever reads this report needs to know
|
|
1019
|
+
// the graph is held together by a declaration rather than by the install.
|
|
1020
|
+
if (result.deduplicatedInstalls !== undefined && result.deduplicatedInstalls.length > 0) {
|
|
1021
|
+
console.log("\n• identity-sensitive packages deduplicated by declaration:\n")
|
|
1022
|
+
for (const finding of result.deduplicatedInstalls) {
|
|
1023
|
+
console.log(` ${finding.package} @ ${finding.versions.join(", ")}`)
|
|
1024
|
+
for (const copy of finding.copies) {
|
|
1025
|
+
console.log(` ${copy.path} ← ${copy.importers.join(", ")}`)
|
|
1026
|
+
}
|
|
1027
|
+
console.log(` ${finding.remediation}`)
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
939
1030
|
if (result.readiness?.strict === true && result.readiness.ok === false) {
|
|
940
1031
|
console.log("\n✗ production readiness is incomplete under --strict")
|
|
941
1032
|
}
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--env-file <path>` for every `nifra` command.
|
|
3
|
+
*
|
|
4
|
+
* Most nifra commands (`check`, `assure`, `levels`, `routes`, `capabilities`, `manifest`, `contracts`,
|
|
5
|
+
* `openapi`, `types`) reflect a project by IMPORTING it, and a production-grade app validates its
|
|
6
|
+
* environment at module scope. Without the app's environment those commands do not report a gate
|
|
7
|
+
* failure - the app's own validator kills the process before nifra reaches its first check, and the
|
|
8
|
+
* only output is the app's `FATAL: invalid environment` with nothing tying it to the command that was
|
|
9
|
+
* run. This flag supplies that environment the same way `bun --env-file` / `node --env-file` do, so a
|
|
10
|
+
* project whose env lives in an uncommitted `.env.local` stays checkable.
|
|
11
|
+
*
|
|
12
|
+
* Two deliberate rules:
|
|
13
|
+
* - A variable already present in `process.env` WINS. The shell (and CI's secret store) is the more
|
|
14
|
+
* explicit source; a checked-in defaults file must never shadow it.
|
|
15
|
+
* - A missing file is an error, never a silent no-op. `--env-file .env.prod` that quietly did nothing
|
|
16
|
+
* would make the command look like it verified an environment it never loaded.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from "node:fs/promises"
|
|
19
|
+
import { resolve } from "node:path"
|
|
20
|
+
|
|
21
|
+
const ENV_FILE_FLAG = "--env-file"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `KEY=VALUE` line. `export ` is tolerated so a file that is also `source`-able works. The key is
|
|
25
|
+
* POSIX-shaped on purpose: it keeps a stray `- foo` bullet or a YAML `key: value` line from being read
|
|
26
|
+
* as an assignment when the wrong file is passed.
|
|
27
|
+
*/
|
|
28
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/
|
|
29
|
+
|
|
30
|
+
/** Strip one layer of matching quotes; a double-quoted value also expands `\n`, `\r`, `\t` and `\"`. */
|
|
31
|
+
function unquote(raw: string): string {
|
|
32
|
+
const value = raw.trim()
|
|
33
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
34
|
+
return value
|
|
35
|
+
.slice(1, -1)
|
|
36
|
+
.replace(/\\([nrt"\\])/g, (_, char: string) =>
|
|
37
|
+
char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1)
|
|
41
|
+
// Unquoted: an inline ` #` starts a comment, matching every dotenv reader. A `#` with no leading
|
|
42
|
+
// space is part of the value (URLs and generated secrets contain one).
|
|
43
|
+
const comment = value.search(/\s#/)
|
|
44
|
+
return (comment === -1 ? value : value.slice(0, comment)).trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse dotenv text into a plain record. Later assignments to the same key win, as in a shell. */
|
|
48
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
49
|
+
const values: Record<string, string> = {}
|
|
50
|
+
for (const line of text.split(/\r?\n/)) {
|
|
51
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue
|
|
52
|
+
const match = ASSIGNMENT.exec(line)
|
|
53
|
+
if (match === null) continue
|
|
54
|
+
values[match[1] as string] = unquote(match[2] as string)
|
|
55
|
+
}
|
|
56
|
+
return values
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pull every `--env-file <path>` / `--env-file=<path>` out of `argv`, leaving the rest for the command
|
|
61
|
+
* parser (which rejects options it does not know). Repeatable; later files override earlier ones, the
|
|
62
|
+
* same precedence `node --env-file` uses.
|
|
63
|
+
*/
|
|
64
|
+
export function takeEnvFileFlags(argv: readonly string[]): {
|
|
65
|
+
readonly argv: readonly string[]
|
|
66
|
+
readonly files: readonly string[]
|
|
67
|
+
} {
|
|
68
|
+
const rest: string[] = []
|
|
69
|
+
const files: string[] = []
|
|
70
|
+
for (let index = 0; index < argv.length; index++) {
|
|
71
|
+
const token = argv[index] as string
|
|
72
|
+
if (token === ENV_FILE_FLAG) {
|
|
73
|
+
const value = argv[index + 1]
|
|
74
|
+
if (value === undefined || value.startsWith("-")) {
|
|
75
|
+
throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG} .env.local`)
|
|
76
|
+
}
|
|
77
|
+
files.push(value)
|
|
78
|
+
index++
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (token.startsWith(`${ENV_FILE_FLAG}=`)) {
|
|
82
|
+
const value = token.slice(ENV_FILE_FLAG.length + 1)
|
|
83
|
+
if (value === "") {
|
|
84
|
+
throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG}=.env.local`)
|
|
85
|
+
}
|
|
86
|
+
files.push(value)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
rest.push(token)
|
|
90
|
+
}
|
|
91
|
+
return { argv: rest, files }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read each file in order and merge it into `process.env`, never overwriting a variable the process
|
|
96
|
+
* already has. Returns the resolved paths that were applied so a command can say what it loaded.
|
|
97
|
+
*/
|
|
98
|
+
export async function applyEnvFiles(
|
|
99
|
+
cwd: string,
|
|
100
|
+
files: readonly string[],
|
|
101
|
+
): Promise<readonly string[]> {
|
|
102
|
+
const applied: string[] = []
|
|
103
|
+
const merged: Record<string, string> = {}
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const path = resolve(cwd, file)
|
|
106
|
+
let text: string
|
|
107
|
+
try {
|
|
108
|
+
text = await readFile(path, "utf8")
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error(`[nifra] --env-file not found or unreadable: ${path}`)
|
|
111
|
+
}
|
|
112
|
+
// Files merge left to right, so a later `--env-file` overrides an earlier one; the merged result
|
|
113
|
+
// is applied afterwards so that precedence never depends on which file happened to define a key.
|
|
114
|
+
Object.assign(merged, parseEnvFile(text))
|
|
115
|
+
applied.push(path)
|
|
116
|
+
}
|
|
117
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
118
|
+
if (process.env[key] === undefined) process.env[key] = value
|
|
119
|
+
}
|
|
120
|
+
return applied
|
|
121
|
+
}
|