@ultimat3/cli 29.4.0 → 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 +11 -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 +9 -0
- 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 +41 -81
- 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 +7 -1
- 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,268 @@
|
|
|
1
|
+
// What both `x g sign-in` shapes write: where a signed-in browser lands, the sign-out action, the
|
|
2
|
+
// boot guard that refuses a people-facing role with sign-in unconfigured, and the page's styles.
|
|
3
|
+
// The two shapes differ only in how a person proves who they are (`sign-in-password.ts`,
|
|
4
|
+
// `sign-in-oidc.ts`); the session after that is one session, so its edges are written once.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile } from './naming';
|
|
7
|
+
import { authModule } from './scaffold-auth';
|
|
8
|
+
import { destroySessionTest } from './sign-in-action-tests';
|
|
9
|
+
|
|
10
|
+
/** Every generated file lives under the scaffold's own auth slice, beside `auth.ts`. */
|
|
11
|
+
export const AUTH_DIR = 'apps/web/app/auth';
|
|
12
|
+
/** The page `x g sign-in` writes, and what `auth.signInPath` names. */
|
|
13
|
+
export const SIGN_IN_PATH = '/auth/sign-in';
|
|
14
|
+
|
|
15
|
+
/** How the generated page reaches a string: the app's own catalog module, or the framework's. */
|
|
16
|
+
export const catalogImport = (module: string | undefined): string =>
|
|
17
|
+
module === undefined
|
|
18
|
+
? "import { t } from '@ultimat3/i18n';"
|
|
19
|
+
: `import { useT } from '${module}';`;
|
|
20
|
+
|
|
21
|
+
export const translatorBinding = (module: string | undefined): string =>
|
|
22
|
+
module === undefined ? '' : '\n const t = useT();';
|
|
23
|
+
|
|
24
|
+
/** `text` as `//` comment lines of at most 100 columns, split on spaces. */
|
|
25
|
+
const commentLines = (text: string): string => {
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
let line = '//';
|
|
28
|
+
for (const word of text.split(' ')) {
|
|
29
|
+
if (line.length + 1 + word.length > 100) {
|
|
30
|
+
lines.push(line);
|
|
31
|
+
line = '//';
|
|
32
|
+
}
|
|
33
|
+
line = `${line} ${word}`;
|
|
34
|
+
}
|
|
35
|
+
return [...lines, line].join('\n');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A string-array literal the way Biome prints it after `prefix`: on one line when it fits in 100
|
|
40
|
+
* columns, otherwise one item per line with a trailing comma.
|
|
41
|
+
*/
|
|
42
|
+
export const arrayLiteral = (prefix: string, items: readonly string[], suffix: string): string => {
|
|
43
|
+
const quoted = items.map((item) => `'${item}'`);
|
|
44
|
+
const inline = `${prefix}[${quoted.join(', ')}]${suffix}`;
|
|
45
|
+
if (inline.length <= 100) return inline;
|
|
46
|
+
const indent = /^\s*/.exec(prefix)?.[0] ?? '';
|
|
47
|
+
const rows = quoted.map((item) => `${indent} ${item},`).join('\n');
|
|
48
|
+
return `${prefix}[\n${rows}\n${indent}]${suffix}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const flowSource =
|
|
52
|
+
(): string => `// Where a signed-in browser goes, and the one browser/agent split every sign-in action makes. The
|
|
53
|
+
// destination is a FIXED path or a same-origin \`?next=\`: the endpoint that hands out a session is
|
|
54
|
+
// the classic open redirect, and \`nextAfterSignIn\` is the framework's one check for it.
|
|
55
|
+
import { NEXT_PARAM, nextAfterSignIn, setRedirect, useRequestHeader } from '@ultimat3/http';
|
|
56
|
+
|
|
57
|
+
/** Where a browser lands with no \`?next=\`. The app's to move. */
|
|
58
|
+
export const AFTER_SIGN_IN = '/dashboard';
|
|
59
|
+
/** The page this app signs in on — \`auth.signInPath\` in \`app.config.ts\` names the same one. */
|
|
60
|
+
export const SIGN_IN_PATH = '${SIGN_IN_PATH}';
|
|
61
|
+
export { NEXT_PARAM };
|
|
62
|
+
|
|
63
|
+
/** A same-origin path to go to next, or the fallback. Never another origin. */
|
|
64
|
+
export const nextPath = (raw: string | undefined, fallback = AFTER_SIGN_IN): string =>
|
|
65
|
+
nextAfterSignIn(raw, fallback);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A browser that posted the native form gets a 303 to where it was going; an agent that posted
|
|
69
|
+
* JSON gets the same path as data. One operation, two audiences — \`setRedirect\` is that seam.
|
|
70
|
+
*/
|
|
71
|
+
export const landAt = (path: string): string => {
|
|
72
|
+
if ((useRequestHeader('accept') ?? '').includes('text/html')) setRedirect(path);
|
|
73
|
+
return path;
|
|
74
|
+
};
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const flowTest =
|
|
78
|
+
(): string => `// The landing rule: a same-origin path is kept, anything else is the fixed destination.
|
|
79
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
80
|
+
import { AFTER_SIGN_IN, nextPath, SIGN_IN_PATH } from './sign-in-flow';
|
|
81
|
+
|
|
82
|
+
unitTest('a same-origin next is kept, another origin is the fixed landing page', () => {
|
|
83
|
+
expect(nextPath('/posts')).toBe('/posts');
|
|
84
|
+
expect(nextPath('https://evil.example/steal')).toBe(AFTER_SIGN_IN);
|
|
85
|
+
expect(nextPath('//evil.example')).toBe(AFTER_SIGN_IN);
|
|
86
|
+
expect(nextPath(undefined)).toBe(AFTER_SIGN_IN);
|
|
87
|
+
expect(nextPath(undefined, SIGN_IN_PATH)).toBe(SIGN_IN_PATH);
|
|
88
|
+
});
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const destroySessionSource =
|
|
92
|
+
(): string => `// Sign out: the session row revoked, the cookie expired and the browser's stored state cleared —
|
|
93
|
+
// \`signOutHeaders()\` is the one list of what a sign-out response carries. Public, because a
|
|
94
|
+
// signed-out caller signing out is a no-op rather than a 401, and a stale tab must not see a 500.
|
|
95
|
+
import { action, t } from '@ultimat3/action';
|
|
96
|
+
import { logout, signOutHeaders } from '@ultimat3/auth';
|
|
97
|
+
import { useRequestContext, useRequestCookie } from '@ultimat3/http';
|
|
98
|
+
import { allow } from '@ultimat3/policy';
|
|
99
|
+
import { appAuth } from '../auth';
|
|
100
|
+
import { landAt } from '../sign-in-flow';
|
|
101
|
+
|
|
102
|
+
export const destroySession = action({
|
|
103
|
+
// A native form with no field posts no body, and an action's input is an object: the form
|
|
104
|
+
// declares one field rather than depending on how a browser encodes nothing.
|
|
105
|
+
input: t.object({ confirm: t.literal('sign-out') }),
|
|
106
|
+
output: t.object({ ok: t.boolean, next: t.string, revoked: t.boolean }),
|
|
107
|
+
policy: allow('public'),
|
|
108
|
+
mcp: { expose: false },
|
|
109
|
+
async handle() {
|
|
110
|
+
const auth = appAuth();
|
|
111
|
+
const token = useRequestCookie(auth.sessions.policy.cookieName);
|
|
112
|
+
const revoked = token === null || token === '' ? false : await logout(auth, token);
|
|
113
|
+
const response = useRequestContext('destroySession').headers;
|
|
114
|
+
for (const [name, value] of signOutHeaders({ session: auth.sessions.policy })) {
|
|
115
|
+
response.append(name, value);
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, next: landAt('/'), revoked };
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The guard reads the environment the way the scaffold's authenticator does — `fallback:
|
|
124
|
+
* 'production'` never applies, because only a NAMED deployed environment is refused: \`x manifest\`
|
|
125
|
+
* and \`x verify\` import every module with none named, and must not trip it.
|
|
126
|
+
*/
|
|
127
|
+
const guardSource = (
|
|
128
|
+
keys: readonly string[],
|
|
129
|
+
why: string,
|
|
130
|
+
): string => `// Refuses to start a people-facing role with sign-in unconfigured. Outside \`development\` and \`test\`, a
|
|
131
|
+
// \`web\` or \`sync\` process with these keys unset would serve every policy-protected route as a 401
|
|
132
|
+
// that nobody can sign in past. Written by \`x g sign-in\` and owned by this app — an API-only app
|
|
133
|
+
// has no sign-in, so this is not a framework rule.
|
|
134
|
+
${commentLines(why)}
|
|
135
|
+
import { signInUnconfigured } from '@ultimat3/auth';
|
|
136
|
+
|
|
137
|
+
/** Set in the role's environment, or sign-in cannot run. \`x env example\` lists them. */
|
|
138
|
+
${arrayLiteral('export const SIGN_IN_ENV: readonly string[] = ', keys, ';')}
|
|
139
|
+
|
|
140
|
+
/** The roles a person reaches. \`worker\`, \`scheduler\`, \`replicator\` and \`migrate\` sign nobody in. */
|
|
141
|
+
const PEOPLE_FACING = new Set(['web', 'sync']);
|
|
142
|
+
|
|
143
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
144
|
+
|
|
145
|
+
const named = (env: Env, key: string): string => (env[key] ?? '').trim();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Throws \`X_AUTH_SIGN_IN_UNCONFIGURED\` naming every unset key, or returns. Only a NAMED deployed
|
|
149
|
+
* environment is judged: \`x manifest\` and \`x verify\` load the app with none named. Called by
|
|
150
|
+
* \`apps/web/runtime.ts\`, which the boot imports before it binds a port — a throw there stops the
|
|
151
|
+
* role, where a throw in a scanned module would only be logged.
|
|
152
|
+
*/
|
|
153
|
+
export function assertSignInConfigured(env: Env): void {
|
|
154
|
+
const environment = named(env, 'ULTIMATE_ENV') || named(env, 'NODE_ENV');
|
|
155
|
+
if (environment === '' || environment === 'development' || environment === 'test') return;
|
|
156
|
+
// \`ROLE\` unset is \`web\`, exactly as the boot reads it.
|
|
157
|
+
const role = named(env, 'ROLE') || 'web';
|
|
158
|
+
if (!PEOPLE_FACING.has(role)) return;
|
|
159
|
+
const missing = SIGN_IN_ENV.filter((key) => named(env, key) === '');
|
|
160
|
+
if (missing.length > 0) throw signInUnconfigured(role, missing);
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const guardTest = (
|
|
165
|
+
keys: readonly string[],
|
|
166
|
+
): string => `// The guard refuses a deployed people-facing role with sign-in unset, and nothing else.
|
|
167
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
168
|
+
import { assertSignInConfigured, SIGN_IN_ENV } from './sign-in-guard';
|
|
169
|
+
|
|
170
|
+
const configured = Object.fromEntries(SIGN_IN_ENV.map((key) => [key, 'set']));
|
|
171
|
+
|
|
172
|
+
const refusal = (env: Readonly<Record<string, string>>): unknown => {
|
|
173
|
+
try {
|
|
174
|
+
assertSignInConfigured(env);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return error;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
unitTest('a deployed web or sync role with sign-in unset refuses to start', () => {
|
|
182
|
+
for (const ROLE of ['web', 'sync']) {
|
|
183
|
+
const error = refusal({ ULTIMATE_ENV: 'production', ROLE });
|
|
184
|
+
expect(error).toBeUltimateError('X_AUTH_SIGN_IN_UNCONFIGURED');
|
|
185
|
+
}
|
|
186
|
+
// \`ROLE\` unset is \`web\`, and \`NODE_ENV\` names the environment as well.
|
|
187
|
+
expect(refusal({ NODE_ENV: 'production' })).toBeUltimateError('X_AUTH_SIGN_IN_UNCONFIGURED');
|
|
188
|
+
expect(refusal({ ULTIMATE_ENV: 'staging', ROLE: 'web' })).toBeUltimateError(
|
|
189
|
+
'X_AUTH_SIGN_IN_UNCONFIGURED',
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
unitTest('configured, local, unnamed, or a role nobody signs in to: it starts', () => {
|
|
194
|
+
${arrayLiteral(' expect(SIGN_IN_ENV).toEqual(', keys, ');')}
|
|
195
|
+
expect(refusal({ ULTIMATE_ENV: 'production', ROLE: 'web', ...configured })).toBeUndefined();
|
|
196
|
+
expect(refusal({ ULTIMATE_ENV: 'development', ROLE: 'web' })).toBeUndefined();
|
|
197
|
+
expect(refusal({ NODE_ENV: 'test', ROLE: 'web' })).toBeUndefined();
|
|
198
|
+
expect(refusal({ ROLE: 'web' })).toBeUndefined();
|
|
199
|
+
expect(refusal({ ULTIMATE_ENV: 'production', ROLE: 'worker' })).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
const styleSource =
|
|
204
|
+
(): string => `// Semantic tokens only: a raw colour here is refused by guards/raw-colour.ts.
|
|
205
|
+
@use '@ultimat3/ui/tokens' as tokens;
|
|
206
|
+
|
|
207
|
+
.page {
|
|
208
|
+
display: grid;
|
|
209
|
+
gap: tokens.space(4);
|
|
210
|
+
max-width: 28rem;
|
|
211
|
+
margin-inline: auto;
|
|
212
|
+
padding: tokens.space(6);
|
|
213
|
+
color: tokens.role('fg');
|
|
214
|
+
}
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
/** The files both shapes write, with the guard over this shape's settings. */
|
|
218
|
+
/** Where the generated runtime lives: `apps/<app>/runtime.ts` is what both boots read first. */
|
|
219
|
+
export const RUNTIME_MODULE = 'apps/web/runtime.ts';
|
|
220
|
+
|
|
221
|
+
/** The plain routes a shape mounts: an import line, and the expression `routes:` takes. */
|
|
222
|
+
export interface RuntimeRoutes {
|
|
223
|
+
readonly importLine: string;
|
|
224
|
+
readonly expression: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const runtimeSource = (
|
|
228
|
+
routes: RuntimeRoutes | undefined,
|
|
229
|
+
): string => `// What this app hands the framework's boot — \`x dev\` and the container both import it before a
|
|
230
|
+
// port is bound. The sign-in guard runs HERE for that reason: a throw in this module stops the
|
|
231
|
+
// role, where the same throw in a module the app scan imports is logged and the boot goes on.
|
|
232
|
+
import { assertSignInConfigured } from './app/auth/sign-in-guard';${routes === undefined ? '' : `\n${routes.importLine}`}
|
|
233
|
+
|
|
234
|
+
assertSignInConfigured(process.env);
|
|
235
|
+
|
|
236
|
+
export const runtime = {${routes === undefined ? '' : ` routes: ${routes.expression} `}};
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
const runtimeTest = (
|
|
240
|
+
routes: RuntimeRoutes | undefined,
|
|
241
|
+
): string => `// The runtime loads under test (the guard judges no local environment)${routes === undefined ? '' : ', and mounts the sign-in routes'}.
|
|
242
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
243
|
+
${routes === undefined ? '' : `${routes.importLine}\n`}import { runtime } from './runtime';
|
|
244
|
+
|
|
245
|
+
unitTest('the runtime loads${routes === undefined ? '' : ' and mounts the sign-in routes'}', () => {
|
|
246
|
+
${routes === undefined ? 'expect(runtime).toEqual({});' : `expect(runtime.routes).toBe(${routes.expression});`}
|
|
247
|
+
});
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
export function sharedSignInFiles(
|
|
251
|
+
keys: readonly string[],
|
|
252
|
+
why: string,
|
|
253
|
+
routes?: RuntimeRoutes,
|
|
254
|
+
): readonly GeneratedFile[] {
|
|
255
|
+
return [
|
|
256
|
+
{ path: RUNTIME_MODULE, contents: runtimeSource(routes) },
|
|
257
|
+
{ path: 'apps/web/runtime.test.ts', contents: runtimeTest(routes) },
|
|
258
|
+
{ path: `${AUTH_DIR}/sign-in-flow.ts`, contents: flowSource() },
|
|
259
|
+
{ path: `${AUTH_DIR}/sign-in-flow.test.ts`, contents: flowTest() },
|
|
260
|
+
{ path: `${AUTH_DIR}/sign-in-guard.ts`, contents: guardSource(keys, why) },
|
|
261
|
+
{ path: `${AUTH_DIR}/sign-in-guard.test.ts`, contents: guardTest(keys) },
|
|
262
|
+
{ path: `${AUTH_DIR}/actions/destroy-session.ts`, contents: destroySessionSource() },
|
|
263
|
+
{ path: `${AUTH_DIR}/actions/destroy-session.test.ts`, contents: destroySessionTest() },
|
|
264
|
+
{ path: `${AUTH_DIR}/sign-in/page.module.scss`, contents: styleSource() },
|
|
265
|
+
// Every action above imports `../auth`; an app from before `x new` wrote it gets it here.
|
|
266
|
+
{ path: `${AUTH_DIR}/auth.ts`, contents: authModule(), merge: 'if-absent' },
|
|
267
|
+
];
|
|
268
|
+
}
|
|
@@ -38,19 +38,17 @@ const errorsSource = (feature: NameSet): string => {
|
|
|
38
38
|
return `// The ${feature.kebab} feature's X_* codes. Never throw a bare Error: an agent reading the failure
|
|
39
39
|
// needs the code, the cause and the exact command that fixes it.
|
|
40
40
|
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
41
|
+
import { defineError } from '@ultimat3/core';
|
|
42
|
+
|
|
43
|
+
// One declaration: the title, the 404 a response answers, and the class a service throws. No
|
|
44
|
+
// \`docs:\` — it resolves from the code's registered descriptor, so the link has one home.
|
|
45
|
+
export const ${feature.pascal}NotFoundError = defineError<{ readonly id: string }>({
|
|
46
|
+
code: '${errorCode}',
|
|
47
|
+
title: 'no such ${feature.kebab}',
|
|
48
|
+
status: 404,
|
|
49
|
+
cause: (input) => \`no ${feature.kebab} with id \${input.id}\`,
|
|
50
|
+
fix: 'x queries list --json, then pass an id the ${feature.kebab} read returns',
|
|
51
|
+
});
|
|
54
52
|
`;
|
|
55
53
|
};
|
|
56
54
|
|
|
@@ -77,13 +75,15 @@ export function sliceFoundation(
|
|
|
77
75
|
needs: readonly SliceModule[],
|
|
78
76
|
/** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
|
|
79
77
|
locales?: readonly string[],
|
|
78
|
+
/** The slice's entity names no tenant, so a planted policy holds no org rule. */
|
|
79
|
+
shape: { readonly singleTenant?: boolean } = {},
|
|
80
80
|
): readonly GeneratedFile[] {
|
|
81
81
|
const feature = names(target.feature);
|
|
82
82
|
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
83
83
|
return [
|
|
84
84
|
...ifAbsent([
|
|
85
85
|
...(needs.includes('entity') ? entityFiles(target.feature, target) : []),
|
|
86
|
-
...(needs.includes('policy') ? policyFiles(target.feature, target) : []),
|
|
86
|
+
...(needs.includes('policy') ? policyFiles(target.feature, target, shape) : []),
|
|
87
87
|
...(needs.includes('errors')
|
|
88
88
|
? [{ path: `${dir}/errors.ts`, contents: errorsSource(feature) }]
|
|
89
89
|
: []),
|
|
@@ -95,6 +95,61 @@ export function sliceFoundation(
|
|
|
95
95
|
];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** One `export const <binding> = entity('<table>', …)` an `entity.ts` declares. */
|
|
99
|
+
export interface DeclaredEntity {
|
|
100
|
+
readonly binding: string;
|
|
101
|
+
readonly table: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every entity a slice's `entity.ts` exports, in source order, comments masked. */
|
|
105
|
+
export const declaredEntities = (source: string): readonly DeclaredEntity[] =>
|
|
106
|
+
[
|
|
107
|
+
...stripComments(source).matchAll(
|
|
108
|
+
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*entity\(\s*['"]([^'"]+)['"]/g,
|
|
109
|
+
),
|
|
110
|
+
].map((match) => ({ binding: match[1] ?? '', table: match[2] ?? '' }));
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The entity a slice's `entity.ts` declares when it is NOT the one this generator would write for
|
|
114
|
+
* `feature` — `shortLink`/`short_links` in a `shortlink/` slice, whose scaffold is
|
|
115
|
+
* `shortlink`/`shortlinks`. A foundation planted around it is a `repo.ts` reading a table and a row
|
|
116
|
+
* type nothing declares. `undefined` when there is no file, the file declares the scaffold's, or it
|
|
117
|
+
* declares nothing this can read.
|
|
118
|
+
*/
|
|
119
|
+
export function foreignSliceEntity(
|
|
120
|
+
feature: string,
|
|
121
|
+
sliceEntity: string | undefined,
|
|
122
|
+
): DeclaredEntity | undefined {
|
|
123
|
+
if (sliceEntity === undefined) return undefined;
|
|
124
|
+
const own = names(feature);
|
|
125
|
+
const declared = declaredEntities(sliceEntity);
|
|
126
|
+
if (declared.some((one) => one.binding === own.camel && one.table === own.table))
|
|
127
|
+
return undefined;
|
|
128
|
+
// Nothing readable is "cannot tell", and a slice is never refused on a guess.
|
|
129
|
+
return declared[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether the slice's `entity.ts` is there and names no tenant: no `tenant:` key but `'none'`, no
|
|
134
|
+
* `.tenant()` column, and no column named `orgId` — the three ways `resolveTenantColumn` turns
|
|
135
|
+
* tenancy on, inference included, so deleting `tenant: 'orgId'` alone is still a tenant-scoped row.
|
|
136
|
+
* Absent is NOT this: a slice with no entity has decided nothing, and keeps the default shape.
|
|
137
|
+
*/
|
|
138
|
+
export function sliceHasNoTenant(sliceEntity: string | undefined): boolean {
|
|
139
|
+
if (sliceEntity === undefined) return false;
|
|
140
|
+
const code = stripComments(sliceEntity);
|
|
141
|
+
if (/\btenant\s*:\s*'none'/.test(code)) return true;
|
|
142
|
+
return !/\btenant\s*:|\.tenant\(|\borgId\s*:/.test(code);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a slice's `policy.ts` decides on an org the input names — `input.orgId`, or the
|
|
147
|
+
* `<Feature>Scope` a scaffolded `can<…>` types its input with. An action dropping `orgId` from its
|
|
148
|
+
* input would not compile against that rule, and at runtime the rule could only deny.
|
|
149
|
+
*/
|
|
150
|
+
export const policyReadsOrg = (source: string): boolean =>
|
|
151
|
+
/\binput\??\.orgId\b|\bcan<\s*\w+Scope\b/.test(stripComments(source));
|
|
152
|
+
|
|
98
153
|
/** The exported names an `export { a, b as c }` list declares — `c`, never `b`. */
|
|
99
154
|
const listedExports = (code: string): readonly string[] =>
|
|
100
155
|
[...code.matchAll(/\bexport\s*\{([^}]*)\}/g)].flatMap((match) =>
|
|
@@ -113,7 +168,7 @@ const listedExports = (code: string): readonly string[] =>
|
|
|
113
168
|
|
|
114
169
|
/**
|
|
115
170
|
* Whether `source` — a slice module as it stands on the app's disk — exports the VALUE `name`.
|
|
116
|
-
* Read at generation time because assuming it was measured: `x g action` into
|
|
171
|
+
* Read at generation time because assuming it was measured: `x g action` into one adopter's
|
|
117
172
|
* `fleet` slice wrote `import { FleetNotFoundError } from '../errors'` into a slice whose
|
|
118
173
|
* `errors.ts` declared `HostNotFoundError` and `SessionNotFoundError` and no `FleetNotFoundError`.
|
|
119
174
|
* The file failed at import — and because `x db gen` and `x manifest` load every module, one
|
package/src/test-batches.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// WHY THERE IS MORE THAN ONE PROCESS, `As of 2026-09-27`. A `--parallel` worker is not freed file
|
|
6
6
|
// by file: it keeps its heap for the life of the `bun test` process and it GROWS with every file it
|
|
7
7
|
// is handed (JIT, module-resolution caches, PGlite heaps the collector has not yet returned). On
|
|
8
|
-
//
|
|
8
|
+
// one adopter's unit corpus (768 files, PGlite per file, 12-core box, Bun 1.4.0), whole-tree RSS
|
|
9
9
|
// at 12 workers:
|
|
10
10
|
//
|
|
11
11
|
// | form | peak tree RSS | wall |
|
package/src/test-database.ts
CHANGED
|
@@ -72,7 +72,7 @@ async function buildTemplate(
|
|
|
72
72
|
await applyFrameworkSchema((statement) => client.execute(raw(statement)));
|
|
73
73
|
await migrate({ migrations: await readMigrations(root), client, lock: false });
|
|
74
74
|
// Without a checkpoint every boot from the template replays the migrations' WAL as crash
|
|
75
|
-
// recovery (~130 ms of each ~600 ms boot, measured on
|
|
75
|
+
// recovery (~130 ms of each ~600 ms boot, measured on one adopter's app).
|
|
76
76
|
await client.execute(raw('CHECKPOINT'));
|
|
77
77
|
const captured = driver as DumpablePglite | undefined;
|
|
78
78
|
assert(
|
package/src/test-shards.ts
CHANGED
|
@@ -87,7 +87,7 @@ export function testArgs(input: {
|
|
|
87
87
|
* A fresh global per FILE. Off by default since 22.7 — `--parallel` implies it, so the default
|
|
88
88
|
* run says `--no-isolate` — and on for a repository whose `x.verify.json` says `"isolate": true`
|
|
89
89
|
* or a caller's `--isolate`. Measured, 4 workers: the framework's unit corpus 168s isolated,
|
|
90
|
-
* 77s not; a
|
|
90
|
+
* 77s not; a third of one adopter's corpus 85-107s isolated, 20s not (`test-workers.ts`).
|
|
91
91
|
*/
|
|
92
92
|
readonly isolate?: boolean;
|
|
93
93
|
/**
|
|
@@ -140,6 +140,8 @@ export interface ReproduceOptions {
|
|
|
140
140
|
readonly passthrough?: readonly string[];
|
|
141
141
|
/** The run was isolated per file; the rerun must be too. */
|
|
142
142
|
readonly isolate?: boolean;
|
|
143
|
+
/** The `simulateJobs` seed the run replayed: without it the rerun draws the default seeds. */
|
|
144
|
+
readonly seed?: number;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
/**
|
|
@@ -162,6 +164,7 @@ export function reproduceFor(options: ReproduceOptions): string {
|
|
|
162
164
|
'--workers',
|
|
163
165
|
String(options.workers),
|
|
164
166
|
...(options.isolate === true ? ['--isolate'] : []),
|
|
167
|
+
...(options.seed === undefined ? [] : ['--seed', String(options.seed)]),
|
|
165
168
|
// Last, and after a `--` of its own, because that is where the caller typed it and where the
|
|
166
169
|
// parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
|
|
167
170
|
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
@@ -195,6 +198,8 @@ export interface RunShardsOptions {
|
|
|
195
198
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
196
199
|
/** A fresh global per file (`x.verify.json` `"isolate": true`, or `--isolate`). Default off. */
|
|
197
200
|
readonly isolate?: boolean;
|
|
201
|
+
/** `--seed`: handed to the children by the caller's runner, printed back in the reproduce. */
|
|
202
|
+
readonly seed?: number;
|
|
198
203
|
/** The machine-pool lease (`test-slots.ts`), for a default-width run. */
|
|
199
204
|
readonly lease?: (want: number) => Promise<{ readonly count: number; release(): void }>;
|
|
200
205
|
}
|
|
@@ -221,6 +226,7 @@ const planOf = (
|
|
|
221
226
|
...(options.sample === undefined ? {} : { sample: options.sample.kept }),
|
|
222
227
|
...(options.affected === undefined ? {} : { affected: options.affected }),
|
|
223
228
|
...(options.isolate === true ? { isolate: true } : {}),
|
|
229
|
+
...(options.seed === undefined ? {} : { seed: options.seed }),
|
|
224
230
|
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
225
231
|
? {}
|
|
226
232
|
: { passthrough: options.passthrough }),
|
package/src/test-workers.ts
CHANGED
|
@@ -52,19 +52,25 @@ export function availableCpus(): number {
|
|
|
52
52
|
* registry per file. Without isolation (the default since 22.7, below) a worker is CPU-bound,
|
|
53
53
|
* and oversubscribing only multiplies memory.
|
|
54
54
|
*
|
|
55
|
-
* So: `
|
|
55
|
+
* So: `max(GATE_BUDGET_FLOOR, GATE_BUDGET_SHARE x TOTAL memory)` — total, never free, so the plan
|
|
56
56
|
* is the same on every run of the same machine — divided by `WORKER_BYTES`, clamped to 1..cpus.
|
|
57
57
|
* `ULTIMATE_TEST_MEMORY_BUDGET` (e.g. `3g`) replaces the budget, `ULTIMATE_TEST_MAX_WORKERS` caps
|
|
58
58
|
* the width, and an explicit `--workers` wins over both.
|
|
59
|
+
*
|
|
60
|
+
* Until 2026-11-07 the budget was also capped at a fixed 4 GiB, which held a 16-core 62 GB box to
|
|
61
|
+
* the 3 workers a 16 GB laptop gets: the framework's unit step took 374 s there at 3 workers and
|
|
62
|
+
* 149 s at 12. The cap is gone because the guard it stood for is elsewhere now — every run on a
|
|
63
|
+
* machine leases its workers from ONE pool sized by this plan (`test-slots.ts`), so a second gate
|
|
64
|
+
* shares the quarter rather than doubling it. A 16 GB runner's quarter is exactly the old cap, so
|
|
65
|
+
* CI plans what it always planned.
|
|
59
66
|
*/
|
|
60
|
-
export const GATE_BUDGET_CAP = 4 * 1024 * 1024 * 1024;
|
|
61
67
|
|
|
62
68
|
/** A quarter of the machine: an editor, a language server and another agent's gate need the rest. */
|
|
63
69
|
export const GATE_BUDGET_SHARE = 0.25;
|
|
64
70
|
|
|
65
71
|
/**
|
|
66
72
|
* The budget never plans below this: enough for TWO workers at `WORKER_BYTES`. A quarter of an
|
|
67
|
-
* 8 GB laptop (2 GiB) planned one worker, ~4.7 min for
|
|
73
|
+
* 8 GB laptop (2 GiB) planned one worker, ~4.7 min for one adopter's unit tier; two stay inside
|
|
68
74
|
* the owner's 1-4 GB gate target (~2.6 GB measured).
|
|
69
75
|
*/
|
|
70
76
|
export const GATE_BUDGET_FLOOR = 2.75 * 1024 * 1024 * 1024;
|
|
@@ -76,9 +82,9 @@ export const GATE_BUDGET_FLOOR = 2.75 * 1024 * 1024 * 1024;
|
|
|
76
82
|
* | corpus | workers | batch | peak tree | tree / worker |
|
|
77
83
|
* |-----------------------------------------|---------|-------|-----------|---------------|
|
|
78
84
|
* | framework unit (1620 files) | 4 | 24 | 3.61-3.78 GB | 0.90-0.95 GB |
|
|
79
|
-
* |
|
|
80
|
-
* |
|
|
81
|
-
* |
|
|
85
|
+
* | one adopter's unit (768 files) | 4 | 24 | 4.26-4.58 GB | 1.07-1.15 GB |
|
|
86
|
+
* | one adopter's unit (768 files) | 4 | 48 | 4.35 GB | 1.09 GB |
|
|
87
|
+
* | one adopter's unit (768 files) | 3 | 24 | 3.28-3.61 GB | 1.09-1.20 GB |
|
|
82
88
|
*
|
|
83
89
|
* The planning figure is what the TREE costs per worker at its peak — the `x` parent and Bun's
|
|
84
90
|
* coordinator included — rounded up from the heaviest corpus, not the largest single worker:
|
|
@@ -103,7 +109,7 @@ export const BATCH_FILES_PER_WORKER = 24;
|
|
|
103
109
|
* The recycle point WITHOUT isolation (the default since 22.7). A shared worker's peak is set by
|
|
104
110
|
* its live PGlite, not by how many files it has run, and a batch boundary is pure cost: every
|
|
105
111
|
* worker restarts, re-imports the app and re-boots its database, and the batch waits on its
|
|
106
|
-
* slowest file. Measured on
|
|
112
|
+
* slowest file. Measured on one adopter's unit tier (768 files, 281 s of file time, 3 workers,
|
|
107
113
|
* no isolation): 6 batches of 48/worker 150-172 s, ONE batch 92-94 s at 3.9 GB peak — the work
|
|
108
114
|
* floor (281 s / 3). 256 a worker keeps a recycle point for a corpus far past that size.
|
|
109
115
|
*/
|
|
@@ -167,7 +173,10 @@ export interface WorkerPlan {
|
|
|
167
173
|
|
|
168
174
|
const gb = (bytes: number): string => (bytes / 1024 ** 3).toFixed(1);
|
|
169
175
|
|
|
170
|
-
/**
|
|
176
|
+
/** `memoryBudget`'s default, as every help line and page prints it — derived, never restated. */
|
|
177
|
+
export const BUDGET_RULE = `max(${String(GATE_BUDGET_FLOOR / 1024 ** 3)} GiB, ${String(GATE_BUDGET_SHARE * 100)}% of RAM)`;
|
|
178
|
+
|
|
179
|
+
/** The budget a default run plans on: the env override, or `BUDGET_RULE`. */
|
|
171
180
|
export function memoryBudget(total: number = totalMemory(), env: Env = Bun.env): number {
|
|
172
181
|
const raw = env[MEMORY_BUDGET_ENV];
|
|
173
182
|
if (raw !== undefined && raw.trim() !== '') {
|
|
@@ -175,10 +184,7 @@ export function memoryBudget(total: number = totalMemory(), env: Env = Bun.env):
|
|
|
175
184
|
if (bytes === undefined) throw new TestBudgetInvalidError(MEMORY_BUDGET_ENV, raw);
|
|
176
185
|
return bytes;
|
|
177
186
|
}
|
|
178
|
-
return Math.
|
|
179
|
-
GATE_BUDGET_CAP,
|
|
180
|
-
Math.max(GATE_BUDGET_FLOOR, Math.floor(Math.max(0, total) * GATE_BUDGET_SHARE)),
|
|
181
|
-
);
|
|
187
|
+
return Math.max(GATE_BUDGET_FLOOR, Math.floor(Math.max(0, total) * GATE_BUDGET_SHARE));
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
const maxWorkersOf = (env: Env): number | undefined => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The `manifest` step's tool-gate half: a name in `mcpConfirmations({ tools })` that no tool answers
|
|
2
|
+
// to. It holds nothing, so an agent's `refundOrder` runs past a gate spelled `refundOrdr`.
|
|
3
|
+
// `defineAppMcp({ confirmations })` refuses that at boot for its own server; this is every other layout.
|
|
4
|
+
|
|
5
|
+
import { gatedToolNames, knownToolNames, listActions } from '@ultimat3/action';
|
|
6
|
+
import { ERROR_DOCS_URL, isMcpExposed } from '@ultimat3/core';
|
|
7
|
+
import { listQueries } from '@ultimat3/query';
|
|
8
|
+
import { loadApp } from './app-load';
|
|
9
|
+
import type { AppLoader } from './app-permissions';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
|
|
12
|
+
/** Every name a gate could hold: what an agent can be offered, on either surface. */
|
|
13
|
+
function toolNames(): readonly string[] {
|
|
14
|
+
const exposed = [...listActions(), ...listQueries()].filter((one) => isMcpExposed(one.mcp));
|
|
15
|
+
return [...new Set([...exposed.map((one) => one.name), ...knownToolNames()])].sort();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Says nothing when the load failed: a short registry is the load's finding. */
|
|
19
|
+
export async function unknownToolGateFindings(
|
|
20
|
+
root: string,
|
|
21
|
+
load: AppLoader = loadApp,
|
|
22
|
+
): Promise<readonly Finding[]> {
|
|
23
|
+
if ((await load(root)).findings.length > 0) return [];
|
|
24
|
+
const known = toolNames();
|
|
25
|
+
const shown = known.length === 0 ? '(none)' : known.join(', ');
|
|
26
|
+
return gatedToolNames()
|
|
27
|
+
.filter((name) => !known.includes(name))
|
|
28
|
+
.map((name) => ({
|
|
29
|
+
code: 'X_MCP_CONFIRMATION_TOOL_UNKNOWN',
|
|
30
|
+
cause: `mcpConfirmations({ tools }) names ${name}, which no exposed action, exposed query, MCP tool or in-process tool answers to, so the gate holds nothing and the tool it meant runs unconfirmed`,
|
|
31
|
+
fix: `mcpConfirmations({ tools: [...] }) # name tools this app declares: ${shown}`,
|
|
32
|
+
docs: ERROR_DOCS_URL,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `x upgrade`'s one context-file edit: `x new` writes `AGENTS.md` as a symlink to `CLAUDE.md`, and an
|
|
2
|
+
// app scaffolded before that holds two regular files. Byte-identical, the copy becomes the link;
|
|
3
|
+
// different, it is reported with the merge to do by hand — which half is right is the author's call.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no lstat, the one call that tells a link from the file it points at.
|
|
6
|
+
import { lstat } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { writeGenerated } from './write-generated';
|
|
9
|
+
|
|
10
|
+
export const AGENTS_FILE = 'AGENTS.md';
|
|
11
|
+
export const CLAUDE_FILE = 'CLAUDE.md';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `link` — two identical regular files, so the hop replaces `AGENTS.md` with the link;
|
|
15
|
+
* `linked` — already a symlink; `differs` — two regular files with different text, left alone;
|
|
16
|
+
* `none` — one of the two is missing, so there is no pair to join.
|
|
17
|
+
*/
|
|
18
|
+
export type AgentsLinkState = 'link' | 'linked' | 'differs' | 'none';
|
|
19
|
+
|
|
20
|
+
const kindOf = async (path: string): Promise<'file' | 'link' | 'missing'> => {
|
|
21
|
+
try {
|
|
22
|
+
const stat = await lstat(path);
|
|
23
|
+
return stat.isSymbolicLink() ? 'link' : stat.isFile() ? 'file' : 'missing';
|
|
24
|
+
} catch {
|
|
25
|
+
return 'missing';
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Read-only: what the hop would do to `<root>/AGENTS.md`. `--dry-run` reports exactly this. */
|
|
30
|
+
export async function planAgentsLink(root: string): Promise<AgentsLinkState> {
|
|
31
|
+
const agents = await kindOf(join(root, AGENTS_FILE));
|
|
32
|
+
if (agents === 'link') return 'linked';
|
|
33
|
+
if (agents === 'missing' || (await kindOf(join(root, CLAUDE_FILE))) !== 'file') return 'none';
|
|
34
|
+
const [held, source] = await Promise.all([
|
|
35
|
+
Bun.file(join(root, AGENTS_FILE)).bytes(),
|
|
36
|
+
Bun.file(join(root, CLAUDE_FILE)).bytes(),
|
|
37
|
+
]);
|
|
38
|
+
return Buffer.from(held).equals(Buffer.from(source)) ? 'link' : 'differs';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Performs a `link` plan. The contents are `CLAUDE.md`'s own, so where Windows refuses the link the
|
|
43
|
+
* fallback rewrites the identical copy — the file is unchanged and the next hop asks again.
|
|
44
|
+
*/
|
|
45
|
+
export async function applyAgentsLink(root: string, state: AgentsLinkState): Promise<void> {
|
|
46
|
+
if (state !== 'link') return;
|
|
47
|
+
const contents = await Bun.file(join(root, CLAUDE_FILE)).bytes();
|
|
48
|
+
await writeGenerated(root, { path: AGENTS_FILE, contents, link: CLAUDE_FILE });
|
|
49
|
+
}
|