@nifrajs/cli 2.14.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +6 -1
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +3 -2
- package/dist/assure.js.map +1 -1
- package/dist/check-diagnostics.d.ts +165 -0
- package/dist/check-diagnostics.d.ts.map +1 -0
- package/dist/check-diagnostics.js +1009 -0
- package/dist/check-diagnostics.js.map +1 -0
- package/dist/check-scan.d.ts +245 -0
- package/dist/check-scan.d.ts.map +1 -0
- package/dist/check-scan.js +1599 -0
- package/dist/check-scan.js.map +1 -0
- package/dist/check.d.ts +4 -324
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +8 -2346
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +285 -10
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +27 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +142 -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/frontend-guidance.d.ts +57 -0
- package/dist/frontend-guidance.d.ts.map +1 -0
- package/dist/frontend-guidance.js +195 -0
- package/dist/frontend-guidance.js.map +1 -0
- 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/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +6 -0
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +4 -1
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +48 -0
- package/dist/mcp-context.d.ts.map +1 -0
- package/dist/mcp-context.js +215 -0
- package/dist/mcp-context.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +31 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +74 -0
- package/dist/mcp-exec.d.ts.map +1 -0
- package/dist/mcp-exec.js +844 -0
- package/dist/mcp-exec.js.map +1 -0
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -0
- package/dist/mcp-io.js +100 -0
- package/dist/mcp-io.js.map +1 -0
- package/dist/mcp-reflect.d.ts +16 -0
- package/dist/mcp-reflect.d.ts.map +1 -0
- package/dist/mcp-reflect.js +101 -0
- package/dist/mcp-reflect.js.map +1 -0
- package/dist/mcp-root.d.ts +7 -0
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +51 -3
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp.d.ts +10 -84
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +13 -1170
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +5 -10
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -2
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +21 -0
- package/dist/openapi-types.d.ts.map +1 -0
- package/dist/openapi-types.js +214 -0
- package/dist/openapi-types.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +2 -1
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +2 -1
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.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 +4 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +6 -123
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/codes.d.ts +4 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +4 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/islands.d.ts +4 -0
- package/dist/rules/islands.d.ts.map +1 -0
- package/dist/rules/islands.js +155 -0
- package/dist/rules/islands.js.map +1 -0
- package/dist/rules/nano.d.ts +6 -0
- package/dist/rules/nano.d.ts.map +1 -0
- package/dist/rules/nano.js +264 -0
- package/dist/rules/nano.js.map +1 -0
- 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/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +38 -4
- package/dist/rules/security.js.map +1 -1
- package/dist/scaffold.d.ts +12 -6
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +149 -10
- package/dist/scaffold.js.map +1 -1
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -3
- package/dist/search-terms.js.map +1 -1
- package/dist/stylex-migrate.d.ts +38 -0
- package/dist/stylex-migrate.d.ts.map +1 -0
- package/dist/stylex-migrate.js +1052 -0
- package/dist/stylex-migrate.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/verification-plan.d.ts +24 -0
- package/dist/verification-plan.d.ts.map +1 -0
- package/dist/verification-plan.js +116 -0
- package/dist/verification-plan.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +6 -3
- package/dist/work-graph.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 +37 -1
- package/docs/llms-full.txt +1598 -44
- package/docs/types.json +5157 -172
- package/package.json +9 -9
- package/src/assure-hydration.ts +6 -1
- package/src/assure.ts +4 -2
- package/src/check-diagnostics.ts +1219 -0
- package/src/check-scan.ts +1825 -0
- package/src/check.ts +47 -2731
- package/src/cli.ts +291 -10
- package/src/command-catalog.ts +177 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/frontend-guidance.ts +259 -0
- 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/levels-tool.ts +6 -0
- package/src/load.ts +4 -1
- package/src/mcp-context.ts +266 -0
- package/src/mcp-docs-tools.ts +37 -0
- package/src/mcp-exec.ts +1040 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +118 -0
- package/src/mcp-reflect.ts +140 -0
- package/src/mcp-root.ts +62 -4
- package/src/mcp.ts +61 -1436
- package/src/openapi-tool.ts +32 -2
- package/src/openapi-types.ts +261 -0
- package/src/port.ts +2 -1
- package/src/project-facts.ts +2 -4
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +22 -147
- package/src/rules/codes.ts +4 -0
- package/src/rules/islands.ts +171 -0
- package/src/rules/nano.ts +295 -0
- package/src/rules/routes.ts +17 -30
- package/src/rules/security.ts +40 -4
- package/src/scaffold.ts +169 -9
- package/src/search-terms.ts +7 -3
- package/src/stylex-migrate.ts +1139 -0
- package/src/sync-manifest.ts +2 -1
- package/src/verification-plan.ts +242 -0
- package/src/work-graph.ts +6 -3
- package/src/workspace-link.ts +80 -0
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 arg = argv[index] as string
|
|
72
|
+
if (arg === 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 (arg.startsWith(`${ENV_FILE_FLAG}=`)) {
|
|
82
|
+
const value = arg.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(arg)
|
|
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
|
+
}
|
package/src/fix-recipes.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync } from "node:fs"
|
|
2
1
|
import { readFile, writeFile } from "node:fs/promises"
|
|
3
2
|
import { join } from "node:path"
|
|
4
3
|
import type { Diagnostic } from "./diagnostics.ts"
|
|
5
4
|
import { resolveInsideProject } from "./project-path.ts"
|
|
5
|
+
import { resolveWorkspaceLinkedPackage } from "./workspace-link.ts"
|
|
6
6
|
|
|
7
7
|
export interface FixRecipe {
|
|
8
8
|
readonly id: string
|
|
@@ -91,29 +91,115 @@ registerFixRecipe({
|
|
|
91
91
|
verify: "nifra doctor --json",
|
|
92
92
|
async apply(root, diagnostic) {
|
|
93
93
|
const packageName = diagnostic.evidence?.[0]
|
|
94
|
-
if (packageName === undefined
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
cwd: packageDir,
|
|
94
|
+
if (packageName === undefined) return []
|
|
95
|
+
// Resolved from the NAME through the project's own node_modules chain, not from any path carried
|
|
96
|
+
// by the diagnostic. A workspace link points outside the project by definition - that escape is
|
|
97
|
+
// what makes the artifact able to go stale - so `resolveInsideProject` would reject every package
|
|
98
|
+
// this diagnostic can ever name. `resolveWorkspaceLinkedPackage` states its own rules instead:
|
|
99
|
+
// reachable through the project's install, not a registry tarball, and declaring its own build.
|
|
100
|
+
const linked = await resolveWorkspaceLinkedPackage(root, packageName)
|
|
101
|
+
// Refusing is a result the caller has to see. Returning "changed nothing" made `nifra fix
|
|
102
|
+
// --code NF-C010` a silent no-op on the only findings that produce it.
|
|
103
|
+
if (!linked.ok) throw new Error(`[nifra] cannot rebuild ${packageName}: ${linked.reason}`)
|
|
104
|
+
const proc = Bun.spawn(["bun", "run", linked.buildScript], {
|
|
105
|
+
cwd: linked.dir,
|
|
107
106
|
stdout: "ignore",
|
|
108
107
|
stderr: "pipe",
|
|
109
108
|
})
|
|
110
109
|
const error = await new Response(proc.stderr).text()
|
|
111
110
|
const exitCode = await proc.exited
|
|
112
|
-
if (exitCode !== 0)
|
|
111
|
+
if (exitCode !== 0) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[nifra] \`bun run ${linked.buildScript}\` failed in ${linked.dir}: ${error.trim() || `exit code ${exitCode}`}`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
113
116
|
return [diagnostic.evidence?.[1] ?? packageName]
|
|
114
117
|
},
|
|
115
118
|
})
|
|
116
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How many typecheck passes the reserved-segment codemod will run. A nested collision
|
|
122
|
+
* (`/api/delete/then`) only reveals its second site once the first is rewritten, so one pass is not
|
|
123
|
+
* always enough - but each pass is a full `tsc`, so this converges or stops rather than looping.
|
|
124
|
+
*/
|
|
125
|
+
const RESERVED_SEGMENT_PASSES = 3
|
|
126
|
+
|
|
127
|
+
registerFixRecipe({
|
|
128
|
+
id: "client.reserved-segment",
|
|
129
|
+
description:
|
|
130
|
+
"Rewrite typed-client call sites broken by a reserved-named route segment to the call spelling.",
|
|
131
|
+
verify: "tsc --noEmit",
|
|
132
|
+
async apply(root) {
|
|
133
|
+
const { parseCollisionSites, resolveTscBin, rewriteFile } = await import(
|
|
134
|
+
"./internal/reserved-segment-codemod.ts"
|
|
135
|
+
)
|
|
136
|
+
const tsconfig = join(root, "tsconfig.json")
|
|
137
|
+
if (!(await Bun.file(tsconfig).exists()))
|
|
138
|
+
throw new Error(
|
|
139
|
+
"[nifra] no tsconfig.json here - the reserved-segment codemod reads the compiler's own list of broken call sites, so it needs a typecheckable project",
|
|
140
|
+
)
|
|
141
|
+
const tscBin = resolveTscBin(root)
|
|
142
|
+
if (tscBin === undefined)
|
|
143
|
+
throw new Error(
|
|
144
|
+
"[nifra] no `typescript` install found from this directory upward - run `bun add -d typescript`, then rerun the fix",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const changed = new Set<string>()
|
|
148
|
+
const skipped: string[] = []
|
|
149
|
+
for (let pass = 0; pass < RESERVED_SEGMENT_PASSES; pass += 1) {
|
|
150
|
+
const proc = Bun.spawn(["bun", tscBin, "--noEmit", "--pretty", "false", "-p", tsconfig], {
|
|
151
|
+
cwd: root,
|
|
152
|
+
stdout: "pipe",
|
|
153
|
+
stderr: "pipe",
|
|
154
|
+
})
|
|
155
|
+
const [stdout, stderr] = await Promise.all([
|
|
156
|
+
new Response(proc.stdout).text(),
|
|
157
|
+
new Response(proc.stderr).text(),
|
|
158
|
+
])
|
|
159
|
+
await proc.exited
|
|
160
|
+
const sites = parseCollisionSites(`${stdout}${stderr}`)
|
|
161
|
+
if (sites.length === 0) break
|
|
162
|
+
|
|
163
|
+
const byFile = new Map<string, (typeof sites)[number][]>()
|
|
164
|
+
for (const site of sites) {
|
|
165
|
+
const existing = byFile.get(site.file)
|
|
166
|
+
if (existing === undefined) byFile.set(site.file, [site])
|
|
167
|
+
else existing.push(site)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let wrote = false
|
|
171
|
+
skipped.length = 0
|
|
172
|
+
for (const [file, fileSites] of byFile) {
|
|
173
|
+
// Resolved through the project boundary: the compiler reports paths from its own rootDir, and
|
|
174
|
+
// a rewrite must never escape the project it was invoked in.
|
|
175
|
+
const path = await resolveInsideProject(root, file)
|
|
176
|
+
if (path === undefined) continue
|
|
177
|
+
const source = await readFile(path, "utf8")
|
|
178
|
+
const result = rewriteFile(source, fileSites)
|
|
179
|
+
for (const site of result.skipped) skipped.push(`${file}:${site.line}:${site.column}`)
|
|
180
|
+
if (result.source === source) continue
|
|
181
|
+
await writeFile(path, result.source, "utf8")
|
|
182
|
+
changed.add(file)
|
|
183
|
+
wrote = true
|
|
184
|
+
}
|
|
185
|
+
// Nothing rewritten this pass means the remaining sites are all shapes the codemod declines to
|
|
186
|
+
// touch. Another `tsc` would report the same list, so stop instead of burning the budget.
|
|
187
|
+
if (!wrote) break
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (skipped.length > 0) {
|
|
191
|
+
const rewritten =
|
|
192
|
+
changed.size === 0
|
|
193
|
+
? "no site was rewritten"
|
|
194
|
+
: `rewrote ${changed.size} file${changed.size === 1 ? "" : "s"}`
|
|
195
|
+
throw new Error(
|
|
196
|
+
`[nifra] ${rewritten}; ${skipped.length} reserved-segment site${skipped.length === 1 ? "" : "s"} need${skipped.length === 1 ? "s" : ""} a manual edit (the collision is not reached by a plain \`.segment\` property access there): ${skipped.join(", ")}`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
return [...changed]
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
117
203
|
export async function applyDiagnosticRecipe(
|
|
118
204
|
root: string,
|
|
119
205
|
diagnostic: Diagnostic,
|