@ultimat3/cli 29.3.2 → 30.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/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The app's own `POST /mcp`, as `x new` writes it: `apps/web/mcp.ts` — the file and export the
|
|
2
|
+
// mount reads (`app-mcp.ts`) — over the `@<app>/mcp` catalog, and the token resolver that makes it a
|
|
3
|
+
// route. Mounted and REFUSING: no bearer, or one nobody issued, is a 401 before a tool is read. The
|
|
4
|
+
// development token works under `x dev` alone, and its test says so in every other environment.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
|
|
8
|
+
const endpoint = (
|
|
9
|
+
app: NameSet,
|
|
10
|
+
): string => `// The app's own MCP endpoint: \`POST /mcp\`, mounted by \`x dev\` and by the web role, which find
|
|
11
|
+
// this file by its path and its \`mcp\` export. The tools are \`@${app.kebab}/mcp\`'s catalog; who may
|
|
12
|
+
// call them is \`resolveAgentToken\`. Delete this file to unmount it — the boot then says
|
|
13
|
+
// X_MCP_APP_UNMOUNTED — or set \`ai: { mcp: { expose: false } }\` in app.config.ts.
|
|
14
|
+
//
|
|
15
|
+
// Built when the boot scan reaches this file, after \`apps/*/api\` registered its actions: a tool
|
|
16
|
+
// declared later in a running \`x dev\` is on \`POST /mcp\` from its next restart.
|
|
17
|
+
import { appMcp } from '@${app.kebab}/mcp';
|
|
18
|
+
import { resolveAgentToken } from './app/auth/agent-token';
|
|
19
|
+
|
|
20
|
+
export const mcp = appMcp({ resolveToken: resolveAgentToken });
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const endpointTest =
|
|
24
|
+
(): string => `// The endpoint is mounted and refusing: a route at /mcp, and a 401 for a request that carries no
|
|
25
|
+
// token or one this process does not honour — before any tool, or the body, is read.
|
|
26
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
27
|
+
import { DEV_AGENT_TOKEN_PREFIX } from './app/auth/agent-token';
|
|
28
|
+
import { mcp } from './mcp';
|
|
29
|
+
|
|
30
|
+
const post = (headers: Record<string, string>) =>
|
|
31
|
+
new Request('http://localhost/mcp', { method: 'POST', headers, body: '{}' });
|
|
32
|
+
|
|
33
|
+
unitTest('POST /mcp is mounted, and answers 401 without a token', async () => {
|
|
34
|
+
const route = mcp.route;
|
|
35
|
+
if (route === undefined) return expect.unreachable('apps/web/mcp.ts built no route');
|
|
36
|
+
expect(route.path).toBe('/mcp');
|
|
37
|
+
const response = await route.handle(post({}));
|
|
38
|
+
expect(response.status).toBe(401);
|
|
39
|
+
// The development token is refused outside \`x dev\`: \`bun test\` is not development.
|
|
40
|
+
const dev = await route.handle(post({ authorization: \`Bearer \${DEV_AGENT_TOKEN_PREFIX}admin\` }));
|
|
41
|
+
expect(dev.status).toBe(401);
|
|
42
|
+
});
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const agentToken =
|
|
46
|
+
(): string => `// Who an MCP caller is, for \`apps/web/mcp.ts\`: an API key issued through \`@ultimat3/auth\` —
|
|
47
|
+
// \`issueApiKey\`, stored in the framework's \`x_api_keys\` — resolved to an agent actor, in every
|
|
48
|
+
// environment. Under \`x dev\` alone, \`dev-agent.<role>\` is also an agent holding that declared role,
|
|
49
|
+
// so a local client can call the app's tools before the app issues keys. It FAILS CLOSED: a process
|
|
50
|
+
// naming no environment is production, where that token is refused like any unknown one.
|
|
51
|
+
import { type Auth, apiKeyResolver } from '@ultimat3/auth';
|
|
52
|
+
import { type Actor, agentActor, peerAddressClass, tryResolveEnvironment } from '@ultimat3/core';
|
|
53
|
+
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
54
|
+
import { roles } from '../../shared/roles';
|
|
55
|
+
import { appAuth } from './auth';
|
|
56
|
+
|
|
57
|
+
/** \`Authorization: Bearer dev-agent.admin\` under \`x dev\`; refused everywhere else. */
|
|
58
|
+
export const DEV_AGENT_TOKEN_PREFIX = 'dev-agent.';
|
|
59
|
+
|
|
60
|
+
/** What \`defineAppMcp\`'s \`resolveToken\` answers: an agent, and the scopes its connection holds. */
|
|
61
|
+
export interface AgentCaller {
|
|
62
|
+
readonly actor: Actor;
|
|
63
|
+
readonly scopes: ReadonlySet<string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The development agent a token names, or \`null\` for a role \`shared/roles.ts\` never declared. */
|
|
67
|
+
export const devAgentFor = (token: string): AgentCaller | null => {
|
|
68
|
+
const role = token.slice(DEV_AGENT_TOKEN_PREFIX.length);
|
|
69
|
+
// \`Object.hasOwn\`, never \`in\`: a token saying \`constructor\` names no role.
|
|
70
|
+
if (!Object.hasOwn(roles, role)) return null;
|
|
71
|
+
return {
|
|
72
|
+
actor: agentActor({ id: 'dev-agent', orgId: DEMO_ORG_ID, roles: [role] }),
|
|
73
|
+
scopes: new Set(),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface AgentTokenOptions {
|
|
78
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
79
|
+
/** The app's \`Auth\`, asked for when a key is presented, never at import. */
|
|
80
|
+
readonly auth: () => Auth;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How the request arrived: the two facts \`defineAppMcp\` hands every resolver that this one reads. */
|
|
84
|
+
export interface CallerFacts {
|
|
85
|
+
/** The \`Origin\` header: a browser sends one on every POST, a local MCP client none. */
|
|
86
|
+
readonly origin: string | null;
|
|
87
|
+
/** The caller's address as the host resolved it. */
|
|
88
|
+
readonly address: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The development token is honoured only from this machine and never from a browser: a page that
|
|
93
|
+
* rebinds its name to 127.0.0.1 reaches \`x dev\` over loopback, but it sends \`Origin\`.
|
|
94
|
+
*/
|
|
95
|
+
const localClient = (facts: CallerFacts | undefined): boolean =>
|
|
96
|
+
facts !== undefined &&
|
|
97
|
+
facts.origin === null &&
|
|
98
|
+
facts.address !== null &&
|
|
99
|
+
peerAddressClass(facts.address) === 'loopback';
|
|
100
|
+
|
|
101
|
+
/** The resolver, over an environment and an \`Auth\` — so its test chooses both. */
|
|
102
|
+
export const agentTokenResolver = (options: AgentTokenOptions) => {
|
|
103
|
+
const keys = apiKeyResolver(() => options.auth().adapter);
|
|
104
|
+
return async (token: string, facts?: CallerFacts): Promise<AgentCaller | null> => {
|
|
105
|
+
if (!token.startsWith(DEV_AGENT_TOKEN_PREFIX)) return await keys(token);
|
|
106
|
+
const environment = tryResolveEnvironment({ env: options.env, fallback: 'production' });
|
|
107
|
+
return environment === 'development' && localClient(facts) ? devAgentFor(token) : null;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const resolveAgentToken = agentTokenResolver({ env: process.env, auth: appAuth });
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const agentTokenTest =
|
|
115
|
+
(): string => `// An issued key is an agent of its org everywhere; the development token is an agent under
|
|
116
|
+
// \`x dev\` and nothing anywhere else — including a process that names no environment at all.
|
|
117
|
+
import { defineAuth, issueApiKey, memoryAuthAdapter } from '@ultimat3/auth';
|
|
118
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
119
|
+
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
120
|
+
import { agentTokenResolver, DEV_AGENT_TOKEN_PREFIX, resolveAgentToken } from './agent-token';
|
|
121
|
+
|
|
122
|
+
/** In memory: the key store is the package's, and this test is about the wiring around it. */
|
|
123
|
+
const memoryAuth = () => {
|
|
124
|
+
const auth = defineAuth({ adapter: memoryAuthAdapter() });
|
|
125
|
+
return () => auth;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
unitTest('an issued API key is an agent of its org, in every environment', async () => {
|
|
129
|
+
const auth = memoryAuth();
|
|
130
|
+
const issued = issueApiKey({ env: 'dev', scopes: [], orgId: DEMO_ORG_ID });
|
|
131
|
+
await auth().adapter.putApiKey(issued.record);
|
|
132
|
+
for (const ULTIMATE_ENV of ['development', 'production']) {
|
|
133
|
+
const caller = await agentTokenResolver({ env: { ULTIMATE_ENV }, auth })(issued.plaintext);
|
|
134
|
+
expect(caller?.actor.kind).toBe('agent');
|
|
135
|
+
expect(caller?.actor.orgId).toBe(DEMO_ORG_ID);
|
|
136
|
+
}
|
|
137
|
+
// One character off the secret is nobody.
|
|
138
|
+
const resolve = agentTokenResolver({ env: {}, auth });
|
|
139
|
+
expect(await resolve(\`\${issued.plaintext}x\`)).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/** A local, non-browser client: what \`x dev\`'s own MCP clients are. */
|
|
143
|
+
const LOCAL = { origin: null, address: '127.0.0.1' };
|
|
144
|
+
|
|
145
|
+
unitTest('dev-agent.<role> is that declared role under x dev, and nobody elsewhere', async () => {
|
|
146
|
+
const auth = memoryAuth();
|
|
147
|
+
const dev = agentTokenResolver({ env: { ULTIMATE_ENV: 'development' }, auth });
|
|
148
|
+
const admin = \`\${DEV_AGENT_TOKEN_PREFIX}admin\`;
|
|
149
|
+
expect((await dev(admin, LOCAL))?.actor.roles).toEqual(['admin']);
|
|
150
|
+
expect(await dev(\`\${DEV_AGENT_TOKEN_PREFIX}nobody\`, LOCAL)).toBeNull();
|
|
151
|
+
expect(await dev(\`\${DEV_AGENT_TOKEN_PREFIX}constructor\`, LOCAL)).toBeNull();
|
|
152
|
+
for (const env of [{ ULTIMATE_ENV: 'production' }, { ULTIMATE_ENV: 'staging' }, {}]) {
|
|
153
|
+
expect(await agentTokenResolver({ env, auth })(admin, LOCAL)).toBeNull();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
unitTest('the development token is refused from a browser or from off this machine', async () => {
|
|
158
|
+
// A hostile page that rebinds its name to 127.0.0.1 reaches \`x dev\` from a loopback socket,
|
|
159
|
+
// but a browser sends Origin on every POST; another machine on the network has no loopback address.
|
|
160
|
+
const dev = agentTokenResolver({ env: { ULTIMATE_ENV: 'development' }, auth: memoryAuth() });
|
|
161
|
+
const admin = \`\${DEV_AGENT_TOKEN_PREFIX}admin\`;
|
|
162
|
+
expect(await dev(admin, { origin: 'http://rebound.test:3000', address: '127.0.0.1' })).toBeNull();
|
|
163
|
+
expect(await dev(admin, { origin: 'null', address: '::1' })).toBeNull();
|
|
164
|
+
expect(await dev(admin, { origin: null, address: '192.168.1.20' })).toBeNull();
|
|
165
|
+
expect(await dev(admin, { origin: null, address: null })).toBeNull();
|
|
166
|
+
expect(await dev(admin)).toBeNull();
|
|
167
|
+
expect((await dev(admin, { origin: null, address: '::1' }))?.actor.kind).toBe('agent');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
unitTest('the installed resolver refuses the development token under bun test', async () => {
|
|
171
|
+
// \`bun test\` sets NODE_ENV=test: not development, so no key store is ever asked.
|
|
172
|
+
expect(await resolveAgentToken(\`\${DEV_AGENT_TOKEN_PREFIX}admin\`)).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
/** `apps/web/mcp.ts`, the resolver it mounts with, and the tests that pin both refusing. */
|
|
177
|
+
export const mcpEndpointFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
178
|
+
{ path: 'apps/web/mcp.ts', contents: endpoint(app) },
|
|
179
|
+
{ path: 'apps/web/mcp.test.ts', contents: endpointTest() },
|
|
180
|
+
{ path: 'apps/web/app/auth/agent-token.ts', contents: agentToken() },
|
|
181
|
+
{ path: 'apps/web/app/auth/agent-token.test.ts', contents: agentTokenTest() },
|
|
182
|
+
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `.mcp.json`: the one MCP client config `x new` writes, in the format several clients read. It
|
|
2
|
+
// starts `x mcp serve` over stdio — the dev tools — through the SCOPED package, because a client may
|
|
3
|
+
// spawn the command outside the installed app, and there `bunx x` fetches an unrelated npm package.
|
|
4
|
+
// A client that wants another file is told how in `wiki/MCP-And-AI.md`, never generated for.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
|
|
8
|
+
export const MCP_JSON_PATH = '.mcp.json';
|
|
9
|
+
|
|
10
|
+
const COMMAND = 'bunx';
|
|
11
|
+
const ARGS = ['@ultimat3/cli', 'mcp', 'serve', '--transport', 'stdio'] as const;
|
|
12
|
+
|
|
13
|
+
/** The line the file encodes, as a shell would run it — what the docs and the smoke both quote. */
|
|
14
|
+
export const MCP_SERVE_COMMAND = [COMMAND, ...ARGS].join(' ');
|
|
15
|
+
|
|
16
|
+
/** The file itself, Biome-formatted: one server, keyed by the app's name. */
|
|
17
|
+
export function mcpJsonFile(app: NameSet): GeneratedFile {
|
|
18
|
+
const args = ARGS.map((arg) => `"${arg}"`).join(', ');
|
|
19
|
+
return {
|
|
20
|
+
path: MCP_JSON_PATH,
|
|
21
|
+
contents: `{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"${app.kebab}": {
|
|
24
|
+
"command": "${COMMAND}",
|
|
25
|
+
"args": [${args}]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -1,85 +1,88 @@
|
|
|
1
1
|
// The generated app's `packages/mcp`: the app's own MCP surface, projected straight from the
|
|
2
2
|
// action registry rather than re-listed, plus the test that pins every projected tool describing
|
|
3
|
-
// itself.
|
|
3
|
+
// itself — and the `apps/web/mcp.ts` endpoint that mounts it (`scaffold-mcp-endpoint.ts`).
|
|
4
4
|
|
|
5
|
-
import { sortedImports } from './imports';
|
|
6
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
-
import {
|
|
6
|
+
import { mcpEndpointFiles } from './scaffold-mcp-endpoint';
|
|
7
|
+
import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
|
|
8
8
|
|
|
9
9
|
const DESCRIPTION = "The app's own MCP tools";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Writes its own manifest, for the reason `scaffold-i18n.ts` does: `workspacePackageJson` is the
|
|
13
|
-
* dependency-free shape, and this package has a real dependency — `src/index.ts` below imports the
|
|
14
|
-
* app's actions out of `apps/web/api`, because `registerActions` has to see them.
|
|
15
|
-
*
|
|
16
|
-
* The edge points AT the app, which is unusual and correct. `apps/web` is a workspace like any
|
|
17
|
-
* other; reversing it would put the tool catalog upstream of the actions it projects. Declared
|
|
18
|
-
* rather than left to the root tsconfig's `paths`: an undeclared edge resolves for `tsc` and for
|
|
19
|
-
* nothing else — not for `bun --filter` ordering, not for any tool asking what a change affects
|
|
20
|
-
* (`X_WORKSPACE_DEP_UNDECLARED`).
|
|
21
|
-
*
|
|
22
|
-
* `"0.0.0"` and not `workspace:*`: it is the version `apps/web` really carries, which is what
|
|
23
|
-
* `checkLockstep` compares a sibling pin against, and it is the one spelling every other manifest
|
|
24
|
-
* `x new` writes already uses.
|
|
25
|
-
*/
|
|
26
|
-
const mcpPackage = (app: NameSet): string => `{
|
|
27
|
-
"name": "@${app.kebab}/mcp",
|
|
28
|
-
"version": "0.0.0",
|
|
29
|
-
"private": true,
|
|
30
|
-
"type": "module",
|
|
31
|
-
"description": "${DESCRIPTION}",
|
|
32
|
-
"exports": {
|
|
33
|
-
".": "./src/index.ts"
|
|
34
|
-
},
|
|
35
|
-
"scripts": {
|
|
36
|
-
"typecheck": "tsc --noEmit -p ../../tsconfig.json"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@${app.kebab}/web": "0.0.0"
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
`;
|
|
43
|
-
|
|
44
11
|
const mcpIndex = (
|
|
45
12
|
app: NameSet,
|
|
46
13
|
): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific
|
|
47
14
|
// read-only helpers here. Authorization is the action's policy, unchanged.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
15
|
+
//
|
|
16
|
+
// A FUNCTION, and it imports no app (X_BOUNDARY_PACKAGE_TO_APP): \`defineAppMcp\` reads the registry
|
|
17
|
+
// where it is called, and \`runRole()\` and \`loadApp()\` both fill that registry before anything
|
|
18
|
+
// asks for the catalog. A module-scope call froze whatever had registered by import time.
|
|
19
|
+
import { defineAppMcp, type McpRequestFacts, type ResolvedToken } from '@ultimat3/mcp';
|
|
53
20
|
|
|
54
|
-
|
|
55
|
-
|
|
21
|
+
/** Who a bearer token is, or \`null\` for nobody — answered 401. */
|
|
22
|
+
export type TokenResolver = (
|
|
23
|
+
token: string,
|
|
24
|
+
facts: McpRequestFacts,
|
|
25
|
+
) => Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
26
|
+
|
|
27
|
+
export interface AppMcpOptions {
|
|
28
|
+
/** \`apps/web/mcp.ts\` passes the app's resolver; without one there is no route. */
|
|
29
|
+
readonly resolveToken?: TokenResolver | undefined;
|
|
30
|
+
}
|
|
56
31
|
|
|
57
32
|
// \`include: 'exposed'\` projects straight from the registry. Re-listing the actions here would
|
|
58
33
|
// copy \`mcp: { expose: true }\` into a second place, and the copy goes stale in silence.
|
|
59
|
-
export const
|
|
60
|
-
|
|
61
|
-
|
|
34
|
+
export const appMcp = (options: AppMcpOptions = {}) =>
|
|
35
|
+
defineAppMcp({
|
|
36
|
+
name: '${app.kebab}',
|
|
37
|
+
include: 'exposed',
|
|
38
|
+
...(options.resolveToken === undefined ? {} : { resolveToken: options.resolveToken }),
|
|
39
|
+
});
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const mcpUnitTest =
|
|
43
|
+
(): string => `// The catalog is built where it is called, from whatever has registered by then: this file loads no
|
|
44
|
+
// app, so what it pins is the shape. The tools themselves are the contract test's, next door.
|
|
45
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
46
|
+
import { appMcp } from './index';
|
|
47
|
+
|
|
48
|
+
unitTest('the catalog is in process until a token resolver is given', () => {
|
|
49
|
+
// No \`resolveToken\` means no route: a mount with no bearer check is the failure this rules out.
|
|
50
|
+
expect(appMcp().route).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
unitTest('given one, it is a route at /mcp', () => {
|
|
54
|
+
expect(appMcp({ resolveToken: () => null }).route?.path).toBe('/mcp');
|
|
62
55
|
});
|
|
63
56
|
`;
|
|
64
57
|
|
|
65
58
|
const mcpTest =
|
|
66
|
-
(): string => `// The app exposes its actions as MCP tools, and each tool carries the action's own policy.
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
import {
|
|
59
|
+
(): string => `// The app exposes its actions as MCP tools, and each tool carries the action's own policy. The
|
|
60
|
+
// catalog reads a registry, so the honest proof fills it the way the process does: \`loadApp\` scans
|
|
61
|
+
// \`apps/*\` exactly as \`runRole()\` does. Booting the app is the fixture, so the timeout moves.
|
|
62
|
+
import { resolve } from 'node:path';
|
|
63
|
+
import { loadApp } from '@ultimat3/cli';
|
|
64
|
+
import { beforeAll, contractTest, expect } from '@ultimat3/testing';
|
|
65
|
+
import { appMcp } from './index';
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
await loadApp(resolve(import.meta.dir, '../../..'));
|
|
69
|
+
}, 30_000);
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
contractTest('the app exposes its actions as MCP tools', () => {
|
|
72
|
+
const tools = appMcp().tools;
|
|
73
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
73
74
|
// Every projected tool must describe itself: an agent picks a tool by its description. Assert
|
|
74
75
|
// on the value, not its length — a failure then prints the empty description, not "0 > 0".
|
|
75
|
-
for (const tool of
|
|
76
|
+
for (const tool of tools) expect(tool.description).not.toBe('');
|
|
76
77
|
});
|
|
77
78
|
`;
|
|
78
79
|
|
|
79
|
-
/** Every file the `packages/mcp` workspace ships,
|
|
80
|
+
/** Every file the `packages/mcp` workspace ships, then the endpoint that mounts it. */
|
|
80
81
|
export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
81
|
-
{ path: 'packages/mcp/package.json', contents:
|
|
82
|
+
{ path: 'packages/mcp/package.json', contents: workspacePackageJson(app, 'mcp', DESCRIPTION) },
|
|
82
83
|
...packageShapeFiles(app, 'mcp', DESCRIPTION),
|
|
83
84
|
{ path: 'packages/mcp/src/index.ts', contents: mcpIndex(app) },
|
|
84
|
-
{ path: 'packages/mcp/src/index.test.ts', contents:
|
|
85
|
+
{ path: 'packages/mcp/src/index.test.ts', contents: mcpUnitTest() },
|
|
86
|
+
{ path: 'packages/mcp/src/index.contract.test.ts', contents: mcpTest() },
|
|
87
|
+
...mcpEndpointFiles(app),
|
|
85
88
|
];
|
|
@@ -74,7 +74,6 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
74
74
|
"scripts": {
|
|
75
75
|
"setup": "${binScript(BIN_SCRIPTS.setup)}",
|
|
76
76
|
"dev": "x dev",
|
|
77
|
-
"check": "${binScript(BIN_SCRIPTS.check)}",
|
|
78
77
|
"verify": "x verify",
|
|
79
78
|
"typecheck": "tsc -p . --pretty",
|
|
80
79
|
"lint": "biome check .",
|
|
@@ -208,7 +207,8 @@ export const config = defineConfig({
|
|
|
208
207
|
// Locales are declared by the catalogs (\`defineCatalogs\` in \`packages/i18n/src/index.ts\`); no
|
|
209
208
|
// zone or currency default exists — every format call names its zone, every \`Money\` its currency.
|
|
210
209
|
// Env KEYS, never the value: the same image deploys to every environment. The database is
|
|
211
|
-
// configured
|
|
210
|
+
// configured from the environment — \`DATABASE_URL\` and \`DATABASE_POOL_MAX\`. Off by default,
|
|
211
|
+
// \`database: { rowSecurity: true }\` adds the database's own tenant guard under the repository's.
|
|
212
212
|
// The tiers ARE the cache selection: add 'redis' to build the shared rung, which reads
|
|
213
213
|
// \`REDIS_URL\` and refuses the boot when it is unset.
|
|
214
214
|
cache: { tiers: ['request-memo', 'lru'] },
|
|
@@ -340,6 +340,8 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
|
|
340
340
|
'errors',
|
|
341
341
|
'unit',
|
|
342
342
|
'contract',
|
|
343
|
+
// Every app has an `app.config.ts` and primitives to run: `x new` writes `apps/web/exercise.ts`.
|
|
344
|
+
'exercise',
|
|
343
345
|
'eval',
|
|
344
346
|
'drift',
|
|
345
347
|
'budgets',
|
|
@@ -387,6 +389,8 @@ const verifyFloor = (example: boolean): string =>
|
|
|
387
389
|
funcs: COVERAGE_BAR,
|
|
388
390
|
exclude: SCAFFOLD_COVERAGE_EXCLUDE,
|
|
389
391
|
},
|
|
392
|
+
// The rolling rail judges from the first migration: a new app has nothing to grandfather.
|
|
393
|
+
rollingSince: '0',
|
|
390
394
|
},
|
|
391
395
|
null,
|
|
392
396
|
2,
|
|
@@ -433,6 +437,7 @@ packages/*/dist/
|
|
|
433
437
|
*.tsbuildinfo
|
|
434
438
|
.env
|
|
435
439
|
.env.*.local
|
|
440
|
+
.env.production
|
|
436
441
|
/coverage/
|
|
437
442
|
playwright-report/
|
|
438
443
|
test-results/
|
|
@@ -14,7 +14,9 @@ const SHELL_DIR = 'apps/web/shared';
|
|
|
14
14
|
const navType = (example: boolean): string => (example ? "'dashboard' | 'posts'" : "'dashboard'");
|
|
15
15
|
|
|
16
16
|
const postsItem = (example: boolean): string =>
|
|
17
|
-
example
|
|
17
|
+
example
|
|
18
|
+
? `\n { label: t('shell.nav.posts'), href: '/posts', icon: <Icon glyph={iconList} size="sm" /> },`
|
|
19
|
+
: '';
|
|
18
20
|
|
|
19
21
|
// Plain strings for the framework lines, never template literals: the workspace-dependency scanner
|
|
20
22
|
// blanks a string's contents but not a nested template's, so a template here would bill the CLI
|
|
@@ -25,14 +27,16 @@ const shell = (
|
|
|
25
27
|
): string => `// The app's frame: brand mark, the sidebar with one link per destination, an environment pill,
|
|
26
28
|
// the page's own actions at the end of the bar, and a footer — composed over the catalog's
|
|
27
29
|
// \`AppShell\`, so the skip link, the landmarks and the phone layout are the framework's and not
|
|
28
|
-
// this file's.
|
|
29
|
-
//
|
|
30
|
+
// this file's. The destinations are ONE list, drawn twice: \`NavList\` in the sidebar, \`BottomNav\`
|
|
31
|
+
// along a phone's bottom edge; each marks the current page by its href, with no script.
|
|
32
|
+
// \`site/page.tsx\` does NOT use it: the landing page is a 0kb document and this is the signed-in
|
|
33
|
+
// product's chrome. Every page under \`app/\` does.
|
|
30
34
|
//
|
|
31
35
|
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
32
36
|
${sortedImports([
|
|
33
37
|
`import { useT } from '@${app.kebab}/i18n';`,
|
|
34
38
|
"import { tryResolveEnvironment } from '@ultimat3/core';",
|
|
35
|
-
"import { AppShell,
|
|
39
|
+
"import { AppShell, BottomNav, Icon, Link, NavList } from '@ultimat3/ui';",
|
|
36
40
|
"import { iconHexagon } from '@ultimat3/ui/icons/hexagon';",
|
|
37
41
|
"import { iconLayoutDashboard } from '@ultimat3/ui/icons/layout-dashboard';",
|
|
38
42
|
...(example ? ["import { iconList } from '@ultimat3/ui/icons/list';"] : []),
|
|
@@ -43,6 +47,11 @@ import styles from './shell.module.scss';
|
|
|
43
47
|
/** The routes the sidebar knows about. A page names itself so its link can carry aria-current. */
|
|
44
48
|
export type ShellNav = ${navType(example)};
|
|
45
49
|
|
|
50
|
+
/** Where each destination lives — what \`NavList\` and \`BottomNav\` match the current page against. */
|
|
51
|
+
const HREFS: Readonly<Record<ShellNav, string>> = {
|
|
52
|
+
dashboard: '/dashboard',${example ? "\n posts: '/posts'," : ''}
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
export interface ShellProps {
|
|
47
56
|
readonly nav?: ShellNav | undefined;
|
|
48
57
|
/** Controls that belong to the page, rendered at the end of the header bar — a theme toggle. */
|
|
@@ -56,20 +65,15 @@ export function Shell(props: ShellProps): JSX.Element {
|
|
|
56
65
|
// neither ULTIMATE_ENV nor NODE_ENV names one, and the pill is absent with it.
|
|
57
66
|
const environment = tryResolveEnvironment();
|
|
58
67
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<Icon glyph={glyph} size="sm" class={styles.navIcon} />
|
|
69
|
-
<span>{label}</span>
|
|
70
|
-
</Link>
|
|
71
|
-
</li>
|
|
72
|
-
);
|
|
68
|
+
const destinations = [
|
|
69
|
+
{
|
|
70
|
+
label: t('shell.nav.dashboard'),
|
|
71
|
+
href: '/dashboard',
|
|
72
|
+
icon: <Icon glyph={iconLayoutDashboard} size="sm" />,
|
|
73
|
+
},${postsItem(example)}
|
|
74
|
+
];
|
|
75
|
+
// No page named means nothing is current: an href no destination covers.
|
|
76
|
+
const currentHref = props.nav === undefined ? '' : HREFS[props.nav];
|
|
73
77
|
|
|
74
78
|
return (
|
|
75
79
|
<div class={styles.doc}>
|
|
@@ -97,11 +101,8 @@ export function Shell(props: ShellProps): JSX.Element {
|
|
|
97
101
|
</div>
|
|
98
102
|
</div>
|
|
99
103
|
}
|
|
100
|
-
sidebar={
|
|
101
|
-
|
|
102
|
-
{item('dashboard', '/dashboard', t('shell.nav.dashboard'), iconLayoutDashboard)}${postsItem(example)}
|
|
103
|
-
</ul>
|
|
104
|
-
}
|
|
104
|
+
sidebar={<NavList items={destinations} currentHref={currentHref} />}
|
|
105
|
+
bottomNav={<BottomNav items={destinations} currentHref={currentHref} />}
|
|
105
106
|
footer={<span>{t('shell.footer')}</span>}
|
|
106
107
|
>
|
|
107
108
|
{props.children}
|
|
@@ -207,55 +208,6 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
207
208
|
opacity: 0.35;
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
|
-
|
|
211
|
-
.nav {
|
|
212
|
-
display: flex;
|
|
213
|
-
// A row on a phone — stacked links cost a fifth of the first screen — a rail from md up.
|
|
214
|
-
flex-direction: row;
|
|
215
|
-
gap: tokens.space(1);
|
|
216
|
-
margin: 0;
|
|
217
|
-
padding: 0;
|
|
218
|
-
overflow-x: auto;
|
|
219
|
-
list-style: none;
|
|
220
|
-
|
|
221
|
-
@include tokens.respond-to(md) {
|
|
222
|
-
flex-direction: column;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
.navLink {
|
|
227
|
-
display: flex;
|
|
228
|
-
align-items: center;
|
|
229
|
-
gap: tokens.space(3);
|
|
230
|
-
padding: tokens.space(2) tokens.space(3);
|
|
231
|
-
border-radius: tokens.radius('md');
|
|
232
|
-
color: tokens.role('fg-muted');
|
|
233
|
-
font-size: tokens.text('sm');
|
|
234
|
-
transition:
|
|
235
|
-
color tokens.duration('fast') tokens.easing('out'),
|
|
236
|
-
background-color tokens.duration('fast') tokens.easing('out');
|
|
237
|
-
|
|
238
|
-
&:hover {
|
|
239
|
-
color: tokens.role('fg-strong');
|
|
240
|
-
background: tokens.role('surface');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
&[aria-current='page'] {
|
|
244
|
-
color: tokens.role('fg-strong');
|
|
245
|
-
background: tokens.role('surface-raised');
|
|
246
|
-
box-shadow: inset 0 0 0 1px tokens.role('line', 0.7);
|
|
247
|
-
|
|
248
|
-
.navIcon {
|
|
249
|
-
color: tokens.role('accent');
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
.navIcon {
|
|
255
|
-
flex: none;
|
|
256
|
-
color: tokens.role('fg-muted');
|
|
257
|
-
transition: color tokens.duration('fast') tokens.easing('out');
|
|
258
|
-
}
|
|
259
211
|
`;
|
|
260
212
|
|
|
261
213
|
const shellTest =
|
|
@@ -267,10 +219,13 @@ import { Shell } from './shell';
|
|
|
267
219
|
|
|
268
220
|
unitTest('the shell marks the current destination and keeps the page in <main>', async () => {
|
|
269
221
|
const view = await renderView(Shell, { nav: 'dashboard', children: 'the page body' });
|
|
270
|
-
|
|
271
|
-
|
|
222
|
+
// One current link per list — the sidebar's and the bottom bar's — and both are the dashboard.
|
|
223
|
+
const current = [...view.html.matchAll(/<a[^>]*aria-current="page"[^>]*>/g)].map((m) => m[0]);
|
|
224
|
+
expect(current).toHaveLength(2);
|
|
225
|
+
for (const link of current) expect(link).toContain('href="/dashboard"');
|
|
226
|
+
// Landmarks come from AppShell: one banner, two navigations (sidebar, bottom bar), one main.
|
|
272
227
|
expect(view.html.match(/<main\\b/g)).toHaveLength(1);
|
|
273
|
-
expect(view.html.match(/<nav\\b/g)).toHaveLength(
|
|
228
|
+
expect(view.html.match(/<nav\\b/g)).toHaveLength(2);
|
|
274
229
|
expect(view.html).toMatch(/<main[^>]*>the page body<\\/main>/);
|
|
275
230
|
});
|
|
276
231
|
|
|
@@ -295,6 +250,7 @@ import {
|
|
|
295
250
|
createContext,
|
|
296
251
|
createEffect,
|
|
297
252
|
createMemo,
|
|
253
|
+
createRoot,
|
|
298
254
|
createSignal,
|
|
299
255
|
onCleanup,
|
|
300
256
|
useContext,
|
|
@@ -309,14 +265,24 @@ export interface ThemeToggleIslandProps {
|
|
|
309
265
|
/**
|
|
310
266
|
* The one export the hydration runtime calls. \`setSolidRuntime\` comes FIRST and is not optional:
|
|
311
267
|
* \`@ultimat3/ui\` imports types from solid-js and never a runtime, so the reactive graph a
|
|
312
|
-
* component reaches is the one an entry registers.
|
|
268
|
+
* component reaches is the one an entry registers. Seven NAMED imports, never a namespace: a
|
|
313
269
|
* namespace object keeps every export of solid-js alive in the chunk. The shell is cleared before
|
|
314
270
|
* \`render\`, which APPENDS — the server's copy would otherwise stay on screen beside the live one.
|
|
315
271
|
*/
|
|
316
|
-
export function mount(el: HTMLElement, props: ThemeToggleIslandProps): void {
|
|
317
|
-
setSolidRuntime({
|
|
272
|
+
export function mount(el: HTMLElement, props: ThemeToggleIslandProps): () => void {
|
|
273
|
+
setSolidRuntime({
|
|
274
|
+
createContext,
|
|
275
|
+
useContext,
|
|
276
|
+
createSignal,
|
|
277
|
+
createMemo,
|
|
278
|
+
createEffect,
|
|
279
|
+
onCleanup,
|
|
280
|
+
createRoot,
|
|
281
|
+
});
|
|
318
282
|
el.textContent = '';
|
|
319
|
-
|
|
283
|
+
// Solid's disposer, returned: the client router calls it when it navigates away, and a mount
|
|
284
|
+
// that returns nothing is X_ISLAND_DISPOSER_MISSING.
|
|
285
|
+
return render(
|
|
320
286
|
() => (
|
|
321
287
|
<UiProvider locale={props.locale}>
|
|
322
288
|
<ThemeToggle mode="toggle" />
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The backing services a new app's suites run against: `docker/docker-compose.test.yml` and the
|
|
2
2
|
// one env file that points the suites at it. In RAM, durability off, silent — the framework's own
|
|
3
3
|
// `docker/docker-compose.test.yml`, cut to what an app has. Ports sit apart from the dev stack's
|
|
4
|
-
// so both can run at once; `ci.yml` starts this file before `bun run
|
|
4
|
+
// so both can run at once; `ci.yml` starts this file before `bun run verify`.
|
|
5
5
|
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { S3_GATEWAY_IMAGE } from './pinned-images';
|
|
7
8
|
|
|
8
9
|
export const TEST_COMPOSE_PATH = 'docker/docker-compose.test.yml';
|
|
9
10
|
export const TEST_ENV_PATH = 'docker/test-services.env';
|
|
@@ -17,7 +18,7 @@ const composeTest = (
|
|
|
17
18
|
#
|
|
18
19
|
# docker compose -f ${TEST_COMPOSE_PATH} up -d --wait
|
|
19
20
|
# set -a; . ${TEST_ENV_PATH}; set +a
|
|
20
|
-
# bun run
|
|
21
|
+
# bun run verify
|
|
21
22
|
#
|
|
22
23
|
# \`--wait\` returns once every healthcheck passes. CI runs the same lines (.github/workflows/ci.yml).
|
|
23
24
|
# None of it is required: with no TEST_* variable set the unit suite runs on the in-memory driver,
|
|
@@ -41,7 +42,9 @@ x-silent: &silent
|
|
|
41
42
|
services:
|
|
42
43
|
# Postgres 18, the production major. \`initdb -c\` writes each setting into the new cluster;
|
|
43
44
|
# \`--no-sync\` is initdb's own fsync of a cluster on a tmpfs. Postgres 18 images keep the cluster
|
|
44
|
-
# under /var/lib/postgresql/18, so the tmpfs is the parent.
|
|
45
|
+
# under /var/lib/postgresql/18, so the tmpfs is the parent. After the logging: no JIT and no
|
|
46
|
+
# autovacuum for millisecond queries, room for every parallel test process, and a small WAL —
|
|
47
|
+
# it is RAM here.
|
|
45
48
|
postgres:
|
|
46
49
|
<<: *silent
|
|
47
50
|
image: postgres:18-alpine
|
|
@@ -62,6 +65,11 @@ services:
|
|
|
62
65
|
-c log_checkpoints=off
|
|
63
66
|
-c log_connections=off
|
|
64
67
|
-c log_disconnections=off
|
|
68
|
+
-c jit=off
|
|
69
|
+
-c autovacuum=off
|
|
70
|
+
-c max_connections=400
|
|
71
|
+
-c min_wal_size=32MB
|
|
72
|
+
-c max_wal_size=256MB
|
|
65
73
|
tmpfs: [/var/lib/postgresql]
|
|
66
74
|
ports: ['127.0.0.1:${TEST_PORTS.postgres}:5432']
|
|
67
75
|
healthcheck:
|
|
@@ -74,7 +82,7 @@ services:
|
|
|
74
82
|
redis:
|
|
75
83
|
<<: *silent
|
|
76
84
|
image: redis:8-alpine
|
|
77
|
-
command: ['redis-server', '--save', '', '--appendonly', 'no', '--loglevel', '
|
|
85
|
+
command: ['redis-server', '--save', '', '--appendonly', 'no', '--loglevel', 'nothing']
|
|
78
86
|
tmpfs: [/data]
|
|
79
87
|
ports: ['127.0.0.1:${TEST_PORTS.redis}:6379']
|
|
80
88
|
healthcheck:
|
|
@@ -99,7 +107,7 @@ services:
|
|
|
99
107
|
# A directory under /data is a bucket, so the tmpfs mounted AT the bucket's path is the bootstrap.
|
|
100
108
|
s3:
|
|
101
109
|
<<: *silent
|
|
102
|
-
image:
|
|
110
|
+
image: ${S3_GATEWAY_IMAGE}
|
|
103
111
|
environment:
|
|
104
112
|
VGW_BACKEND: posix
|
|
105
113
|
VGW_BACKEND_ARG: /data
|