@nifrajs/cli 3.3.0 → 4.0.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 +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +7 -15
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +162 -713
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +58 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +323 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +145 -54
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +306 -87
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +60 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +298 -33
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +56 -16
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diagnostics.d.ts +86 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +85 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +41 -21
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -4
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +93 -74
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +71 -16
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +5 -6
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +3 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +263 -131
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +20 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +113 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-path.d.ts +10 -0
- package/dist/mcp-path.d.ts.map +1 -0
- package/dist/mcp-path.js +51 -0
- package/dist/mcp-path.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-reflect.d.ts.map +1 -1
- package/dist/mcp-reflect.js +2 -2
- package/dist/mcp-reflect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +64 -45
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +124 -25
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +159 -55
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +18 -5
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +165 -82
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +982 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +28 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +12 -2
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +34 -0
- package/dist/recipes/4.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 +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts +21 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +823 -0
- package/dist/review.js.map +1 -0
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts +3 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +829 -34
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +154 -90
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.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/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +8 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts +3 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +33 -16
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +4123 -548
- package/docs/types.json +14391 -10060
- package/package.json +19 -11
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +199 -797
- package/src/check-scan.ts +394 -63
- package/src/check.ts +146 -58
- package/src/cli.ts +347 -92
- package/src/command-catalog.ts +407 -45
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +66 -15
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diagnostics.ts +179 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +44 -31
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +93 -82
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +144 -32
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +5 -7
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +301 -161
- package/src/mcp-http.ts +22 -5
- package/src/mcp-io.ts +130 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-path.ts +50 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-reflect.ts +3 -2
- package/src/mcp-render.ts +72 -42
- package/src/mcp-root.ts +130 -24
- package/src/mcp-run.ts +190 -56
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +193 -91
- package/src/migrate-layout.ts +1121 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +42 -8
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +988 -0
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +947 -35
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +175 -98
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +27 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +40 -16
package/src/init-agents.ts
CHANGED
|
@@ -1,43 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra init-agents` - retrofit an EXISTING app with the agent
|
|
3
|
-
*
|
|
2
|
+
* `nifra init-agents` - retrofit an EXISTING app with the agent files a freshly scaffolded app ships, so
|
|
3
|
+
* an already-built project adopts them in one command:
|
|
4
4
|
*
|
|
5
|
-
* .mcp.json
|
|
6
|
-
* .cursor/
|
|
7
|
-
*
|
|
8
|
-
* AGENTS.md
|
|
5
|
+
* .mcp.json, .cursor/mcp.json - the nifra MCP server for Claude Code and Cursor
|
|
6
|
+
* CLAUDE.md, GEMINI.md, .cursor/rules/nifra.mdc, .github/copilot-instructions.md
|
|
7
|
+
* - each agent's pointer to AGENTS.md, the single source of guidance
|
|
8
|
+
* AGENTS.md - the `## MCP server` section, and for an app with `routes/` the
|
|
9
|
+
* `## Project structure` section, appended when missing (or a minimal
|
|
10
|
+
* file if none exists)
|
|
9
11
|
*
|
|
10
12
|
* The generators are imported from `create-nifra/agent-files` - the SAME source of truth `create-nifra`
|
|
11
13
|
* uses at scaffold time - so a retrofitted app and a freshly scaffolded one get byte-identical configs.
|
|
12
14
|
*
|
|
13
15
|
* Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
|
|
14
16
|
* customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
|
|
15
|
-
* notice; `--force` overwrites. `AGENTS.md` is special-cased -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* notice; `--force` overwrites. `AGENTS.md` is special-cased - a section it already has is left alone,
|
|
18
|
+
* a missing one is APPENDED (never overwriting the user's conventions), and `--force` is not needed for
|
|
19
|
+
* that append since it's additive. Every write path is resolved + confined under the
|
|
18
20
|
* cwd (no `..` traversal escaping the project root).
|
|
21
|
+
*
|
|
22
|
+
* `--sync-mcp` is the upgrade path: the launch command pins an exact `@nifrajs/cli` version, so after
|
|
23
|
+
* the app upgrades nifra every existing file still launches the old CLI. It rewrites that version and
|
|
24
|
+
* nothing else - the pin in both MCP registries and the launch command in CLAUDE.md and AGENTS.md's
|
|
25
|
+
* MCP section - to the nifra the project installs, leaving every other byte of every file as it was.
|
|
26
|
+
*
|
|
27
|
+
* At a workspace root that is not itself a nifra project but has exactly one nifra member, both runs
|
|
28
|
+
* also name that member in the registries' launch args (`mcp app`), so the server keeps its root when
|
|
29
|
+
* a second nifra member arrives.
|
|
19
30
|
*/
|
|
20
31
|
|
|
21
32
|
import type { Stats } from "node:fs"
|
|
22
|
-
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
23
|
-
import { isAbsolute, relative, resolve } from "node:path"
|
|
33
|
+
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
34
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
24
35
|
import {
|
|
36
|
+
AGENT_POINTERS,
|
|
25
37
|
AGENTS_MD_PATH,
|
|
26
38
|
agentsMcpSection,
|
|
39
|
+
agentsStructureSection,
|
|
27
40
|
CLAUDE_MD_PATH,
|
|
28
41
|
CURSOR_MCP_JSON_PATH,
|
|
29
|
-
|
|
42
|
+
MCP_CLI_VERSION,
|
|
30
43
|
MCP_JSON_PATH,
|
|
44
|
+
MCP_SERVER_COMMAND,
|
|
31
45
|
mcpJson,
|
|
46
|
+
STRUCTURE_HEADING,
|
|
32
47
|
} from "create-nifra/agent-files"
|
|
48
|
+
import { installedNifraVersion, resolveRootState } from "./mcp-root.ts"
|
|
33
49
|
|
|
34
50
|
/** What happened to one file during the retrofit, for the printed report + the `--json` shape. */
|
|
35
51
|
export interface InitAgentsFileResult {
|
|
36
52
|
/** Project-root-relative POSIX path. */
|
|
37
53
|
readonly path: string
|
|
38
|
-
/** `wrote` - created or (with --force) overwrote; `appended` - added
|
|
39
|
-
* AGENTS.md; `skipped` - already present and not forced
|
|
40
|
-
|
|
54
|
+
/** `wrote` - created or (with --force) overwrote; `appended` - added missing sections to an existing
|
|
55
|
+
* AGENTS.md; `skipped` - already present and not forced (or, under --sync-mcp, absent or unpinned);
|
|
56
|
+
* `present` - every section already there (or, under --sync-mcp, already pinned to the target);
|
|
57
|
+
* `synced` - --sync-mcp rewrote a stale pinned version or named the workspace member. */
|
|
58
|
+
readonly action: "wrote" | "appended" | "skipped" | "present" | "synced"
|
|
41
59
|
/** Why it was skipped/left, for the notice (e.g. "exists - pass --force to overwrite"). */
|
|
42
60
|
readonly note?: string
|
|
43
61
|
}
|
|
@@ -45,11 +63,16 @@ export interface InitAgentsFileResult {
|
|
|
45
63
|
export interface InitAgentsResult {
|
|
46
64
|
readonly cwd: string
|
|
47
65
|
readonly files: readonly InitAgentsFileResult[]
|
|
66
|
+
/** Under --sync-mcp: the `@nifrajs/cli` version every pin was brought to. */
|
|
67
|
+
readonly syncedTo?: string
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
export interface InitAgentsOptions {
|
|
51
71
|
/** Overwrite an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` instead of skipping it. */
|
|
52
72
|
readonly force?: boolean
|
|
73
|
+
/** Rewrite only the pinned `@nifrajs/cli` version (and, at a workspace root, the member the launch
|
|
74
|
+
* names) in the existing files; create and append nothing. */
|
|
75
|
+
readonly syncMcp?: boolean
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
/** Confine a project-relative path under `cwd` and return the absolute path. Rejects a spec that escapes
|
|
@@ -123,7 +146,7 @@ async function writeOwned(
|
|
|
123
146
|
if (!force && (await fileExists(abs))) {
|
|
124
147
|
return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
|
|
125
148
|
}
|
|
126
|
-
// `.cursor
|
|
149
|
+
// A nested file (`.cursor/`, `.github/`) needs its parent dir; `recursive` is a no-op at the root.
|
|
127
150
|
await mkdir(resolve(abs, ".."), { recursive: true })
|
|
128
151
|
await atomicWrite(abs, content)
|
|
129
152
|
return { path: rel, action: "wrote" }
|
|
@@ -131,11 +154,16 @@ async function writeOwned(
|
|
|
131
154
|
|
|
132
155
|
/** Write via a sibling temp file so a crash mid-write can't leave a half-written config in place.
|
|
133
156
|
* `wx` fails rather than following an existing name, and the temp is cleaned up on any failure so a
|
|
134
|
-
* botched run doesn't litter the user's project. */
|
|
135
|
-
async function atomicWrite(
|
|
157
|
+
* botched run doesn't litter the user's project. `mode` carries a replaced file's permissions over. */
|
|
158
|
+
async function atomicWrite(
|
|
159
|
+
abs: string,
|
|
160
|
+
content: string | Uint8Array,
|
|
161
|
+
mode?: number,
|
|
162
|
+
): Promise<void> {
|
|
136
163
|
const tmp = `${abs}.nifra-${process.pid}-${Date.now()}`
|
|
137
164
|
await writeFile(tmp, content, { flag: "wx" })
|
|
138
165
|
try {
|
|
166
|
+
if (mode !== undefined) await chmod(tmp, mode)
|
|
139
167
|
await rename(tmp, abs)
|
|
140
168
|
} catch (error) {
|
|
141
169
|
await rm(tmp, { force: true })
|
|
@@ -144,16 +172,19 @@ async function atomicWrite(abs: string, content: string): Promise<void> {
|
|
|
144
172
|
}
|
|
145
173
|
|
|
146
174
|
/**
|
|
147
|
-
* AGENTS.md is additive, not owned:
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
175
|
+
* AGENTS.md is additive, not owned: each section it lacks is appended (preserving the user's
|
|
176
|
+
* conventions), each it has is left alone, and an absent file is written with just those sections.
|
|
177
|
+
* `--force` is irrelevant here - we never overwrite the user's existing guidance. The structure section
|
|
178
|
+
* is for an app with `routes/`; an API-only app has no frontend to keep apart.
|
|
151
179
|
*/
|
|
152
180
|
async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
153
181
|
const abs = safeJoin(cwd, AGENTS_MD_PATH)
|
|
154
182
|
await assertSafeParents(cwd, abs)
|
|
155
183
|
await assertRegularTarget(abs, AGENTS_MD_PATH)
|
|
156
|
-
const
|
|
184
|
+
const sections = [{ heading: "## MCP server", text: agentsMcpSection() }]
|
|
185
|
+
if (await isDirectory(safeJoin(cwd, "routes"))) {
|
|
186
|
+
sections.push({ heading: STRUCTURE_HEADING, text: agentsStructureSection() })
|
|
187
|
+
}
|
|
157
188
|
let existing: string | undefined
|
|
158
189
|
try {
|
|
159
190
|
existing = await readFile(abs, "utf8")
|
|
@@ -161,23 +192,221 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
|
161
192
|
existing = undefined // no AGENTS.md yet
|
|
162
193
|
}
|
|
163
194
|
if (existing === undefined) {
|
|
195
|
+
const body = sections.map((section) => section.text).join("\n\n")
|
|
164
196
|
await atomicWrite(
|
|
165
197
|
abs,
|
|
166
|
-
`# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${
|
|
198
|
+
`# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${body}\n`,
|
|
167
199
|
)
|
|
168
200
|
return { path: AGENTS_MD_PATH, action: "wrote" }
|
|
169
201
|
}
|
|
170
202
|
// Match on the section heading, not the full body, so a reformatted-but-present section still counts.
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
const current = existing
|
|
204
|
+
const missing = sections.filter((section) => !current.includes(section.heading))
|
|
205
|
+
if (missing.length === 0) {
|
|
206
|
+
return { path: AGENTS_MD_PATH, action: "present", note: "already has every section" }
|
|
173
207
|
}
|
|
174
|
-
const sep =
|
|
175
|
-
await atomicWrite(abs, `${
|
|
176
|
-
|
|
208
|
+
const sep = current.endsWith("\n") ? "\n" : "\n\n"
|
|
209
|
+
await atomicWrite(abs, `${current}${sep}${missing.map((section) => section.text).join("\n\n")}\n`)
|
|
210
|
+
const names = missing.map((section) => section.heading.slice(3)).join(" + ")
|
|
211
|
+
return { path: AGENTS_MD_PATH, action: "appended", note: names }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const isDirectory = (path: string): Promise<boolean> =>
|
|
215
|
+
lstat(path)
|
|
216
|
+
.then((stat) => stat.isDirectory())
|
|
217
|
+
.catch(() => false)
|
|
218
|
+
|
|
219
|
+
/** An exact version: the only pin --sync-mcp rewrites. A dist-tag or a range is a deliberate choice,
|
|
220
|
+
* not a scaffold-time freeze. Prerelease and build suffixes are part of the version. */
|
|
221
|
+
const PIN_VERSION = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`
|
|
222
|
+
/** The pin as both MCP registries hold it: one JSON string in the server's `args`. */
|
|
223
|
+
const JSON_PIN = new RegExp(`"@nifrajs/cli@(${PIN_VERSION})"`, "g")
|
|
224
|
+
/** The pin as the markdown quotes it: the whole launch command, so prose that merely mentions a
|
|
225
|
+
* version (an upgrade note, a changelog line) is never touched. */
|
|
226
|
+
const LAUNCH_PIN = new RegExp(`${MCP_SERVER_COMMAND} @nifrajs/cli@(${PIN_VERSION}) mcp`, "g")
|
|
227
|
+
|
|
228
|
+
/** The one nifra workspace member `cwd` resolves to, as a POSIX path relative to it, when `cwd` is a
|
|
229
|
+
* workspace root and not itself a nifra project. */
|
|
230
|
+
async function workspaceMember(cwd: string): Promise<string | undefined> {
|
|
231
|
+
const state = await resolveRootState(cwd, false)
|
|
232
|
+
return state.source === "workspace" ? relative(cwd, state.root).split(sep).join("/") : undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The registries' launch args when they name no directory: `["@nifrajs/cli@x.y.z", "mcp"]`. Group 2
|
|
236
|
+
* is the whitespace between the two, reused before the member so the file keeps its own layout. */
|
|
237
|
+
const JSON_BARE_LAUNCH = new RegExp(`("@nifrajs/cli@${PIN_VERSION}"\\s*,(\\s*)"mcp")(\\s*\\])`, "g")
|
|
238
|
+
|
|
239
|
+
/** Add `member` after a bare `"mcp"` launch arg; a launch that already names a directory is left alone. */
|
|
240
|
+
function nameMember(
|
|
241
|
+
text: string,
|
|
242
|
+
member: string,
|
|
243
|
+
): { readonly text: string; readonly named: boolean } {
|
|
244
|
+
let named = false
|
|
245
|
+
const rewritten = text.replace(
|
|
246
|
+
JSON_BARE_LAUNCH,
|
|
247
|
+
(_match: string, launch: string, gap: string, close: string) => {
|
|
248
|
+
named = true
|
|
249
|
+
return `${launch},${gap}${JSON.stringify(member)}${close}`
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
return { text: rewritten, named }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface PinScan {
|
|
256
|
+
/** The input with every stale pin rewritten - every other character untouched. */
|
|
257
|
+
readonly text: string
|
|
258
|
+
/** Pins found, current or stale. */
|
|
259
|
+
readonly pins: number
|
|
260
|
+
/** The distinct stale versions, in order of appearance. */
|
|
261
|
+
readonly stale: readonly string[]
|
|
262
|
+
/** The workspace member this scan added to a launch that named no directory. */
|
|
263
|
+
readonly named?: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function repin(text: string, pattern: RegExp, target: string): PinScan {
|
|
267
|
+
let pins = 0
|
|
268
|
+
const stale: string[] = []
|
|
269
|
+
const rewritten = text.replace(pattern, (match: string, version: string) => {
|
|
270
|
+
pins++
|
|
271
|
+
if (version === target) return match
|
|
272
|
+
if (!stale.includes(version)) stale.push(version)
|
|
273
|
+
return match.replace(`@${version}`, () => `@${target}`)
|
|
274
|
+
})
|
|
275
|
+
return { text: rewritten, pins, stale }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** AGENTS.md is the user's file: only its `## MCP server` section - the heading up to the next `#` or
|
|
279
|
+
* `##` heading, or the end of the file - is ours to re-pin. */
|
|
280
|
+
function repinMcpSection(text: string, target: string): PinScan {
|
|
281
|
+
const heading = /^## MCP server\b.*$/m.exec(text)
|
|
282
|
+
if (heading === null) return { text, pins: 0, stale: [] }
|
|
283
|
+
const start = heading.index + heading[0].length
|
|
284
|
+
const next = /^#{1,2}[ \t]/m.exec(text.slice(start))
|
|
285
|
+
const end = next === null ? text.length : start + next.index
|
|
286
|
+
const section = repin(text.slice(start, end), LAUNCH_PIN, target)
|
|
287
|
+
return { ...section, text: text.slice(0, start) + section.text + text.slice(end) }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** An MCP registry: its pin, and at a workspace root the member its launch names. Only the registries
|
|
291
|
+
* launch the server, so the markdown launch commands keep their wording. */
|
|
292
|
+
function repinRegistry(text: string, target: string, member?: string): PinScan {
|
|
293
|
+
const scan = repin(text, JSON_PIN, target)
|
|
294
|
+
if (member === undefined || scan.pins === 0) return scan
|
|
295
|
+
const { text: rewritten, named } = nameMember(scan.text, member)
|
|
296
|
+
return named ? { ...scan, text: rewritten, named: member } : scan
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Every file that carries the pin, and the part of it --sync-mcp may rewrite. */
|
|
300
|
+
const PIN_SITES: readonly {
|
|
301
|
+
readonly path: string
|
|
302
|
+
readonly repin: (text: string, target: string, member?: string) => PinScan
|
|
303
|
+
}[] = [
|
|
304
|
+
{ path: MCP_JSON_PATH, repin: repinRegistry },
|
|
305
|
+
{ path: CURSOR_MCP_JSON_PATH, repin: repinRegistry },
|
|
306
|
+
{ path: CLAUDE_MD_PATH, repin: (text, target) => repin(text, LAUNCH_PIN, target) },
|
|
307
|
+
{ path: AGENTS_MD_PATH, repin: repinMcpSection },
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
type PinState =
|
|
311
|
+
| { readonly kind: "skip"; readonly note: string }
|
|
312
|
+
| { readonly kind: "current" }
|
|
313
|
+
| {
|
|
314
|
+
readonly kind: "stale"
|
|
315
|
+
readonly abs: string
|
|
316
|
+
readonly mode: number
|
|
317
|
+
readonly scan: PinScan
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Where one file's pin stands against `target`. Never throws on a file it would not write: a
|
|
321
|
+
* symlinked CLAUDE.md (a common alias for AGENTS.md) is reported and left alone, not a hard stop. */
|
|
322
|
+
async function readPin(
|
|
323
|
+
cwd: string,
|
|
324
|
+
site: (typeof PIN_SITES)[number],
|
|
325
|
+
target: string,
|
|
326
|
+
member?: string,
|
|
327
|
+
): Promise<PinState> {
|
|
328
|
+
const abs = safeJoin(cwd, site.path)
|
|
329
|
+
try {
|
|
330
|
+
await assertSafeParents(cwd, abs)
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return {
|
|
333
|
+
kind: "skip",
|
|
334
|
+
note: `left alone - ${(error as Error).message.replace(/^\[nifra\] /, "")}`,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const stat = await lstatOrMissing(abs)
|
|
338
|
+
if (stat === undefined) return { kind: "skip", note: "absent - `nifra init-agents` creates it" }
|
|
339
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
340
|
+
return { kind: "skip", note: "not a regular file - left alone" }
|
|
341
|
+
// latin1 is one char per byte, so writing it back reproduces every byte outside a pin exactly,
|
|
342
|
+
// whatever the file's encoding; the pin patterns are ASCII, so they match as they would in UTF-8.
|
|
343
|
+
const scan = site.repin(await readFile(abs, "latin1"), target, member)
|
|
344
|
+
if (scan.pins === 0) return { kind: "skip", note: "no pinned @nifrajs/cli launch command" }
|
|
345
|
+
if (scan.stale.length === 0 && scan.named === undefined) return { kind: "current" }
|
|
346
|
+
return { kind: "stale", abs, mode: stat.mode & 0o777, scan }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The version --sync-mcp pins to: the nifra the project installs, so the server an agent launches
|
|
351
|
+
* describes the surface the code builds with. The running CLI's own version when nothing is
|
|
352
|
+
* installed. Not simply the running CLI's version: the command that fixes a drifted server is run
|
|
353
|
+
* by that drifted CLI, and it must not re-pin to itself.
|
|
354
|
+
*/
|
|
355
|
+
export async function mcpPinTarget(cwd: string): Promise<string> {
|
|
356
|
+
const installed = await installedNifraVersion(cwd)
|
|
357
|
+
if (installed !== undefined) return installed.version
|
|
358
|
+
// A workspace with isolated installs keeps nifra in the member's own node_modules.
|
|
359
|
+
const member = await workspaceMember(cwd)
|
|
360
|
+
const memberInstalled =
|
|
361
|
+
member === undefined ? undefined : await installedNifraVersion(resolve(cwd, member))
|
|
362
|
+
return memberInstalled?.version ?? MCP_CLI_VERSION
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** One file whose launch command pins a version other than {@link mcpPinTarget}. */
|
|
366
|
+
export interface StaleMcpPin {
|
|
367
|
+
readonly path: string
|
|
368
|
+
readonly pinned: readonly string[]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Read-only: every agent file whose pinned `@nifrajs/cli` is not the one the project installs. */
|
|
372
|
+
export async function collectStaleMcpPins(
|
|
373
|
+
cwd: string,
|
|
374
|
+
): Promise<{ readonly target: string; readonly files: readonly StaleMcpPin[] }> {
|
|
375
|
+
const target = await mcpPinTarget(cwd)
|
|
376
|
+
const files: StaleMcpPin[] = []
|
|
377
|
+
for (const site of PIN_SITES) {
|
|
378
|
+
const state = await readPin(cwd, site, target)
|
|
379
|
+
if (state.kind === "stale") files.push({ path: site.path, pinned: state.scan.stale })
|
|
380
|
+
}
|
|
381
|
+
return { target, files }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** --sync-mcp: rewrite each stale pin to the project's version and touch nothing else. Creates no
|
|
385
|
+
* file and appends no section - a file without a pin is reported, not repaired. */
|
|
386
|
+
async function syncMcpPins(cwd: string): Promise<InitAgentsResult> {
|
|
387
|
+
const target = await mcpPinTarget(cwd)
|
|
388
|
+
const member = await workspaceMember(cwd)
|
|
389
|
+
const files: InitAgentsFileResult[] = []
|
|
390
|
+
for (const site of PIN_SITES) {
|
|
391
|
+
const state = await readPin(cwd, site, target, member)
|
|
392
|
+
if (state.kind === "skip") {
|
|
393
|
+
files.push({ path: site.path, action: "skipped", note: state.note })
|
|
394
|
+
} else if (state.kind === "current") {
|
|
395
|
+
files.push({ path: site.path, action: "present", note: `already pinned to ${target}` })
|
|
396
|
+
} else {
|
|
397
|
+
await atomicWrite(state.abs, Buffer.from(state.scan.text, "latin1"), state.mode)
|
|
398
|
+
const notes = [
|
|
399
|
+
...(state.scan.stale.length > 0 ? [`${state.scan.stale.join(", ")} -> ${target}`] : []),
|
|
400
|
+
...(state.scan.named !== undefined ? [`launches \`mcp ${state.scan.named}\``] : []),
|
|
401
|
+
]
|
|
402
|
+
files.push({ path: site.path, action: "synced", note: notes.join("; ") })
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { cwd, files, syncedTo: target }
|
|
177
406
|
}
|
|
178
407
|
|
|
179
408
|
/**
|
|
180
|
-
* Retrofit `cwd` with the
|
|
409
|
+
* Retrofit `cwd` with the agent files. Pure enough to unit-test (no argv, no process.exit,
|
|
181
410
|
* no console) - the CLI wrapper handles printing + the exit code.
|
|
182
411
|
*/
|
|
183
412
|
export async function initAgents(
|
|
@@ -185,20 +414,45 @@ export async function initAgents(
|
|
|
185
414
|
opts: InitAgentsOptions = {},
|
|
186
415
|
): Promise<InitAgentsResult> {
|
|
187
416
|
const force = opts.force ?? false
|
|
188
|
-
|
|
417
|
+
if (opts.syncMcp === true) {
|
|
418
|
+
// --force rewrites whole files; --sync-mcp exists to promise it will not. Combined, one of the
|
|
419
|
+
// two promises breaks, so refuse rather than pick.
|
|
420
|
+
if (force) throw new Error("[nifra] --sync-mcp cannot be combined with --force")
|
|
421
|
+
return syncMcpPins(cwd)
|
|
422
|
+
}
|
|
423
|
+
// Order: the two MCP registries and each agent's pointer (owned, no-clobber), then AGENTS.md (additive).
|
|
424
|
+
const member = await workspaceMember(cwd)
|
|
425
|
+
const registry = member === undefined ? mcpJson() : nameMember(mcpJson(), member).text
|
|
189
426
|
const files: InitAgentsFileResult[] = []
|
|
190
|
-
files.push(await writeOwned(cwd, MCP_JSON_PATH,
|
|
191
|
-
files.push(await writeOwned(cwd, CURSOR_MCP_JSON_PATH,
|
|
192
|
-
|
|
427
|
+
files.push(await writeOwned(cwd, MCP_JSON_PATH, registry, force))
|
|
428
|
+
files.push(await writeOwned(cwd, CURSOR_MCP_JSON_PATH, registry, force))
|
|
429
|
+
for (const pointer of AGENT_POINTERS) {
|
|
430
|
+
files.push(await writeOwned(cwd, pointer.path, pointer.content(), force))
|
|
431
|
+
}
|
|
193
432
|
files.push(await ensureAgentsMd(cwd))
|
|
194
|
-
|
|
433
|
+
// A file the run left alone may still launch an old CLI. Say so where the file is reported, with
|
|
434
|
+
// the command that fixes only that. Advisory: a file it cannot read costs the hint, not the run.
|
|
435
|
+
const { target, files: stale } = await collectStaleMcpPins(cwd).catch(() => ({
|
|
436
|
+
target: "",
|
|
437
|
+
files: [] as readonly StaleMcpPin[],
|
|
438
|
+
}))
|
|
439
|
+
return {
|
|
440
|
+
cwd,
|
|
441
|
+
files: files.map((file) => {
|
|
442
|
+
const pin = stale.find((entry) => entry.path === file.path)
|
|
443
|
+
if (pin === undefined || (file.action !== "skipped" && file.action !== "present")) return file
|
|
444
|
+
const hint = `pinned to ${pin.pinned.join(", ")}; \`nifra init-agents --sync-mcp\` re-pins it to ${target}`
|
|
445
|
+
return { ...file, note: file.note === undefined ? hint : `${file.note}; ${hint}` }
|
|
446
|
+
}),
|
|
447
|
+
}
|
|
195
448
|
}
|
|
196
449
|
|
|
197
450
|
const ACTION_GLYPH: Readonly<Record<InitAgentsFileResult["action"], string>> = {
|
|
198
451
|
wrote: "✓ wrote",
|
|
199
|
-
appended: "✓ appended
|
|
452
|
+
appended: "✓ appended to",
|
|
200
453
|
skipped: "• skipped",
|
|
201
454
|
present: "• kept",
|
|
455
|
+
synced: "✓ re-pinned",
|
|
202
456
|
}
|
|
203
457
|
|
|
204
458
|
/** Format the retrofit result for the terminal. */
|
|
@@ -207,6 +461,12 @@ export function renderInitAgents(result: InitAgentsResult): string {
|
|
|
207
461
|
const tail = f.note ? ` (${f.note})` : ""
|
|
208
462
|
return ` ${ACTION_GLYPH[f.action]} ${f.path}${tail}`
|
|
209
463
|
})
|
|
464
|
+
if (result.syncedTo !== undefined) {
|
|
465
|
+
const footer = result.files.some((f) => f.action === "synced")
|
|
466
|
+
? `\nThe nifra MCP launch now pins @nifrajs/cli@${result.syncedTo}. Restart your agent so it respawns the server.`
|
|
467
|
+
: `\nNothing to re-pin - no file pins a version other than @nifrajs/cli@${result.syncedTo}.`
|
|
468
|
+
return `nifra init-agents --sync-mcp\n\n${lines.join("\n")}\n${footer}`
|
|
469
|
+
}
|
|
210
470
|
const wroteAny = result.files.some((f) => f.action === "wrote" || f.action === "appended")
|
|
211
471
|
const footer = wroteAny
|
|
212
472
|
? "\nThe nifra MCP is now registered. Restart your agent so it picks up .mcp.json, then prefer nifra_docs / nifra_example and gate on nifra check."
|
|
@@ -221,9 +481,12 @@ export function renderInitAgents(result: InitAgentsResult): string {
|
|
|
221
481
|
*/
|
|
222
482
|
export async function runInitAgents(
|
|
223
483
|
cwd: string,
|
|
224
|
-
opts: { readonly json?: boolean; readonly force?: boolean } = {},
|
|
484
|
+
opts: { readonly json?: boolean; readonly force?: boolean; readonly syncMcp?: boolean } = {},
|
|
225
485
|
): Promise<boolean> {
|
|
226
|
-
const result = await initAgents(cwd, {
|
|
486
|
+
const result = await initAgents(cwd, {
|
|
487
|
+
force: opts.force ?? false,
|
|
488
|
+
syncMcp: opts.syncMcp ?? false,
|
|
489
|
+
})
|
|
227
490
|
if (opts.json) {
|
|
228
491
|
console.log(JSON.stringify(result, null, 2))
|
|
229
492
|
} else {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Code-unit order, the same on every runtime and in every locale. A file a project commits (a lock, a
|
|
2
|
+
* generated SDK) sorts with this, so its bytes do not depend on the machine that wrote it. */
|
|
3
|
+
export function codeUnitOrder(a: string, b: string): number {
|
|
4
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
5
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type * as TSApi from "typescript"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The parser mode for a source file, by its extension. A `.ts` file is not parsed as TSX: there
|
|
5
|
+
* `<T>(x: T) => x` and `<Type>value` read as JSX, and parser recovery loses what follows them.
|
|
6
|
+
*/
|
|
7
|
+
export function scriptKindOf(ts: typeof TSApi, file: string): TSApi.ScriptKind {
|
|
8
|
+
if (file.endsWith(".tsx")) return ts.ScriptKind.TSX
|
|
9
|
+
if (/\.[cm]?ts$/.test(file)) return ts.ScriptKind.TS
|
|
10
|
+
return file.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.JS
|
|
11
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as TSApi from "typescript"
|
|
2
|
+
import { scriptKindOf } from "./script-kind.ts"
|
|
2
3
|
import type { TypeScriptApi } from "./typescript-import.ts"
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,7 +20,22 @@ export interface SourceFacts {
|
|
|
19
20
|
method: string,
|
|
20
21
|
path: string,
|
|
21
22
|
): boolean | undefined
|
|
23
|
+
/** A call to the free function `callee` whose argument at `pathIndex` is the literal `path`. */
|
|
24
|
+
isFunctionRouteCallAt(
|
|
25
|
+
source: TSApi.SourceFile,
|
|
26
|
+
position: number,
|
|
27
|
+
callee: string,
|
|
28
|
+
pathIndex: number,
|
|
29
|
+
path: string,
|
|
30
|
+
): boolean | undefined
|
|
22
31
|
isResponseSyntaxAt(source: TSApi.SourceFile, position: number): boolean | undefined
|
|
32
|
+
/** Whether the `import` keyword at `position` starts a runtime `import(specifier)` call (true) or a
|
|
33
|
+
* type-position `import("…")` such as `typeof import("x")` (false); undefined when neither. */
|
|
34
|
+
isDynamicImportAt(
|
|
35
|
+
source: TSApi.SourceFile,
|
|
36
|
+
position: number,
|
|
37
|
+
specifier: string,
|
|
38
|
+
): boolean | undefined
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
function nodeAt(ts: TypeScriptApi, source: TSApi.SourceFile, position: number): TSApi.Node {
|
|
@@ -33,12 +49,12 @@ function nodeAt(ts: TypeScriptApi, source: TSApi.SourceFile, position: number):
|
|
|
33
49
|
return best
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
function ancestorAt(
|
|
52
|
+
function ancestorAt<T extends TSApi.Node>(
|
|
37
53
|
ts: TypeScriptApi,
|
|
38
54
|
source: TSApi.SourceFile,
|
|
39
55
|
position: number,
|
|
40
|
-
predicate: (node: TSApi.Node) =>
|
|
41
|
-
):
|
|
56
|
+
predicate: (node: TSApi.Node) => node is T,
|
|
57
|
+
): T | undefined {
|
|
42
58
|
let node: TSApi.Node = nodeAt(ts, source, Math.max(0, Math.min(position, source.end - 1)))
|
|
43
59
|
for (;;) {
|
|
44
60
|
if (predicate(node)) return node
|
|
@@ -65,12 +81,19 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
65
81
|
const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
|
|
66
82
|
const cached = cache.get(file)
|
|
67
83
|
if (cached?.content === content) return cached.source
|
|
68
|
-
const kind =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
source
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
const kind = scriptKindOf(ts, file)
|
|
85
|
+
let parsed: TSApi.SourceFile | undefined
|
|
86
|
+
try {
|
|
87
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
88
|
+
const diagnostics = (
|
|
89
|
+
source as TSApi.SourceFile & { parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
90
|
+
).parseDiagnostics
|
|
91
|
+
parsed = diagnostics !== undefined && diagnostics.length > 0 ? undefined : source
|
|
92
|
+
} catch {
|
|
93
|
+
// TypeScript 7 parses only the files its session preloaded; a module reached by following an
|
|
94
|
+
// import may not be one. Callers fall back to the lexical rule for a file with no tree.
|
|
95
|
+
parsed = undefined
|
|
96
|
+
}
|
|
74
97
|
cache.set(file, { content, source: parsed })
|
|
75
98
|
return parsed
|
|
76
99
|
}
|
|
@@ -80,9 +103,18 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
80
103
|
position: number,
|
|
81
104
|
specifier: string,
|
|
82
105
|
): boolean | undefined => {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
const reexport = ancestorAt(ts, source, position, ts.isExportDeclaration)
|
|
107
|
+
if (reexport !== undefined) {
|
|
108
|
+
const from = reexport.moduleSpecifier
|
|
109
|
+
if (from === undefined || !ts.isStringLiteral(from) || from.text !== specifier)
|
|
110
|
+
return undefined
|
|
111
|
+
if (reexport.isTypeOnly) return false
|
|
112
|
+
const named = reexport.exportClause
|
|
113
|
+
if (named !== undefined && ts.isNamedExports(named))
|
|
114
|
+
return named.elements.some((element) => !element.isTypeOnly)
|
|
115
|
+
return true // `export *` and `export * as ns`
|
|
116
|
+
}
|
|
117
|
+
const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration)
|
|
86
118
|
if (declaration === undefined || !ts.isStringLiteral(declaration.moduleSpecifier))
|
|
87
119
|
return undefined
|
|
88
120
|
if (declaration.moduleSpecifier.text !== specifier) return undefined
|
|
@@ -101,9 +133,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
101
133
|
method: string,
|
|
102
134
|
path: string,
|
|
103
135
|
): boolean | undefined => {
|
|
104
|
-
const call = ancestorAt(ts, source, position, ts.isCallExpression)
|
|
105
|
-
| TSApi.CallExpression
|
|
106
|
-
| undefined
|
|
136
|
+
const call = ancestorAt(ts, source, position, ts.isCallExpression)
|
|
107
137
|
if (call === undefined || !ts.isPropertyAccessExpression(call.expression)) return false
|
|
108
138
|
const name = call.expression.name.text.toUpperCase()
|
|
109
139
|
const first = call.arguments[0]
|
|
@@ -112,22 +142,66 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
112
142
|
)
|
|
113
143
|
}
|
|
114
144
|
|
|
145
|
+
const isFunctionRouteCallAt = (
|
|
146
|
+
source: TSApi.SourceFile,
|
|
147
|
+
position: number,
|
|
148
|
+
callee: string,
|
|
149
|
+
pathIndex: number,
|
|
150
|
+
path: string,
|
|
151
|
+
): boolean | undefined => {
|
|
152
|
+
const call = ancestorAt(ts, source, position, ts.isCallExpression)
|
|
153
|
+
if (call === undefined || !ts.isIdentifier(call.expression)) return false
|
|
154
|
+
const argument = call.arguments[pathIndex]
|
|
155
|
+
return (
|
|
156
|
+
call.expression.text === callee &&
|
|
157
|
+
argument !== undefined &&
|
|
158
|
+
ts.isStringLiteralLike(argument) &&
|
|
159
|
+
argument.text === path
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
115
163
|
const isResponseSyntaxAt = (source: TSApi.SourceFile, position: number): boolean | undefined => {
|
|
116
|
-
const returnStatement = ancestorAt(ts, source, position, ts.isReturnStatement)
|
|
117
|
-
| TSApi.ReturnStatement
|
|
118
|
-
| undefined
|
|
164
|
+
const returnStatement = ancestorAt(ts, source, position, ts.isReturnStatement)
|
|
119
165
|
if (returnStatement !== undefined) {
|
|
120
166
|
return (
|
|
121
167
|
returnStatement.expression !== undefined &&
|
|
122
168
|
rawResponseExpression(ts, returnStatement.expression)
|
|
123
169
|
)
|
|
124
170
|
}
|
|
125
|
-
const arrow = ancestorAt(ts, source, position, ts.isArrowFunction)
|
|
126
|
-
| TSApi.ArrowFunction
|
|
127
|
-
| undefined
|
|
171
|
+
const arrow = ancestorAt(ts, source, position, ts.isArrowFunction)
|
|
128
172
|
if (arrow !== undefined && !ts.isBlock(arrow.body)) return rawResponseExpression(ts, arrow.body)
|
|
129
173
|
return false
|
|
130
174
|
}
|
|
131
175
|
|
|
132
|
-
|
|
176
|
+
const isDynamicImportAt = (
|
|
177
|
+
source: TSApi.SourceFile,
|
|
178
|
+
position: number,
|
|
179
|
+
specifier: string,
|
|
180
|
+
): boolean | undefined => {
|
|
181
|
+
let node: TSApi.Node = nodeAt(ts, source, Math.max(0, Math.min(position, source.end - 1)))
|
|
182
|
+
for (;;) {
|
|
183
|
+
if (ts.isImportTypeNode(node)) return false
|
|
184
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
185
|
+
const argument = node.arguments[0]
|
|
186
|
+
if (
|
|
187
|
+
argument === undefined ||
|
|
188
|
+
!(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
189
|
+
) {
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
return argument.text === specifier ? true : undefined
|
|
193
|
+
}
|
|
194
|
+
if (node === source) return undefined
|
|
195
|
+
node = node.parent
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
parse,
|
|
201
|
+
isValueImportAt,
|
|
202
|
+
isRouteRegistrationAt,
|
|
203
|
+
isFunctionRouteCallAt,
|
|
204
|
+
isResponseSyntaxAt,
|
|
205
|
+
isDynamicImportAt,
|
|
206
|
+
}
|
|
133
207
|
}
|