@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
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// `x deploy verify`: does production serve this build, does it hold, and does a worker of this build
|
|
2
|
+
// claim every queue. HTTP and Postgres only (axiom 7): pods are SAMPLED through the load balancer,
|
|
3
|
+
// never enumerated, and a GitOps roll that never ran `x deploy` is checked the same way.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
BUILD_ID_HEADER,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
renderThrowable,
|
|
10
|
+
systemClock,
|
|
11
|
+
UltimateError,
|
|
12
|
+
} from '@ultimat3/core';
|
|
13
|
+
import { deadlineSignal } from '@ultimat3/core/outbound';
|
|
14
|
+
import type { QueueDemand, WorkerRecord } from '@ultimat3/jobs';
|
|
15
|
+
import { requireAppRoot } from './app-root';
|
|
16
|
+
import type { CommandContext } from './command';
|
|
17
|
+
import { failed, ok } from './command';
|
|
18
|
+
import type { WaitOutcome } from './deploy-verify-findings';
|
|
19
|
+
import {
|
|
20
|
+
queueReadFinding,
|
|
21
|
+
resultData,
|
|
22
|
+
sampleFinding,
|
|
23
|
+
unservedFinding,
|
|
24
|
+
} from './deploy-verify-findings';
|
|
25
|
+
import type { VerifyOptions } from './deploy-verify-flags';
|
|
26
|
+
import { readBuild, readOptions, VERIFY_COMMAND } from './deploy-verify-flags';
|
|
27
|
+
import type { QueueGrade } from './deploy-verify-grade';
|
|
28
|
+
import {
|
|
29
|
+
emptyTally,
|
|
30
|
+
failureOf,
|
|
31
|
+
foldSample,
|
|
32
|
+
gradeQueues,
|
|
33
|
+
UNGRADED,
|
|
34
|
+
windowClosed,
|
|
35
|
+
} from './deploy-verify-grade';
|
|
36
|
+
import { BadFlagError } from './errors';
|
|
37
|
+
import { msg } from './messages';
|
|
38
|
+
import type { CommandResult, Finding } from './output';
|
|
39
|
+
|
|
40
|
+
/** One sample's own limit: a pod that hangs is a bad sample, never the whole wait gone. */
|
|
41
|
+
const SAMPLE_TIMEOUT_MS = 10_000;
|
|
42
|
+
/**
|
|
43
|
+
* The least a sample gets, however little of the wait is left. The last sleep ends ON the deadline,
|
|
44
|
+
* and that sample cut to nothing tallied a healthy origin as `unreachable`, every run. So `--timeout`
|
|
45
|
+
* can overrun by this much, once.
|
|
46
|
+
*/
|
|
47
|
+
const SAMPLE_FLOOR_MS = 1_000;
|
|
48
|
+
|
|
49
|
+
/** What the queue grade reads. `close` lets the short-lived pool go. */
|
|
50
|
+
export interface QueueReader {
|
|
51
|
+
workers(): Promise<readonly WorkerRecord[]>;
|
|
52
|
+
/**
|
|
53
|
+
* `JobDriver.demand()`: running on a worker that still serves, plus due rows of unpaused queues.
|
|
54
|
+
* Never `ready + running`, which counts a retiring old worker's runs and a paused queue's rows as
|
|
55
|
+
* work an `--idle-ok` pool is missing.
|
|
56
|
+
*/
|
|
57
|
+
demand(): Promise<readonly QueueDemand[]>;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ExpectedQueues {
|
|
62
|
+
readonly queues: readonly string[];
|
|
63
|
+
/** A module that would not import leaves the set partial, which grades nothing honestly. */
|
|
64
|
+
readonly findings: readonly Finding[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DeployVerifyDeps {
|
|
68
|
+
readonly clock: Clock;
|
|
69
|
+
sleep(ms: number): Promise<void>;
|
|
70
|
+
expectedQueues(root: string): Promise<ExpectedQueues>;
|
|
71
|
+
/**
|
|
72
|
+
* `env` is the one `DATABASE_URL` came from, so the pooler mode is read from it too.
|
|
73
|
+
* `statementTimeoutMs` is what is left of `--timeout`: the server cancels a read past it.
|
|
74
|
+
*/
|
|
75
|
+
openQueue(
|
|
76
|
+
databaseUrl: string,
|
|
77
|
+
env: CommandContext['env'],
|
|
78
|
+
statementTimeoutMs: number,
|
|
79
|
+
): Promise<QueueReader>;
|
|
80
|
+
/**
|
|
81
|
+
* Resolves once `ms` of real time pass: what a hung read is raced against. `signal` disarms it,
|
|
82
|
+
* aborted the moment the read settles, so no timer holds the process open past the verdict.
|
|
83
|
+
*/
|
|
84
|
+
expire(ms: number, signal: AbortSignal): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves once `ms` of real time pass; `signal`'s abort disarms it. Core's `deadlineSignal`, the
|
|
89
|
+
* one undoable deadline: a timer left armed would hold the process open past the verdict.
|
|
90
|
+
*/
|
|
91
|
+
export function expireAfter(ms: number, signal: AbortSignal): Promise<void> {
|
|
92
|
+
return new Promise<void>((resolve) => {
|
|
93
|
+
const deadline = deadlineSignal(ms, signal);
|
|
94
|
+
deadline.signal.addEventListener(
|
|
95
|
+
'abort',
|
|
96
|
+
() => {
|
|
97
|
+
const fired = deadline.timedOut();
|
|
98
|
+
deadline[Symbol.dispose]();
|
|
99
|
+
if (fired) resolve();
|
|
100
|
+
},
|
|
101
|
+
{ once: true },
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Imported when asked: the verdict on the build alone never loads the app or a database driver. */
|
|
107
|
+
const realDeps: DeployVerifyDeps = {
|
|
108
|
+
clock: systemClock,
|
|
109
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
110
|
+
expectedQueues: async (root) => (await import('./deploy-verify-queues')).expectedQueues(root),
|
|
111
|
+
openQueue: async (url, env, statementTimeoutMs) =>
|
|
112
|
+
(await import('./deploy-verify-queues')).openQueue(url, env, statementTimeoutMs),
|
|
113
|
+
expire: expireAfter,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
interface Answered {
|
|
117
|
+
readonly status: number | null;
|
|
118
|
+
readonly build: string | null;
|
|
119
|
+
readonly cached: boolean;
|
|
120
|
+
readonly failure?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One `GET /readyz` on a connection of its own, so the load balancer chooses again: a kept-alive
|
|
125
|
+
* socket would ask one pod the same question for the whole window. A redirect is an answer to tally,
|
|
126
|
+
* never to follow: another host's `/readyz` says nothing about this origin.
|
|
127
|
+
*/
|
|
128
|
+
async function takeSample(origin: string, limitMs: number): Promise<Answered> {
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetch(`${origin}/readyz`, {
|
|
131
|
+
keepalive: false,
|
|
132
|
+
redirect: 'manual',
|
|
133
|
+
// Past a CDN or a caching proxy: the question is the fleet, never a stored copy of it.
|
|
134
|
+
headers: { 'cache-control': 'no-cache', pragma: 'no-cache' },
|
|
135
|
+
signal: AbortSignal.timeout(limitMs),
|
|
136
|
+
});
|
|
137
|
+
await response.body?.cancel();
|
|
138
|
+
const header = response.headers.get(BUILD_ID_HEADER);
|
|
139
|
+
const age = Number(response.headers.get('age') ?? '0');
|
|
140
|
+
return {
|
|
141
|
+
status: response.status,
|
|
142
|
+
build: header === null || header === '' ? null : header,
|
|
143
|
+
cached: Number.isNaN(age) || age > 0,
|
|
144
|
+
};
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { status: null, build: null, cached: false, failure: renderThrowable(error) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The least time a database read gets, and the floor of its server-side `statement_timeout`. */
|
|
151
|
+
const READ_FLOOR_MS = 1_000;
|
|
152
|
+
|
|
153
|
+
/** The queue half: the set to grade, and how to reach the rows. Absent: no `DATABASE_URL`. */
|
|
154
|
+
interface QueuePlan {
|
|
155
|
+
readonly expected: readonly string[];
|
|
156
|
+
open(statementTimeoutMs: number): Promise<QueueReader>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The last grade a read produced, and the last read that failed. No grade: none ever landed. */
|
|
160
|
+
interface QueueRead {
|
|
161
|
+
readonly grade: QueueGrade | undefined;
|
|
162
|
+
readonly lastFailure: string | undefined;
|
|
163
|
+
/**
|
|
164
|
+
* The grade came from a read on THIS pass. A kept grade is detail for the report and never a
|
|
165
|
+
* pass: workers can go between a read that landed and the next that failed.
|
|
166
|
+
*/
|
|
167
|
+
readonly current: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface Verdict {
|
|
171
|
+
readonly wait: WaitOutcome;
|
|
172
|
+
/** `undefined`: no queue plan, or the window never closed so nothing was read. */
|
|
173
|
+
readonly read: QueueRead | undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One read of both halves, bounded by `limitMs` of real time. A miss is a failed read. */
|
|
177
|
+
async function readQueues(
|
|
178
|
+
reader: QueueReader,
|
|
179
|
+
input: {
|
|
180
|
+
readonly expected: readonly string[];
|
|
181
|
+
readonly build: string;
|
|
182
|
+
readonly idleOk: readonly string[];
|
|
183
|
+
},
|
|
184
|
+
limitMs: number,
|
|
185
|
+
deps: DeployVerifyDeps,
|
|
186
|
+
): Promise<QueueGrade> {
|
|
187
|
+
const disarm = new AbortController();
|
|
188
|
+
const expired = deps.expire(limitMs, disarm.signal).then(() => {
|
|
189
|
+
throw new UltimateError({
|
|
190
|
+
code: 'X_DB_UNAVAILABLE',
|
|
191
|
+
cause: `a read of x_job_workers outlasted one --interval or what was left of --timeout (${limitMs} ms)`,
|
|
192
|
+
fix: 'x jobs list --json',
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
let rows: readonly [readonly WorkerRecord[], readonly QueueDemand[]];
|
|
196
|
+
try {
|
|
197
|
+
rows = await Promise.race([Promise.all([reader.workers(), reader.demand()]), expired]);
|
|
198
|
+
} finally {
|
|
199
|
+
disarm.abort();
|
|
200
|
+
}
|
|
201
|
+
const [workers, demand] = rows;
|
|
202
|
+
return gradeQueues({
|
|
203
|
+
...input,
|
|
204
|
+
workers,
|
|
205
|
+
depth: new Map(demand.map((queue) => [queue.queue, queue.demand])),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Sample until the window holds AND, with a queue plan, every queue is served, both on the same
|
|
211
|
+
* pass: web rolling back while the workers still roll is red, never a window that closed earlier.
|
|
212
|
+
* The queues are read only once the window has closed, then on every pass; the web role and the
|
|
213
|
+
* workers roll independently, so one read right after the window would fail a healthy roll. A failed
|
|
214
|
+
* read keeps the last grade. `--timeout` ends the whole wait, reads included.
|
|
215
|
+
*/
|
|
216
|
+
async function awaitVerdict(
|
|
217
|
+
options: VerifyOptions,
|
|
218
|
+
build: string,
|
|
219
|
+
deadline: number,
|
|
220
|
+
deps: DeployVerifyDeps,
|
|
221
|
+
plan: QueuePlan | undefined,
|
|
222
|
+
): Promise<Verdict> {
|
|
223
|
+
// One bad sample per window's worth of samples is the most a settled fleet is allowed.
|
|
224
|
+
const perWindow = Math.max(1, Math.ceil(options.windowMs / options.intervalMs));
|
|
225
|
+
let tally = emptyTally();
|
|
226
|
+
let lastFailure: string | undefined;
|
|
227
|
+
let reader: QueueReader | undefined;
|
|
228
|
+
let read: QueueRead | undefined;
|
|
229
|
+
const left = (): number => deadline - deps.clock.monotonic();
|
|
230
|
+
/** One `/readyz` sample, folded in; whether the window holds after it. */
|
|
231
|
+
const sample = async (): Promise<boolean> => {
|
|
232
|
+
const answered = await takeSample(
|
|
233
|
+
options.origin,
|
|
234
|
+
Math.max(SAMPLE_FLOOR_MS, Math.min(SAMPLE_TIMEOUT_MS, left())),
|
|
235
|
+
);
|
|
236
|
+
lastFailure = answered.failure ?? lastFailure;
|
|
237
|
+
tally = foldSample(
|
|
238
|
+
tally,
|
|
239
|
+
{
|
|
240
|
+
at: deps.clock.monotonic(),
|
|
241
|
+
status: answered.status,
|
|
242
|
+
build: answered.build,
|
|
243
|
+
cached: answered.cached,
|
|
244
|
+
},
|
|
245
|
+
build,
|
|
246
|
+
);
|
|
247
|
+
return windowClosed(tally, options.windowMs, perWindow);
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
for (;;) {
|
|
251
|
+
// Every pass starts with nothing current: a grade from an earlier pass is history.
|
|
252
|
+
if (read !== undefined) read = { ...read, current: false };
|
|
253
|
+
let closed = await sample();
|
|
254
|
+
if (closed && plan !== undefined) {
|
|
255
|
+
// One --interval at most: the window's last sample is never older than that when the
|
|
256
|
+
// queues are judged, and the server stops the statement at the same bound.
|
|
257
|
+
const bound = Math.max(READ_FLOOR_MS, Math.min(options.intervalMs, left()));
|
|
258
|
+
reader ??= await plan.open(bound);
|
|
259
|
+
try {
|
|
260
|
+
const grade = await readQueues(
|
|
261
|
+
reader,
|
|
262
|
+
{ expected: plan.expected, build, idleOk: options.idleOk },
|
|
263
|
+
bound,
|
|
264
|
+
deps,
|
|
265
|
+
);
|
|
266
|
+
read = { grade, lastFailure: read?.lastFailure, current: true };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// One reset connection is a read lost, never the verdict: the last grade stands.
|
|
269
|
+
read = { grade: read?.grade, lastFailure: renderThrowable(error), current: false };
|
|
270
|
+
}
|
|
271
|
+
// Served is judged against web as it answers NOW: a sample taken after the read landed,
|
|
272
|
+
// so a rollback during the read is red, never a green from the sample before it.
|
|
273
|
+
if (read.current && read.grade?.unserved.length === 0) closed = await sample();
|
|
274
|
+
}
|
|
275
|
+
// The one success rule: this pass's sample holds the window and, with a plan, this pass's
|
|
276
|
+
// read landed and serves every queue. Nothing kept from an earlier pass counts.
|
|
277
|
+
const served =
|
|
278
|
+
plan === undefined || (read?.current === true && read.grade?.unserved.length === 0);
|
|
279
|
+
const wait = { tally, closed, lastFailure };
|
|
280
|
+
if ((closed && served) || left() <= 0) return { wait, read };
|
|
281
|
+
await deps.sleep(Math.min(options.intervalMs, left()));
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
await reader?.close();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The set first, so a typo or an app that will not load costs no wait. */
|
|
289
|
+
async function readExpected(
|
|
290
|
+
root: string,
|
|
291
|
+
options: VerifyOptions,
|
|
292
|
+
deps: DeployVerifyDeps,
|
|
293
|
+
): Promise<ExpectedQueues> {
|
|
294
|
+
const expected = await deps.expectedQueues(root);
|
|
295
|
+
if (expected.findings.length > 0) return expected;
|
|
296
|
+
const stray = options.idleOk.find((queue) => !expected.queues.includes(queue));
|
|
297
|
+
if (stray !== undefined) {
|
|
298
|
+
throw new BadFlagError({
|
|
299
|
+
flag: 'idle-ok',
|
|
300
|
+
command: VERIFY_COMMAND,
|
|
301
|
+
reason: `${renderCauseValue(stray)} is not a queue of this app (${expected.queues.map((queue) => renderCauseValue(queue)).join(', ')}), so it would exempt nothing`,
|
|
302
|
+
fix: 'x jobs list --json # data.depth.queues names what each queue holds',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return expected;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function runDeployVerify(
|
|
309
|
+
ctx: CommandContext,
|
|
310
|
+
deps: DeployVerifyDeps = realDeps,
|
|
311
|
+
): Promise<CommandResult> {
|
|
312
|
+
const root = requireAppRoot('deploy', ctx.cwd).dir;
|
|
313
|
+
const options = readOptions(ctx);
|
|
314
|
+
const build = await readBuild(ctx, root);
|
|
315
|
+
// Read off the env directly: `resolveServices` falls back to the embedded database and would grade
|
|
316
|
+
// an empty one, so a CI job with no credentials would "pass" having read nothing.
|
|
317
|
+
const raw = ctx.env['DATABASE_URL'];
|
|
318
|
+
const databaseUrl = raw === undefined || raw.trim() === '' ? undefined : raw;
|
|
319
|
+
const expected = databaseUrl === undefined ? undefined : await readExpected(root, options, deps);
|
|
320
|
+
if (expected !== undefined && expected.findings.length > 0) {
|
|
321
|
+
return failed(
|
|
322
|
+
'deploy',
|
|
323
|
+
msg('cli.deploy.unverified', { build, count: expected.findings.length }),
|
|
324
|
+
{
|
|
325
|
+
findings: expected.findings,
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const deadline = deps.clock.monotonic() + options.timeoutMs;
|
|
331
|
+
const plan: QueuePlan | undefined =
|
|
332
|
+
databaseUrl === undefined || expected === undefined
|
|
333
|
+
? undefined
|
|
334
|
+
: {
|
|
335
|
+
expected: expected.queues,
|
|
336
|
+
open: (statementTimeoutMs) => deps.openQueue(databaseUrl, ctx.env, statementTimeoutMs),
|
|
337
|
+
};
|
|
338
|
+
const { wait, read } = await awaitVerdict(options, build, deadline, deps, plan);
|
|
339
|
+
if (!wait.closed) {
|
|
340
|
+
return failed('deploy', msg('cli.deploy.unverified', { build, count: 1 }), {
|
|
341
|
+
findings: [sampleFinding(failureOf(wait.tally, build), wait, build, options)],
|
|
342
|
+
data: resultData(build, options.windowMs, wait.tally, read?.grade ?? UNGRADED),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const samples = wait.tally.total;
|
|
347
|
+
const held = { build, windowMs: options.windowMs, samples };
|
|
348
|
+
if (plan === undefined) {
|
|
349
|
+
return ok('deploy', msg('cli.deploy.verifiedUngraded', held), {
|
|
350
|
+
data: resultData(build, options.windowMs, wait.tally, UNGRADED),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const queues = read?.grade;
|
|
354
|
+
if (queues === undefined) {
|
|
355
|
+
return failed('deploy', msg('cli.deploy.unverified', { build, count: 1 }), {
|
|
356
|
+
findings: [queueReadFinding(read?.lastFailure)],
|
|
357
|
+
data: resultData(build, options.windowMs, wait.tally, UNGRADED),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const data = resultData(build, options.windowMs, wait.tally, queues);
|
|
361
|
+
// A kept grade that says served is not a pass: the read that would confirm it never landed.
|
|
362
|
+
if (queues.unserved.length === 0 && read?.current !== true) {
|
|
363
|
+
return failed('deploy', msg('cli.deploy.unverified', { build, count: 1 }), {
|
|
364
|
+
findings: [queueReadFinding(read?.lastFailure)],
|
|
365
|
+
data,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (queues.unserved.length > 0) {
|
|
369
|
+
return failed(
|
|
370
|
+
'deploy',
|
|
371
|
+
msg('cli.deploy.unverified', { build, count: queues.unserved.length }),
|
|
372
|
+
{
|
|
373
|
+
findings: queues.unserved.map((unserved) => unservedFinding(unserved, options.origin)),
|
|
374
|
+
data,
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const verified = { ...held, served: queues.served.length, idle: queues.idle.length };
|
|
379
|
+
return ok('deploy', msg('cli.deploy.verified', verified), { data });
|
|
380
|
+
}
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import { loadEnvSchema } from './app-env';
|
|
7
8
|
import { requireAppRoot } from './app-root';
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type ComposeStrategy,
|
|
11
|
+
composeStrategies,
|
|
12
|
+
DEFAULT_IMAGE,
|
|
13
|
+
readComposeFile,
|
|
14
|
+
} from './cmd-deploy-compose';
|
|
9
15
|
import { readHelmDrainOverrides } from './cmd-deploy-drain';
|
|
10
16
|
import {
|
|
11
17
|
type HelmTarget,
|
|
@@ -15,6 +21,7 @@ import {
|
|
|
15
21
|
readReleaseName,
|
|
16
22
|
readRollout,
|
|
17
23
|
} from './cmd-deploy-helm';
|
|
24
|
+
import { composeEnvPreflight, lastLine, roleLastLogLine } from './cmd-deploy-preflight';
|
|
18
25
|
import { deploySpec } from './cmd-deploy-spec';
|
|
19
26
|
import { runStartFirst, startFirstUp } from './cmd-deploy-start-first';
|
|
20
27
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -344,8 +351,11 @@ const stepLine = (env: Readonly<Record<string, string>>, command: readonly strin
|
|
|
344
351
|
export const deployCommand: CliCommand = {
|
|
345
352
|
spec: deploySpec,
|
|
346
353
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
354
|
+
if (ctx.args.subcommand === 'verify') {
|
|
355
|
+
return (await import('./cmd-deploy-verify')).runDeployVerify(ctx);
|
|
356
|
+
}
|
|
347
357
|
const root = requireAppRoot('deploy', ctx.cwd).dir;
|
|
348
|
-
const image = flagString(ctx.args, 'image') ??
|
|
358
|
+
const image = flagString(ctx.args, 'image') ?? DEFAULT_IMAGE;
|
|
349
359
|
// No "is there a chart?" branch. It threw X_NOT_IMPLEMENTED — "this build does not implement
|
|
350
360
|
// helm" — over a build that implements it completely (`planDeploy` above); what was missing was
|
|
351
361
|
// a FILE, and its fix said to copy it from the framework repository, which `packages/cli`'s
|
|
@@ -356,15 +366,12 @@ export const deployCommand: CliCommand = {
|
|
|
356
366
|
const request = await readDeployRequest(ctx, method, root);
|
|
357
367
|
// Read before the plan, because the plan IS the rollout: which compose roles can start their
|
|
358
368
|
// new container first is a fact of the app's compose file, and a dry run reports it too.
|
|
369
|
+
const composeText =
|
|
370
|
+
request.method === 'compose' ? await readComposeFile(composeFileOf(root)) : undefined;
|
|
359
371
|
const plan =
|
|
360
372
|
request.method === 'helm'
|
|
361
373
|
? planDeploy(image, 'helm', root, request)
|
|
362
|
-
: planDeploy(
|
|
363
|
-
image,
|
|
364
|
-
'compose',
|
|
365
|
-
root,
|
|
366
|
-
composeStrategies(await readComposeFile(composeFileOf(root))),
|
|
367
|
-
);
|
|
374
|
+
: planDeploy(image, 'compose', root, composeStrategies(composeText));
|
|
368
375
|
// What the helm flags resolved to — the release above all, which is read off `app.config.ts`
|
|
369
376
|
// and so is not something the operator typed. Absent on compose, which has no release.
|
|
370
377
|
const target: { readonly [key: string]: JsonValue } =
|
|
@@ -405,11 +412,28 @@ export const deployCommand: CliCommand = {
|
|
|
405
412
|
),
|
|
406
413
|
};
|
|
407
414
|
}
|
|
415
|
+
// Before the first step: a compose step without its env file fails as `exited 1` and says no
|
|
416
|
+
// more, so the variables it lacks are named here, and nothing runs.
|
|
417
|
+
const preflight =
|
|
418
|
+
request.method === 'compose'
|
|
419
|
+
? await composeEnvPreflight(root, composeText, ctx.env, await loadEnvSchema(root))
|
|
420
|
+
: undefined;
|
|
421
|
+
if (preflight !== undefined) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
command: 'deploy',
|
|
425
|
+
summary: msg('cli.deploy.plan', { images: 1, roles }),
|
|
426
|
+
findings: [preflight],
|
|
427
|
+
data: planJson,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
408
430
|
// helm prints its release record (`--output json`), so the report carries helm's own verdict
|
|
409
431
|
// and revision rather than an exit code standing in for one. Compose has no such record.
|
|
410
432
|
let rollout: { readonly [key: string]: JsonValue } = {};
|
|
411
433
|
for (const step of plan.steps) {
|
|
412
434
|
const options = { cwd: root, env: plan.env };
|
|
435
|
+
// A one-shot's container output is its step's own; a serving role's is read off its log.
|
|
436
|
+
let output = '';
|
|
413
437
|
// A start-first step is a sequence — list, scale up, wait, stop the old — and reports the
|
|
414
438
|
// command of the sub-step that failed, so the fix line below is still the one to rerun.
|
|
415
439
|
const outcome =
|
|
@@ -428,11 +452,20 @@ export const deployCommand: CliCommand = {
|
|
|
428
452
|
: await ctx.runner(step.command, options).then((result) => {
|
|
429
453
|
if (request.method === 'helm')
|
|
430
454
|
rollout = { rollout: { ...readRollout(request, result.stdout) } };
|
|
455
|
+
if (!result.ok) output = lastLine(result);
|
|
431
456
|
return result.ok
|
|
432
457
|
? { ok: true as const }
|
|
433
458
|
: { ok: false as const, code: result.code, command: step.command };
|
|
434
459
|
});
|
|
435
460
|
if (!outcome.ok) {
|
|
461
|
+
if (output === '' && request.method === 'compose' && !ONE_SHOT_ROLES.includes(step.role)) {
|
|
462
|
+
output = await roleLastLogLine({
|
|
463
|
+
base: composeBase(root),
|
|
464
|
+
role: step.role,
|
|
465
|
+
runner: ctx.runner,
|
|
466
|
+
options,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
436
469
|
return {
|
|
437
470
|
ok: false,
|
|
438
471
|
command: 'deploy',
|
|
@@ -440,7 +473,7 @@ export const deployCommand: CliCommand = {
|
|
|
440
473
|
findings: [
|
|
441
474
|
{
|
|
442
475
|
code: 'X_DEPLOY_FAILED',
|
|
443
|
-
cause: `role "${step.role}" step exited ${outcome.code}${cleanupNote(outcome)}`,
|
|
476
|
+
cause: `role "${step.role}" step exited ${outcome.code}${output === '' ? '' : `: ${output}`}${cleanupNote(outcome)}`,
|
|
444
477
|
fix: `${stepLine(plan.env, outcome.command)} # run it directly to see the full output`,
|
|
445
478
|
docs: ERROR_DOCS_URL,
|
|
446
479
|
},
|
package/src/cmd-docs.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
8
|
import type { DocEntry, DocHit } from '@ultimat3/manifest';
|
|
9
|
-
import { nearestTopics,
|
|
9
|
+
import { nearestTopics, searchDocs } from '@ultimat3/manifest';
|
|
10
10
|
import { DEFAULT_LIMIT, docsSpec } from './cmd-docs-spec';
|
|
11
11
|
import type { CliCommand, CommandContext } from './command';
|
|
12
|
+
import { parseWikiTopic, sectionResult } from './docs-section';
|
|
13
|
+
import { loadDocs, wikiSource } from './docs-sources';
|
|
12
14
|
import { MissingPositionalError } from './errors';
|
|
13
15
|
import { frameworkScopeDir } from './framework-scope';
|
|
14
16
|
import { parseLimitFlag } from './jobs-report';
|
|
@@ -38,6 +40,19 @@ const asJson = (hit: DocHit): JsonValue => ({
|
|
|
38
40
|
score: hit.score,
|
|
39
41
|
});
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The tarball ships `wiki/` (its `prepack`), so an install without it is broken in the same way as
|
|
45
|
+
* one whose CLI cannot see `@ultimat3/core`: the answer is still given from the packages, and the
|
|
46
|
+
* finding says what is missing rather than leaving the wiki's absence to look like no answer.
|
|
47
|
+
*/
|
|
48
|
+
const noWikiFinding = (): Finding => ({
|
|
49
|
+
code: 'X_CLI_UNEXPECTED',
|
|
50
|
+
cause: '@ultimat3/cli ships no wiki/ directory, so no wiki page could be searched',
|
|
51
|
+
fix: 'bun install && x doctor --json',
|
|
52
|
+
docs: ERROR_DOCS_URL,
|
|
53
|
+
at: import.meta.dir,
|
|
54
|
+
});
|
|
55
|
+
|
|
41
56
|
/** The exact path an agent opens next — package-relative is ambiguous across 29 packages. */
|
|
42
57
|
const locate = (entry: DocEntry): string => `${entry.package}/${entry.source}`;
|
|
43
58
|
|
|
@@ -96,7 +111,11 @@ function codeResult(code: string, query: string): CommandResult {
|
|
|
96
111
|
* related at all, the honest fallback is the list of packages actually installed — the universe
|
|
97
112
|
* the question could have been about — rather than five topics picked for sharing letters.
|
|
98
113
|
*/
|
|
99
|
-
function missResult(
|
|
114
|
+
function missResult(
|
|
115
|
+
query: string,
|
|
116
|
+
entries: readonly DocEntry[],
|
|
117
|
+
findings: readonly Finding[],
|
|
118
|
+
): CommandResult {
|
|
100
119
|
const suggestions = nearestTopics(entries, query);
|
|
101
120
|
const packages = [...new Set(entries.map((entry) => entry.package))].sort();
|
|
102
121
|
const next =
|
|
@@ -108,6 +127,7 @@ function missResult(query: string, entries: readonly DocEntry[]): CommandResult
|
|
|
108
127
|
command: 'docs',
|
|
109
128
|
summary: msg('cli.docs.none', { query }),
|
|
110
129
|
lines: [...next, ...alsoTry()],
|
|
130
|
+
...(findings.length > 0 ? { findings } : {}),
|
|
111
131
|
data: { matches: [], suggestions: [...suggestions], packages, query },
|
|
112
132
|
};
|
|
113
133
|
}
|
|
@@ -131,6 +151,14 @@ export const docsCommand: CliCommand = {
|
|
|
131
151
|
const code = CODE_QUERY.exec(query)?.[0];
|
|
132
152
|
if (code !== undefined) return codeResult(code, query);
|
|
133
153
|
|
|
154
|
+
// A wiki topic is answered with its whole section, before any package is scanned.
|
|
155
|
+
const topic = parseWikiTopic(query);
|
|
156
|
+
if (topic !== undefined) {
|
|
157
|
+
const wiki = await wikiSource();
|
|
158
|
+
const section = wiki === undefined ? undefined : await sectionResult(wiki, topic, query);
|
|
159
|
+
if (section !== undefined) return section;
|
|
160
|
+
}
|
|
161
|
+
|
|
134
162
|
const scope = frameworkScopeDir();
|
|
135
163
|
if (scope === undefined) {
|
|
136
164
|
const finding = unresolvedFinding();
|
|
@@ -143,20 +171,22 @@ export const docsCommand: CliCommand = {
|
|
|
143
171
|
};
|
|
144
172
|
}
|
|
145
173
|
|
|
146
|
-
const entries = await
|
|
174
|
+
const { entries, wiki } = await loadDocs(scope);
|
|
175
|
+
const findings = wiki === undefined ? [noWikiFinding()] : [];
|
|
147
176
|
// `x jobs list --limit`'s reader, and its `command` parameter exists for exactly this second
|
|
148
177
|
// caller. A local `Number.parseInt` accepted `--limit 1e9` as 1 and answered with one match,
|
|
149
178
|
// and fell silently through to the default for `abc`, `0` and `-3` — a bound other than the one
|
|
150
179
|
// typed, from the same binary that refuses all four one command over.
|
|
151
180
|
const limit = parseLimitFlag(flagString(ctx.args, 'limit'), 'docs') ?? DEFAULT_LIMIT;
|
|
152
181
|
const hits = searchDocs(entries, query, limit);
|
|
153
|
-
if (hits.length === 0) return missResult(query, entries);
|
|
182
|
+
if (hits.length === 0) return missResult(query, entries, findings);
|
|
154
183
|
|
|
155
184
|
return {
|
|
156
185
|
ok: true,
|
|
157
186
|
command: 'docs',
|
|
158
187
|
summary: msg('cli.docs.found', { count: hits.length, query }),
|
|
159
188
|
lines: [...humanLines(hits)],
|
|
189
|
+
...(findings.length > 0 ? { findings } : {}),
|
|
160
190
|
data: { matches: hits.map(asJson), suggestions: [], query },
|
|
161
191
|
};
|
|
162
192
|
},
|