@ultimat3/cli 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +1314 -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
package/src/cmd-doctor.ts
CHANGED
|
@@ -6,16 +6,23 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
8
|
import { checkDb, PGLITE_FIX, PGLITE_MISSING, PGLITE_PACKAGE, postgresClient } from '@ultimat3/db';
|
|
9
|
+
import { loadApp } from './app-load';
|
|
10
|
+
import { IMPORT_UNRESOLVED } from './app-load-unresolved';
|
|
9
11
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
10
12
|
import { doctorSpec } from './cmd-doctor-spec';
|
|
11
13
|
import type { CliCommand, CommandContext } from './command';
|
|
12
14
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
13
15
|
import { appUrlFindings } from './doctor-app-url';
|
|
14
16
|
import { type AuthStorageFact, authStorageFindings, authStorageProbe } from './doctor-auth';
|
|
17
|
+
import { chartAlertsProbe, withChartAlertsListing } from './doctor-chart-alerts';
|
|
15
18
|
import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
|
|
16
19
|
import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
|
|
17
20
|
import type { OfflineFallbackFact } from './doctor-offline';
|
|
18
21
|
import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
22
|
+
import type { PoolerFact } from './doctor-pooler';
|
|
23
|
+
import { NO_POOLER_FACT, poolerFindings, poolerProbe, withPoolerReport } from './doctor-pooler';
|
|
24
|
+
import { rollingSinceProbe, withRollingReminder } from './doctor-rolling';
|
|
25
|
+
import { rowSecurityProbe } from './doctor-row-security';
|
|
19
26
|
import type { SealedKeysFact } from './doctor-sealed';
|
|
20
27
|
import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
|
|
21
28
|
import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
|
|
@@ -97,6 +104,10 @@ export interface DoctorProbe {
|
|
|
97
104
|
* secrets, and tables no release reads. Asked of an external database only.
|
|
98
105
|
*/
|
|
99
106
|
authStorage(): Promise<AuthStorageFact>;
|
|
107
|
+
/** The statement bound the session really reads, and what a pooler refused (`doctor-pooler.ts`). */
|
|
108
|
+
pooler(): Promise<PoolerFact>;
|
|
109
|
+
/** With `database.rowSecurity` on, whether the serving role is exempt (`doctor-row-security.ts`). */
|
|
110
|
+
rowSecurity?(): Promise<readonly Finding[]>;
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/** What `x doctor` reads about the embedded database, without opening it. */
|
|
@@ -201,7 +212,12 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
201
212
|
}
|
|
202
213
|
if (probe.root === undefined) {
|
|
203
214
|
findings.push(
|
|
204
|
-
|
|
215
|
+
// Not `x new`: outside an app `x` is on no PATH, and `bunx x` fetches an unrelated npm package.
|
|
216
|
+
finding(
|
|
217
|
+
'X_NOT_IN_APP',
|
|
218
|
+
'no app.config.ts at or above the working directory',
|
|
219
|
+
'bunx create-ultimate myapp && cd myapp',
|
|
220
|
+
),
|
|
205
221
|
);
|
|
206
222
|
return findings;
|
|
207
223
|
}
|
|
@@ -250,12 +266,19 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
250
266
|
// holds the rule and the reasons.
|
|
251
267
|
const offline = offlineFallbackFinding(await probe.offlineFallback());
|
|
252
268
|
if (offline !== undefined) findings.push(offline);
|
|
253
|
-
const
|
|
269
|
+
const pooler = await probe.pooler();
|
|
270
|
+
findings.push(...poolerFindings(pooler));
|
|
271
|
+
// A pooler's refusal is every other database probe's answer too, and its fix is the true one: this
|
|
272
|
+
// one's says "set DATABASE_URL" to an operator whose URL is right.
|
|
273
|
+
const database = pooler.refused === undefined ? await probe.database() : null;
|
|
254
274
|
if (database !== null) findings.push(database);
|
|
255
275
|
// The other half of the same question, and the half a bare VM lands on: `database()` is silent
|
|
256
276
|
// where there is no `DATABASE_URL`, and that silence IS the bare-VM configuration.
|
|
257
277
|
const embedded = embeddedDatabaseFinding(await probe.embeddedDatabase());
|
|
258
278
|
if (embedded !== undefined) findings.push(embedded);
|
|
279
|
+
// Asked only of a database that answered: an unreachable one is already the finding above.
|
|
280
|
+
const reachable = pooler.refused === undefined && database === null;
|
|
281
|
+
if (reachable) findings.push(...((await probe.rowSecurity?.()) ?? []));
|
|
259
282
|
findings.push(...(await probe.drift()));
|
|
260
283
|
// Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
|
|
261
284
|
// author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
|
|
@@ -333,9 +356,17 @@ const installedAbove = (dir: string, specifier: string): boolean => {
|
|
|
333
356
|
* The pool is CLOSED on every path — this command exits, and a held pool is a connection slot the
|
|
334
357
|
* next `x db migrate` cannot have.
|
|
335
358
|
*/
|
|
336
|
-
async function probeDatabase(
|
|
359
|
+
async function probeDatabase(
|
|
360
|
+
url: string | undefined,
|
|
361
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
362
|
+
): Promise<Finding | null> {
|
|
337
363
|
if (externalUrl(url) === undefined) return null;
|
|
338
|
-
|
|
364
|
+
let client: ReturnType<typeof postgresClient>;
|
|
365
|
+
try {
|
|
366
|
+
client = postgresClient({ url, env, applicationName: 'x-doctor' });
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return findingFrom(error);
|
|
369
|
+
}
|
|
339
370
|
try {
|
|
340
371
|
const report = await checkDb(client);
|
|
341
372
|
if (report.ok) return null;
|
|
@@ -369,7 +400,7 @@ export function probeFor(
|
|
|
369
400
|
frameworkFindings: machineSecretFindings(env),
|
|
370
401
|
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
371
402
|
portFree,
|
|
372
|
-
database: () => probeDatabase(process.env['DATABASE_URL']),
|
|
403
|
+
database: () => probeDatabase(process.env['DATABASE_URL'], env),
|
|
373
404
|
// `root ?? cwd` because the walk needs a directory that exists; outside an app `runDoctor`
|
|
374
405
|
// returns on `X_NOT_IN_APP` before this is ever asked, so the value only has to be honest.
|
|
375
406
|
embeddedDatabase: async () => ({
|
|
@@ -377,23 +408,36 @@ export function probeFor(
|
|
|
377
408
|
resolved: installedAbove(root ?? cwd, PGLITE_PACKAGE),
|
|
378
409
|
}),
|
|
379
410
|
drift: async () => (root === undefined ? [] : checkMigrationDrift(root)),
|
|
380
|
-
|
|
411
|
+
// The import half too: `X_CLI_UNEXPECTED` used to send a reader here and find this silent.
|
|
412
|
+
snapshots: async () =>
|
|
413
|
+
root === undefined
|
|
414
|
+
? []
|
|
415
|
+
: [...(await unresolvedImportFindings(root)), ...(await checkMigrationSnapshots(root))],
|
|
381
416
|
// `routes: undefined` outside an app is "not judged", which is what the caller already is:
|
|
382
417
|
// `runDoctor` returns on `X_NOT_IN_APP` before this can be asked.
|
|
383
418
|
offlineFallback: async () =>
|
|
384
419
|
root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
|
|
385
420
|
sealedKeys: async () =>
|
|
386
421
|
root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
|
|
387
|
-
authStorage: () => authStorageProbe(process.env['DATABASE_URL']),
|
|
422
|
+
authStorage: () => authStorageProbe(process.env['DATABASE_URL'], env),
|
|
423
|
+
pooler: () => poolerProbe(process.env['DATABASE_URL'], env),
|
|
424
|
+
rowSecurity: async () =>
|
|
425
|
+
root === undefined ? [] : rowSecurityProbe(root, process.env['DATABASE_URL'], env),
|
|
388
426
|
};
|
|
389
427
|
}
|
|
390
428
|
|
|
429
|
+
/** What a load reports, narrowed to the one failure a doctor can name a repair for. */
|
|
430
|
+
const unresolvedImportFindings = async (root: string): Promise<readonly Finding[]> =>
|
|
431
|
+
(await loadApp(root)).findings.filter((finding) => finding.code === IMPORT_UNRESOLVED);
|
|
432
|
+
|
|
391
433
|
export const doctorCommand: CliCommand = {
|
|
392
434
|
spec: doctorSpec,
|
|
393
435
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
394
436
|
const port = doctorPort(ctx.args, ctx.env);
|
|
395
437
|
const probe = probeFor(ctx.cwd, ctx.bunVersion, port, ctx.env);
|
|
396
|
-
|
|
438
|
+
// Asked once: `runDoctor` reads the verdict and the report beside it reads the numbers.
|
|
439
|
+
const pooler = probe.root === undefined ? NO_POOLER_FACT : await probe.pooler();
|
|
440
|
+
const findings = await runDoctor({ ...probe, pooler: async () => pooler });
|
|
397
441
|
const listed = withGuardListing(
|
|
398
442
|
{
|
|
399
443
|
ok: findings.length === 0,
|
|
@@ -409,9 +453,22 @@ export const doctorCommand: CliCommand = {
|
|
|
409
453
|
probe.root === undefined ? [] : await shippedGuardsProbe(probe.root),
|
|
410
454
|
);
|
|
411
455
|
// The second listing: what the coverage floor does not see (`doctor-coverage.ts`).
|
|
412
|
-
|
|
456
|
+
const covered = withCoverageExcludes(
|
|
413
457
|
listed,
|
|
414
458
|
probe.root === undefined ? [] : await coverageExcludesProbe(probe.root),
|
|
415
459
|
);
|
|
460
|
+
// The third: the monitoring templates the app's chart predates (`doctor-chart-alerts.ts`).
|
|
461
|
+
const charted = withChartAlertsListing(
|
|
462
|
+
covered,
|
|
463
|
+
probe.root === undefined ? [] : chartAlertsProbe(probe.root),
|
|
464
|
+
);
|
|
465
|
+
// A reminder, not a finding: the rolling rail is off until this is set (`doctor-rolling.ts`).
|
|
466
|
+
return withPoolerReport(
|
|
467
|
+
withRollingReminder(
|
|
468
|
+
charted,
|
|
469
|
+
probe.root === undefined ? undefined : await rollingSinceProbe(probe.root),
|
|
470
|
+
),
|
|
471
|
+
pooler,
|
|
472
|
+
);
|
|
416
473
|
},
|
|
417
474
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// `x drill`'s declaration, apart from its body: the parser, `x help` and the `errors` step read it
|
|
2
|
+
// without loading `cmd-drill.ts`, which `registry.ts` imports only when the command runs.
|
|
3
|
+
|
|
4
|
+
import type { CommandSpec } from './parse';
|
|
5
|
+
|
|
6
|
+
export const drillSpec: CommandSpec = {
|
|
7
|
+
name: 'drill',
|
|
8
|
+
summary:
|
|
9
|
+
'fault the real services under web and worker processes, and fail on any framework contract that does not hold',
|
|
10
|
+
usage: 'x drill [<name>] [--list] [--budget-ms n] [--json]',
|
|
11
|
+
requiresApp: true,
|
|
12
|
+
flags: [
|
|
13
|
+
{
|
|
14
|
+
name: 'list',
|
|
15
|
+
type: 'boolean',
|
|
16
|
+
summary: 'print every drill, its fault and the properties it holds the app to; run nothing',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'budget-ms',
|
|
20
|
+
type: 'string',
|
|
21
|
+
summary:
|
|
22
|
+
'the request deadline the drills judge against, in place of the one derived from the app; printed in the verdict',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
package/src/cmd-drill.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `x drill [<name>] [--list] [--budget-ms n]` — the app's web and worker roles as real processes on
|
|
2
|
+
// real services, each fault in `@ultimat3/testing`'s drill table played through a proxy or a
|
|
3
|
+
// signal, red on any framework contract that does not hold. CLI wiring only: the table, the fleet
|
|
4
|
+
// and the verdict are testing's. Not a gate step: it needs Postgres, and `x verify` needs nothing.
|
|
5
|
+
|
|
6
|
+
import type { AppConfig } from '@ultimat3/core';
|
|
7
|
+
import { DRAIN_DEADLINE_DEFAULT_MS, MAX_TIMER_MS } from '@ultimat3/core';
|
|
8
|
+
import type { Drill, DrillConfig, DrillDeclarations, DrillReport } from '@ultimat3/testing';
|
|
9
|
+
import {
|
|
10
|
+
bunDrillSql,
|
|
11
|
+
DRILL_NAMES,
|
|
12
|
+
DRILLS,
|
|
13
|
+
DrillFailedError,
|
|
14
|
+
DrillServiceMissingError,
|
|
15
|
+
drillBudgets,
|
|
16
|
+
drillNamed,
|
|
17
|
+
drillRoles,
|
|
18
|
+
drillServices,
|
|
19
|
+
runDrill,
|
|
20
|
+
startDrillFleet,
|
|
21
|
+
} from '@ultimat3/testing';
|
|
22
|
+
import { loadAppConfig } from './app-config-load';
|
|
23
|
+
import { requireAppRoot } from './app-root';
|
|
24
|
+
import { drillSpec } from './cmd-drill-spec';
|
|
25
|
+
import type { CliCommand, CommandContext } from './command';
|
|
26
|
+
import { drillSyncLane } from './drill-sync-lane';
|
|
27
|
+
import { drillTargets } from './drill-targets';
|
|
28
|
+
import { UnknownCommandError } from './errors';
|
|
29
|
+
import { readIntFlag } from './flag-number';
|
|
30
|
+
import { msg } from './messages';
|
|
31
|
+
import type { CommandResult, Finding, JsonValue } from './output';
|
|
32
|
+
import { flagBool } from './parse';
|
|
33
|
+
|
|
34
|
+
/** Read through `readIntFlag`, which refuses `5s`, `NaN` and a fraction with the flag's name. */
|
|
35
|
+
const BUDGET_FLAG = 'budget-ms';
|
|
36
|
+
|
|
37
|
+
/** How long a role may take to boot: a cold Bun over a whole app, islands included. */
|
|
38
|
+
const READY_TIMEOUT_MS = 90_000;
|
|
39
|
+
|
|
40
|
+
/** `@ultimat3/http`'s own default — the app's `configureHttp` is runtime code, not config. */
|
|
41
|
+
const HTTP_DEFAULT_DEADLINE_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
const declarationsOf = (config: AppConfig | undefined): DrillDeclarations => ({
|
|
44
|
+
redis: config?.cache.tiers.includes('redis') ?? false,
|
|
45
|
+
nats:
|
|
46
|
+
config?.realtime.transport === 'nats'
|
|
47
|
+
? { env: config.realtime.urlEnv ?? 'NATS_URL' }
|
|
48
|
+
: undefined,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** The facts the thresholds derive from; `drillBudgets` screens every number it is handed. */
|
|
52
|
+
const drillConfigOf = (config: AppConfig | undefined): DrillConfig =>
|
|
53
|
+
config === undefined
|
|
54
|
+
? {
|
|
55
|
+
requestTimeoutMs: HTTP_DEFAULT_DEADLINE_MS,
|
|
56
|
+
drainDeadlineMs: DRAIN_DEADLINE_DEFAULT_MS,
|
|
57
|
+
readinessGraceMs: 0,
|
|
58
|
+
readiness: 'dependencies',
|
|
59
|
+
}
|
|
60
|
+
: {
|
|
61
|
+
requestTimeoutMs: HTTP_DEFAULT_DEADLINE_MS,
|
|
62
|
+
drainDeadlineMs: config.drain.deadlineMs,
|
|
63
|
+
readinessGraceMs: config.drain.readinessGraceMs,
|
|
64
|
+
readiness: config.health.readiness,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const listing = (drill: Drill): JsonValue => ({
|
|
68
|
+
name: drill.name,
|
|
69
|
+
fault:
|
|
70
|
+
drill.fault.kind === 'service'
|
|
71
|
+
? { service: drill.fault.service }
|
|
72
|
+
: { role: drill.fault.role, signal: drill.fault.signal },
|
|
73
|
+
holds: [...drill.holds],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const reportJson = (report: DrillReport): JsonValue =>
|
|
77
|
+
JSON.parse(
|
|
78
|
+
JSON.stringify(report, (_key, value: unknown) =>
|
|
79
|
+
value instanceof Map ? Object.fromEntries(value) : value,
|
|
80
|
+
),
|
|
81
|
+
) as JsonValue;
|
|
82
|
+
|
|
83
|
+
const findingsOf = (report: DrillReport): readonly Finding[] =>
|
|
84
|
+
report.verdict.failures.map((failure) => {
|
|
85
|
+
const error = new DrillFailedError({ drill: report.name, failure });
|
|
86
|
+
return { code: error.code, cause: error.cause, fix: error.fix };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const drillCommand: CliCommand = {
|
|
90
|
+
spec: drillSpec,
|
|
91
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
92
|
+
const root = requireAppRoot('drill', ctx.cwd).dir;
|
|
93
|
+
if (flagBool(ctx.args, 'list')) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
command: 'drill',
|
|
97
|
+
summary: msg('cli.drill.listed', { count: DRILLS.length }),
|
|
98
|
+
lines: DRILLS.map((drill) => ` ${drill.name} ${drill.holds.join(', ')}`),
|
|
99
|
+
data: { drills: DRILLS.map(listing) },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const override = readIntFlag(ctx.args, {
|
|
103
|
+
name: BUDGET_FLAG,
|
|
104
|
+
command: 'drill',
|
|
105
|
+
min: 1,
|
|
106
|
+
max: MAX_TIMER_MS,
|
|
107
|
+
example: 'x drill pg-blackhole --budget-ms 5000 --json',
|
|
108
|
+
});
|
|
109
|
+
const name = ctx.args.positionals[0];
|
|
110
|
+
const named = name === undefined ? undefined : drillNamed(name);
|
|
111
|
+
if (name !== undefined && named === undefined) {
|
|
112
|
+
throw new UnknownCommandError({
|
|
113
|
+
path: `drill ${name}`,
|
|
114
|
+
known: DRILL_NAMES,
|
|
115
|
+
suggestion: 'drill --list --json',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const config = await loadAppConfig(root);
|
|
119
|
+
const declared = declarationsOf(config);
|
|
120
|
+
// Every drill's services, decided before any process starts. A named drill's gap is thrown; in
|
|
121
|
+
// a whole run an undeclared service is stated as not run — an unset URL still refuses it all.
|
|
122
|
+
const planned: { drill: Drill; services: ReturnType<typeof drillServices> }[] = [];
|
|
123
|
+
const notRun: string[] = [];
|
|
124
|
+
for (const drill of named === undefined ? DRILLS : [named]) {
|
|
125
|
+
try {
|
|
126
|
+
planned.push({ drill, services: drillServices(drill, ctx.env, declared) });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const undeclared =
|
|
129
|
+
named === undefined &&
|
|
130
|
+
error instanceof DrillServiceMissingError &&
|
|
131
|
+
error.meta?.['gap'] === 'undeclared';
|
|
132
|
+
if (!undeclared) throw error;
|
|
133
|
+
notRun.push(drill.name);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const budgets = drillBudgets(drillConfigOf(config), override);
|
|
137
|
+
const { targets, skipped } = await drillTargets(root);
|
|
138
|
+
const reports: DrillReport[] = [];
|
|
139
|
+
for (const { drill, services } of planned) {
|
|
140
|
+
const fleet = await startDrillFleet({
|
|
141
|
+
root,
|
|
142
|
+
services,
|
|
143
|
+
roles: drillRoles(drill),
|
|
144
|
+
readyTimeoutMs: READY_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
const sql = bunDrillSql(fleet.databaseUrl);
|
|
147
|
+
try {
|
|
148
|
+
const sync = drillSyncLane(fleet);
|
|
149
|
+
reports.push(await runDrill({ drill, fleet, budgets, targets, sql, sync }));
|
|
150
|
+
} finally {
|
|
151
|
+
await sql.close();
|
|
152
|
+
await fleet.stop();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const held = reports.filter((report) => report.verdict.ok).length;
|
|
156
|
+
const findings = reports.flatMap(findingsOf);
|
|
157
|
+
return {
|
|
158
|
+
ok: findings.length === 0 && held === reports.length,
|
|
159
|
+
command: 'drill',
|
|
160
|
+
summary: msg('cli.drill.summary', { held, ran: reports.length, notRun: notRun.length }),
|
|
161
|
+
lines: [
|
|
162
|
+
...reports.map((report) => ` ${report.verdict.ok ? 'held' : 'FAILED'} ${report.name}`),
|
|
163
|
+
...notRun.map((drill) => ` not run ${drill}`),
|
|
164
|
+
],
|
|
165
|
+
findings,
|
|
166
|
+
data: {
|
|
167
|
+
budgets: { ...budgets, override: override ?? null },
|
|
168
|
+
targets: targets.map((target) => `${target.method} ${target.path}`),
|
|
169
|
+
// The actions the write lane left out, each with why: an empty lane is never a silent one.
|
|
170
|
+
skippedWrites: skipped.map((entry) => ({ ...entry })),
|
|
171
|
+
drills: reports.map(reportJson),
|
|
172
|
+
notRun,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// `x env init --production`: the one writer of `.env.production`, the file `x deploy --method
|
|
2
|
+
// compose` reads and nothing else wrote. Values come from the app's own compose file and env
|
|
3
|
+
// declaration (`env-production.ts`); secrets are minted here, written once, and never printed.
|
|
4
|
+
|
|
5
|
+
// why: Bun.write takes neither an exclusive-create flag nor a file mode, and a secrets file needs
|
|
6
|
+
// both: `wx` refuses a file that appeared since the check, 0600 keeps it from other users.
|
|
7
|
+
import { writeFile } from 'node:fs/promises';
|
|
8
|
+
// why: Bun ships no path-join primitive, and both files are app-root-relative.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { UltimateError } from '@ultimat3/core';
|
|
11
|
+
import { generateVapidKeys } from '@ultimat3/pwa';
|
|
12
|
+
import { appSecretFacts, loadEnvSchema } from './app-env';
|
|
13
|
+
import { requireAppRoot } from './app-root';
|
|
14
|
+
import { readComposeFile } from './cmd-deploy-compose';
|
|
15
|
+
import type { CommandContext } from './command';
|
|
16
|
+
import { productionEnvFile } from './env-production';
|
|
17
|
+
import { BadFlagError } from './errors';
|
|
18
|
+
import { msg } from './messages';
|
|
19
|
+
import type { CommandResult } from './output';
|
|
20
|
+
import { flagBool } from './parse';
|
|
21
|
+
import { PROD_ENV_FILE } from './templates/scaffold-container-compose';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The file exists. Never overwritten: it holds the password the database volume was initialised
|
|
25
|
+
* with and the keys every session and cursor is signed with, so new ones lock the running deploy
|
|
26
|
+
* out of its own data.
|
|
27
|
+
*/
|
|
28
|
+
export class EnvFileExistsError extends UltimateError {
|
|
29
|
+
constructor(input: { path: string }) {
|
|
30
|
+
super({
|
|
31
|
+
code: 'X_ENV_FILE_EXISTS',
|
|
32
|
+
cause: `${input.path} already exists, and x env init would replace the secrets this deploy runs with`,
|
|
33
|
+
// `NODE_ENV=production` is what makes Bun load `.env.production` into the check's process.
|
|
34
|
+
fix: `NODE_ENV=production x env check --json # the file is kept: edit a value in ${input.path} by hand`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 32 random bytes as hex — `openssl rand -hex 32`, without needing openssl on the box. */
|
|
40
|
+
export const randomSecret = (): string =>
|
|
41
|
+
Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
|
|
42
|
+
|
|
43
|
+
/** `.gitignore` gains the file unless a line already names it; the scaffold's ignores `.env` only. */
|
|
44
|
+
async function ignoreEnvFile(root: string): Promise<boolean> {
|
|
45
|
+
const path = join(root, '.gitignore');
|
|
46
|
+
const file = Bun.file(path);
|
|
47
|
+
const text = (await file.exists()) ? await file.text() : '';
|
|
48
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim());
|
|
49
|
+
if (lines.includes(PROD_ENV_FILE) || lines.includes(`/${PROD_ENV_FILE}`)) return false;
|
|
50
|
+
const separator = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
51
|
+
await Bun.write(path, `${text}${separator}${PROD_ENV_FILE}\n`);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function initProductionEnv(
|
|
56
|
+
ctx: CommandContext,
|
|
57
|
+
secret: () => string = randomSecret,
|
|
58
|
+
): Promise<CommandResult> {
|
|
59
|
+
if (!flagBool(ctx.args, 'production')) {
|
|
60
|
+
throw new BadFlagError({
|
|
61
|
+
flag: 'production',
|
|
62
|
+
command: 'env init',
|
|
63
|
+
reason: `init writes ${PROD_ENV_FILE}, the one environment a compose deploy reads`,
|
|
64
|
+
fix: 'x env init --production --json',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const root = requireAppRoot('env init', ctx.cwd).dir;
|
|
68
|
+
const path = join(root, PROD_ENV_FILE);
|
|
69
|
+
if (await Bun.file(path).exists()) throw new EnvFileExistsError({ path: PROD_ENV_FILE });
|
|
70
|
+
const facts = await appSecretFacts(root);
|
|
71
|
+
const env = productionEnvFile({
|
|
72
|
+
schema: (await loadEnvSchema(root)) ?? {},
|
|
73
|
+
compose: await readComposeFile(join(root, 'docker', 'docker-compose.prod.yml')),
|
|
74
|
+
facts,
|
|
75
|
+
secret,
|
|
76
|
+
vapid: facts.push ? await generateVapidKeys() : undefined,
|
|
77
|
+
});
|
|
78
|
+
// Ignored before it is written: a crash between the two must not leave secrets to `git add -A`.
|
|
79
|
+
const ignored = await ignoreEnvFile(root);
|
|
80
|
+
await writeFile(path, env.contents, { flag: 'wx', mode: 0o600 });
|
|
81
|
+
const complete = env.unfilled.length === 0;
|
|
82
|
+
return {
|
|
83
|
+
// Written either way — a rerun refuses the file — and red until every required key has a value.
|
|
84
|
+
ok: complete,
|
|
85
|
+
command: 'env',
|
|
86
|
+
summary: msg('cli.env.initialised', { path: PROD_ENV_FILE, count: env.written.length }),
|
|
87
|
+
lines: env.unfilled.map((key) => msg('cli.env.unfilled', { key, path: PROD_ENV_FILE })),
|
|
88
|
+
findings: env.unfilled.map((key) => ({
|
|
89
|
+
code: 'X_ENV_MISSING',
|
|
90
|
+
cause: `${key} is required, and nothing in this app says what its production value is`,
|
|
91
|
+
fix: `NODE_ENV=production x env check --json # after setting ${key} in ${PROD_ENV_FILE}`,
|
|
92
|
+
at: PROD_ENV_FILE,
|
|
93
|
+
})),
|
|
94
|
+
data: {
|
|
95
|
+
path: PROD_ENV_FILE,
|
|
96
|
+
written: [...env.written],
|
|
97
|
+
unfilled: [...env.unfilled],
|
|
98
|
+
gitignored: ignored,
|
|
99
|
+
},
|
|
100
|
+
exitCode: complete ? 0 : 1,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/cmd-env-spec.ts
CHANGED
|
@@ -8,11 +8,18 @@ import type { CommandSpec } from './parse';
|
|
|
8
8
|
export const envSpec: CommandSpec = {
|
|
9
9
|
name: 'env',
|
|
10
10
|
summary: `the typed environment declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`,
|
|
11
|
-
usage: 'x env [check|example] [--json]',
|
|
11
|
+
usage: 'x env [check|example] [--json] | x env init --production [--json]',
|
|
12
12
|
requiresApp: true,
|
|
13
|
-
subcommands: ['check', 'example'],
|
|
13
|
+
subcommands: ['check', 'example', 'init'],
|
|
14
14
|
// The bare `x env` answers the question the fix line on every `X_ENV_MISSING` in this
|
|
15
15
|
// framework already tells its reader to run.
|
|
16
16
|
defaultSubcommand: 'check',
|
|
17
|
-
flags: [
|
|
17
|
+
flags: [
|
|
18
|
+
{
|
|
19
|
+
name: 'production',
|
|
20
|
+
type: 'boolean',
|
|
21
|
+
summary: 'write .env.production: what x deploy --method compose reads, secrets generated',
|
|
22
|
+
subcommands: ['init'],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
18
25
|
};
|
package/src/cmd-env.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `x env` — the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `x env` — the things a typed environment owes an agent: the committed `.env.example` that says
|
|
2
|
+
// which variables exist, the answer to "does this process have them?", and (`init`,
|
|
3
|
+
// `cmd-env-init.ts`) the deploy's `.env.production`. All read the one `defineEnv` declaration.
|
|
4
4
|
|
|
5
5
|
// Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
|
|
6
6
|
import { join } from 'node:path';
|
|
@@ -15,7 +15,7 @@ import { appSecretFacts, envExampleFor, loadEnvSchema } from './app-env';
|
|
|
15
15
|
import { requireAppRoot } from './app-root';
|
|
16
16
|
import { envSpec } from './cmd-env-spec';
|
|
17
17
|
import type { CliCommand, CommandContext } from './command';
|
|
18
|
-
import { frameworkSecretFindings } from './framework-env';
|
|
18
|
+
import { frameworkSecretFindings, namesNoEnvironment } from './framework-env';
|
|
19
19
|
import { msg } from './messages';
|
|
20
20
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
21
21
|
|
|
@@ -97,12 +97,17 @@ async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
|
|
|
97
97
|
...framework,
|
|
98
98
|
];
|
|
99
99
|
const ok = findings.length === 0;
|
|
100
|
+
const verdict = ok
|
|
101
|
+
? msg('cli.env.checked', { count: total })
|
|
102
|
+
: msg('cli.env.invalid', { count: findings.length, total });
|
|
100
103
|
return {
|
|
101
104
|
ok,
|
|
102
105
|
command: 'env',
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
// `x doctor` asks about this machine and stays quiet on the same shell, so an exit 1 here over
|
|
107
|
+
// a table that named nothing reads as a contradiction until this line says which question ran.
|
|
108
|
+
summary: namesNoEnvironment(ctx.env)
|
|
109
|
+
? `${verdict} — ${msg('cli.env.checkedAsProduction')}`
|
|
110
|
+
: verdict,
|
|
106
111
|
findings,
|
|
107
112
|
data: {
|
|
108
113
|
variables: total,
|
|
@@ -118,8 +123,8 @@ export const envCommand: CliCommand = {
|
|
|
118
123
|
// `subcommand`, never `positionals[0]`: the parser has already lifted a declared subcommand
|
|
119
124
|
// out of the positionals, so reading the array here matches nothing and every invocation
|
|
120
125
|
// silently ran the default.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
const subcommand = ctx.args.subcommand ?? 'check';
|
|
127
|
+
if (subcommand === 'init') return (await import('./cmd-env-init')).initProductionEnv(ctx);
|
|
128
|
+
return subcommand === 'example' ? writeExample(ctx) : checkProcessEnv(ctx);
|
|
124
129
|
},
|
|
125
130
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `x errors explain <CODE> --recent` — the code's explanation, then what this app has seen of it:
|
|
2
|
+
// its groups in `x_error_groups` (count, first and last seen) and the replayable bundles
|
|
3
|
+
// `x_failures` holds, each with the `x repro` line that turns it into a failing test.
|
|
4
|
+
|
|
5
|
+
import { postgresErrorGroupsStore } from '@ultimat3/admin/error-groups';
|
|
6
|
+
import { postgresFailureStore } from '@ultimat3/db';
|
|
7
|
+
import { requireAppRoot } from './app-root';
|
|
8
|
+
import type { CommandContext } from './command';
|
|
9
|
+
import { withJobDriver } from './jobs-driver';
|
|
10
|
+
import type { CommandResult, JsonValue } from './output';
|
|
11
|
+
|
|
12
|
+
/** The newest groups read to find this code's: the store's own ceiling (`ERROR_GROUPS_MAX_LIMIT`). */
|
|
13
|
+
const GROUPS_READ = 500;
|
|
14
|
+
|
|
15
|
+
/** `explained` with the app's own sightings of `code` folded into its lines and its data. */
|
|
16
|
+
export function withRecent(
|
|
17
|
+
ctx: CommandContext,
|
|
18
|
+
code: string,
|
|
19
|
+
explained: CommandResult,
|
|
20
|
+
): Promise<CommandResult> {
|
|
21
|
+
const root = requireAppRoot('errors explain --recent', ctx.cwd).dir;
|
|
22
|
+
return withJobDriver(root, ctx, async () => {
|
|
23
|
+
const groups = (await postgresErrorGroupsStore().list({ limit: GROUPS_READ })).filter(
|
|
24
|
+
(group) => group.code === code,
|
|
25
|
+
);
|
|
26
|
+
const bundles = await postgresFailureStore().list({ code });
|
|
27
|
+
const recent: JsonValue = {
|
|
28
|
+
groups: groups.map((group) => ({
|
|
29
|
+
fingerprint: group.fingerprint,
|
|
30
|
+
operation: group.operation,
|
|
31
|
+
count: group.count,
|
|
32
|
+
firstSeen: group.firstSeen,
|
|
33
|
+
lastSeen: group.lastSeen,
|
|
34
|
+
status: group.status,
|
|
35
|
+
})),
|
|
36
|
+
bundles: bundles.map((bundle) => ({
|
|
37
|
+
...bundle,
|
|
38
|
+
repro: `x repro ${bundle.id} --write --verify`,
|
|
39
|
+
})),
|
|
40
|
+
};
|
|
41
|
+
const lines = [
|
|
42
|
+
...(explained.lines ?? []),
|
|
43
|
+
` recent: ${groups.length} group(s), ${bundles.length} captured bundle(s)`,
|
|
44
|
+
...groups.map(
|
|
45
|
+
(group) =>
|
|
46
|
+
` ${group.fingerprint} ${String(group.count).padStart(6)}× last ${group.lastSeen} ${group.operation ?? ''}`,
|
|
47
|
+
),
|
|
48
|
+
...bundles.map(
|
|
49
|
+
(bundle) => ` x repro ${bundle.id} --write --verify # ${bundle.name} at ${bundle.at}`,
|
|
50
|
+
),
|
|
51
|
+
];
|
|
52
|
+
const data =
|
|
53
|
+
typeof explained.data === 'object' &&
|
|
54
|
+
explained.data !== null &&
|
|
55
|
+
!Array.isArray(explained.data)
|
|
56
|
+
? explained.data
|
|
57
|
+
: {};
|
|
58
|
+
return { ...explained, lines, data: { ...data, recent } };
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/cmd-errors-spec.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const ERRORS_SUBCOMMANDS = ['explain', 'list'] as const;
|
|
|
8
8
|
export const errorsSpec: CommandSpec = {
|
|
9
9
|
name: 'errors',
|
|
10
10
|
summary: 'an X_* code, explained: cause, runnable fix, docs URL',
|
|
11
|
-
usage: 'x errors [explain <CODE
|
|
11
|
+
usage: 'x errors [explain <CODE> [--recent]|list] [--json]',
|
|
12
12
|
subcommands: ERRORS_SUBCOMMANDS,
|
|
13
13
|
// `explain`, deliberately: the bare `x errors` then answers with `MissingPositionalError`,
|
|
14
14
|
// which names `<CODE>` and hands back a real invocation. `list` would silently print 200 rows
|
|
@@ -20,4 +20,12 @@ export const errorsSpec: CommandSpec = {
|
|
|
20
20
|
// Safe to declare here and nowhere else so far: the only thing that is not `explain` or
|
|
21
21
|
// `list` in this slot is a code, and a near miss of either is still refused (#F16).
|
|
22
22
|
defaultSubcommandTakesPositional: true,
|
|
23
|
+
flags: [
|
|
24
|
+
{
|
|
25
|
+
name: 'recent',
|
|
26
|
+
type: 'boolean',
|
|
27
|
+
summary: "explain: this app's groups of the code and the captured bundles x repro replays",
|
|
28
|
+
subcommands: ['explain'],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
23
31
|
};
|
package/src/cmd-errors.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { nearestName, singleLine } from '@ultimat3/core';
|
|
|
5
5
|
// a plausible-sounding explanation an agent would then act on.
|
|
6
6
|
|
|
7
7
|
import type { ErrorExplanation } from '@ultimat3/mcp';
|
|
8
|
+
import { withRecent } from './cmd-errors-recent';
|
|
8
9
|
import { errorsSpec } from './cmd-errors-spec';
|
|
9
10
|
import type { CliCommand, CommandContext } from './command';
|
|
10
11
|
import type { ErrorCatalog } from './error-catalog';
|
|
@@ -14,6 +15,7 @@ import { ErrorCodeUnknownError, MissingPositionalError } from './errors';
|
|
|
14
15
|
import { explainErrorCode, explainEveryErrorCode } from './mcp-errors';
|
|
15
16
|
import { msg } from './messages';
|
|
16
17
|
import type { CommandResult, JsonValue } from './output';
|
|
18
|
+
import { flagBool } from './parse';
|
|
17
19
|
|
|
18
20
|
export { ERRORS_SUBCOMMANDS } from './cmd-errors-spec';
|
|
19
21
|
|
|
@@ -106,6 +108,8 @@ export const errorsCommand: CliCommand = {
|
|
|
106
108
|
example: 'x errors list --json',
|
|
107
109
|
});
|
|
108
110
|
}
|
|
111
|
+
// `--recent`: what THIS app has seen of the code — groups and the bundles `x repro` replays.
|
|
112
|
+
if (flagBool(ctx.args, 'recent')) return withRecent(ctx, code, explainOne(code));
|
|
109
113
|
return explainOne(code);
|
|
110
114
|
},
|
|
111
115
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// `x exercise`'s declaration, apart from its body: the parser, `x help` and the `errors` step
|
|
2
|
+
// read it without loading `cmd-exercise.ts`, which `registry.ts` imports only when the command runs.
|
|
3
|
+
|
|
4
|
+
import type { CommandSpec } from './parse';
|
|
5
|
+
|
|
6
|
+
export const exerciseSpec: CommandSpec = {
|
|
7
|
+
name: 'exercise',
|
|
8
|
+
summary: 'run every action, query and job once on generated rows, with no test written',
|
|
9
|
+
usage: 'x exercise [<name>] [--seed n] [--json]',
|
|
10
|
+
requiresApp: true,
|
|
11
|
+
flags: [
|
|
12
|
+
{
|
|
13
|
+
name: 'seed',
|
|
14
|
+
type: 'string',
|
|
15
|
+
summary:
|
|
16
|
+
'the integer the seed graph and every generated input are drawn from; same seed, same pass',
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|