@ultimat3/cli 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// `x g sign-in --password | --oidc <provider>`: the shape's files written through the one writer
|
|
2
|
+
// (`writeFiles` — a second run conflicts and writes nothing), then the edits outside them: the
|
|
3
|
+
// actions listed in the API index, `app.config.ts`'s `signInPath` and env keys, and — for OIDC —
|
|
4
|
+
// the authenticator swap that drops `x new`'s development viewer, only over `x new`'s own bytes.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL, stringField } from '@ultimat3/core';
|
|
7
|
+
import { indexBindingFindings } from './api-registration';
|
|
8
|
+
import { generateSpec } from './cmd-generate-spec';
|
|
9
|
+
import type { CommandContext } from './command';
|
|
10
|
+
import { invocationOf } from './command';
|
|
11
|
+
import { EXERCISE_FILE, exerciseWithEntries, signInExerciseEntries } from './exercise-registration';
|
|
12
|
+
import { localiseCatalogs, namedOrAppLocales } from './generate-catalog-locales';
|
|
13
|
+
import { appDisk, plannedDisk } from './generate-disk';
|
|
14
|
+
import { generate } from './generate-files';
|
|
15
|
+
import { followUpEdits } from './generate-follow-ups';
|
|
16
|
+
import { formatWritten } from './generate-format-written';
|
|
17
|
+
import { APP_CONFIG, type SignInEnvKey, signInConfigEdit } from './generate-sign-in-config';
|
|
18
|
+
import { appManifestIfAny, existingArtifacts } from './generate-sign-in-manifest';
|
|
19
|
+
import { readSignInMode, type SignInMode, signInLabel } from './generate-sign-in-mode';
|
|
20
|
+
import { planWrites, writeFiles } from './generate-write';
|
|
21
|
+
import { resolveDbModule } from './handle-registration';
|
|
22
|
+
import { resolveCatalogModule } from './i18n-audit';
|
|
23
|
+
import { reproducedFlags } from './invocation-flags';
|
|
24
|
+
import { msg } from './messages';
|
|
25
|
+
import type { CommandResult, Finding } from './output';
|
|
26
|
+
import { flagBool, flagList } from './parse';
|
|
27
|
+
import { names, resolveLocales } from './templates';
|
|
28
|
+
import { authFiles } from './templates/scaffold-auth';
|
|
29
|
+
import { DEV_ACTOR_FILES, signedInAuthenticatorFiles } from './templates/sign-in-authenticator';
|
|
30
|
+
import { oidcEnvPrefix } from './templates/sign-in-oidc';
|
|
31
|
+
import { SIGN_IN_PATH } from './templates/sign-in-shared';
|
|
32
|
+
|
|
33
|
+
/** The env keys an OIDC sign-in adds to `envSchema`; `SESSION_SECRET` and `APP_URL` exist already. */
|
|
34
|
+
export const oidcEnvKeys = (provider: string): readonly SignInEnvKey[] => {
|
|
35
|
+
const prefix = oidcEnvPrefix(provider);
|
|
36
|
+
return [
|
|
37
|
+
{ key: `${prefix}_ISSUER`, type: 'url', description: `The ${provider} OpenID Connect issuer.` },
|
|
38
|
+
{ key: `${prefix}_CLIENT_ID`, description: `The client id ${provider} issued this app.` },
|
|
39
|
+
{
|
|
40
|
+
key: `${prefix}_CLIENT_SECRET`,
|
|
41
|
+
secret: true,
|
|
42
|
+
description: `The client secret ${provider} issued this app.`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The app's slug, as `x new` named it: the root `package.json`'s `name`. A manifest that does not
|
|
49
|
+
* parse names nothing, which is the conservative answer: no scaffold to compare against, so every
|
|
50
|
+
* file on disk reads as edited and the swap becomes a finding rather than a throw.
|
|
51
|
+
*/
|
|
52
|
+
export async function appName(root: string): Promise<string | undefined> {
|
|
53
|
+
const text = await appDisk(root).read('package.json');
|
|
54
|
+
if (text === undefined) return undefined;
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return stringField(parsed, 'name');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AuthenticatorSwap {
|
|
65
|
+
/** The files written over `x new`'s, and the ones deleted, when every one was still `x new`'s. */
|
|
66
|
+
readonly replace: readonly { readonly path: string; readonly contents: string }[];
|
|
67
|
+
readonly remove: readonly string[];
|
|
68
|
+
readonly findings: readonly Finding[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether `x new`'s authenticator and development viewer are on disk exactly as it wrote them. */
|
|
72
|
+
export async function planAuthenticatorSwap(root: string): Promise<AuthenticatorSwap> {
|
|
73
|
+
const name = await appName(root);
|
|
74
|
+
const scaffold = new Map(
|
|
75
|
+
name === undefined ? [] : authFiles(names(name)).map((file) => [file.path, file.contents]),
|
|
76
|
+
);
|
|
77
|
+
const swapped = signedInAuthenticatorFiles();
|
|
78
|
+
const paths = [...swapped.map((file) => file.path), ...DEV_ACTOR_FILES];
|
|
79
|
+
const disk = appDisk(root);
|
|
80
|
+
const edited: string[] = [];
|
|
81
|
+
for (const path of paths) {
|
|
82
|
+
const now = await disk.read(path);
|
|
83
|
+
if (now !== undefined && now !== scaffold.get(path)) edited.push(path);
|
|
84
|
+
}
|
|
85
|
+
if (edited.length > 0) {
|
|
86
|
+
return {
|
|
87
|
+
replace: [],
|
|
88
|
+
remove: [],
|
|
89
|
+
findings: [
|
|
90
|
+
{
|
|
91
|
+
code: 'X_GENERATE_CONFLICT',
|
|
92
|
+
cause: `${edited.join(', ')} ${edited.length === 1 ? 'is' : 'are'} no longer what x new wrote, so the development viewer was left in place rather than overwritten`,
|
|
93
|
+
fix: 'rm apps/web/app/auth/dev-actor.ts apps/web/app/auth/dev-actor.test.ts # then answer null in apps/web/app/auth/authenticator.ts where it called devAuthenticate(request)',
|
|
94
|
+
docs: ERROR_DOCS_URL,
|
|
95
|
+
at: edited[0] ?? '',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
replace: swapped.map((file) => ({ path: file.path, contents: String(file.contents) })),
|
|
102
|
+
remove: [...DEV_ACTOR_FILES],
|
|
103
|
+
findings: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runSignIn(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
108
|
+
const mode: SignInMode = readSignInMode(ctx.args);
|
|
109
|
+
const label = signInLabel(mode);
|
|
110
|
+
const catalogModule = await resolveCatalogModule(root);
|
|
111
|
+
const dbModule = await resolveDbModule(root);
|
|
112
|
+
// Every catalog the app keeps, as every other generator seeds them: `en` alone renders ⟦key⟧
|
|
113
|
+
// for a reader of any other locale the app ships.
|
|
114
|
+
const locales = resolveLocales(await namedOrAppLocales(root, flagList(ctx.args, 'locales')));
|
|
115
|
+
const files = await localiseCatalogs(
|
|
116
|
+
root,
|
|
117
|
+
generate({
|
|
118
|
+
kind: 'sign-in',
|
|
119
|
+
name: label,
|
|
120
|
+
signIn: mode,
|
|
121
|
+
locales,
|
|
122
|
+
...(catalogModule === undefined ? {} : { catalogModule }),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
const flags = reproducedFlags(generateSpec, ctx.args);
|
|
126
|
+
const invocation = [invocationOf(ctx, 'g'), 'sign-in', ...flags].join(' ');
|
|
127
|
+
const force = flagBool(ctx.args, 'force');
|
|
128
|
+
const swap =
|
|
129
|
+
mode.kind === 'oidc'
|
|
130
|
+
? await planAuthenticatorSwap(root)
|
|
131
|
+
: { replace: [], remove: [], findings: [] };
|
|
132
|
+
const configSource = await appDisk(root).read(APP_CONFIG);
|
|
133
|
+
const config = signInConfigEdit(
|
|
134
|
+
configSource ?? '',
|
|
135
|
+
SIGN_IN_PATH,
|
|
136
|
+
mode.kind === 'oidc' ? oidcEnvKeys(mode.provider) : [],
|
|
137
|
+
);
|
|
138
|
+
// An app with no `app.config.ts` has no `signInPath` to declare: nothing to edit, nothing owed.
|
|
139
|
+
const configFindings = configSource === undefined ? [] : config.findings;
|
|
140
|
+
const configEdited = configSource !== undefined && config.contents !== configSource;
|
|
141
|
+
// The three actions are listed in the API index under their file names, so an app already
|
|
142
|
+
// binding `createSession` there is refused before anything lands — `cmd-generate.ts`'s preflight.
|
|
143
|
+
const bindings = await indexBindingFindings(
|
|
144
|
+
root,
|
|
145
|
+
files.map((file) => file.path),
|
|
146
|
+
`${invocation} # after renaming what apps/web/api/index.ts already binds under that name`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// The pass's half: what the written actions cannot run as generated, turned off with a reason.
|
|
150
|
+
const exerciseSource = await appDisk(root).read(EXERCISE_FILE);
|
|
151
|
+
const exerciseContents = exerciseWithEntries(
|
|
152
|
+
exerciseSource,
|
|
153
|
+
signInExerciseEntries(files.map((file) => file.path)),
|
|
154
|
+
);
|
|
155
|
+
const exerciseEdited = exerciseContents !== exerciseSource;
|
|
156
|
+
|
|
157
|
+
if (flagBool(ctx.args, 'dry-run')) {
|
|
158
|
+
const plan = await planWrites(root, files, force, invocation, label);
|
|
159
|
+
const planned = new Map(
|
|
160
|
+
files.flatMap((file) =>
|
|
161
|
+
plan.written.includes(file.path) && typeof file.contents === 'string'
|
|
162
|
+
? [[file.path, file.contents] as const]
|
|
163
|
+
: [],
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
const refused = [...plan.conflicts, ...bindings];
|
|
167
|
+
const follow =
|
|
168
|
+
refused.length > 0
|
|
169
|
+
? { edited: [], findings: [] }
|
|
170
|
+
: await followUpEdits(root, plan.written, dbModule, plannedDisk(root, planned));
|
|
171
|
+
const findings = [...refused, ...follow.findings, ...swap.findings, ...configFindings];
|
|
172
|
+
const touched =
|
|
173
|
+
refused.length > 0
|
|
174
|
+
? []
|
|
175
|
+
: [
|
|
176
|
+
...new Set([
|
|
177
|
+
...plan.written,
|
|
178
|
+
...follow.edited,
|
|
179
|
+
...swap.replace.map((file) => file.path),
|
|
180
|
+
...(configEdited ? [APP_CONFIG] : []),
|
|
181
|
+
...(exerciseEdited ? [EXERCISE_FILE] : []),
|
|
182
|
+
]),
|
|
183
|
+
];
|
|
184
|
+
const removed = refused.length > 0 ? [] : [...swap.remove];
|
|
185
|
+
return {
|
|
186
|
+
ok: findings.length === 0,
|
|
187
|
+
command: 'g',
|
|
188
|
+
summary: msg('cli.generate.planned', { count: touched.length, kind: 'sign-in', name: label }),
|
|
189
|
+
data: {
|
|
190
|
+
files: touched,
|
|
191
|
+
removed,
|
|
192
|
+
// What a real run re-projects after it writes, named as `x g`'s other dry runs name them.
|
|
193
|
+
...(touched.length === 0 ? {} : { refreshes: [...(await existingArtifacts(root))] }),
|
|
194
|
+
dryRun: true,
|
|
195
|
+
},
|
|
196
|
+
lines: fileLines(touched, removed),
|
|
197
|
+
findings,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const report =
|
|
202
|
+
bindings.length > 0
|
|
203
|
+
? { written: [], conflicts: bindings }
|
|
204
|
+
: await writeFiles(root, files, force, invocation, label);
|
|
205
|
+
if (report.conflicts.length > 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
command: 'g',
|
|
209
|
+
summary: msg('cli.generate.wrote', { count: 0, kind: 'sign-in', name: label }),
|
|
210
|
+
data: { files: [] },
|
|
211
|
+
findings: report.conflicts,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const disk = appDisk(root);
|
|
215
|
+
for (const file of swap.replace) await disk.write(file.path, file.contents);
|
|
216
|
+
for (const path of swap.remove) {
|
|
217
|
+
const file = Bun.file(`${root}/${path}`);
|
|
218
|
+
if (await file.exists()) await file.delete();
|
|
219
|
+
}
|
|
220
|
+
if (configEdited) await disk.write(APP_CONFIG, config.contents);
|
|
221
|
+
if (exerciseEdited) await disk.write(EXERCISE_FILE, exerciseContents);
|
|
222
|
+
const follow = await followUpEdits(root, report.written, dbModule, disk);
|
|
223
|
+
await formatWritten(root, report.written, ctx.runner);
|
|
224
|
+
const refreshed = await appManifestIfAny(root);
|
|
225
|
+
const written = [
|
|
226
|
+
...new Set([
|
|
227
|
+
...report.written,
|
|
228
|
+
...swap.replace.map((file) => file.path),
|
|
229
|
+
...(configEdited ? [APP_CONFIG] : []),
|
|
230
|
+
...(exerciseEdited ? [EXERCISE_FILE] : []),
|
|
231
|
+
...follow.edited,
|
|
232
|
+
...refreshed.written,
|
|
233
|
+
]),
|
|
234
|
+
];
|
|
235
|
+
const findings = [...follow.findings, ...swap.findings, ...configFindings, ...refreshed.findings];
|
|
236
|
+
return {
|
|
237
|
+
ok: findings.length === 0,
|
|
238
|
+
command: 'g',
|
|
239
|
+
summary: msg('cli.generate.wrote', { count: written.length, kind: 'sign-in', name: label }),
|
|
240
|
+
data: { files: written, removed: [...swap.remove] },
|
|
241
|
+
lines: fileLines(written, swap.remove),
|
|
242
|
+
findings,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One `+` line per file written, then one `-` per file deleted: a deletion is never "added". */
|
|
247
|
+
const fileLines = (added: readonly string[], removed: readonly string[]): readonly string[] => [
|
|
248
|
+
...added.map((path) => msg('cli.file.added', { path })),
|
|
249
|
+
...removed.map((path) => msg('cli.file.removed', { path })),
|
|
250
|
+
];
|
package/src/generate-write.ts
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
// which decides WHICH files a generator emits — this file decides what happens to them, and `x new`
|
|
4
4
|
// and the scaffold fixture assemble their own lists and land them through exactly these rules.
|
|
5
5
|
|
|
6
|
-
// `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed
|
|
7
|
-
// app root, and `node:path` is the only API that resolves one. `node:fs` for the exists
|
|
6
|
+
// why: `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed
|
|
7
|
+
// inside the app root, and `node:path` is the only API that resolves one. `node:fs` for the exists
|
|
8
|
+
// check.
|
|
8
9
|
import { existsSync } from 'node:fs';
|
|
9
|
-
import { resolve, sep } from 'node:path';
|
|
10
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
10
11
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
12
|
+
import { commentSafe } from './comment-safe';
|
|
11
13
|
import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
|
|
12
14
|
import { mergeJsonDeep, mergeJsonInPlace, sortJsonDeep } from './json-merge';
|
|
13
15
|
import type { Finding } from './output';
|
|
16
|
+
import { quoteArg } from './shell-quote';
|
|
14
17
|
import type { GeneratedFile } from './templates';
|
|
15
18
|
|
|
16
19
|
/** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
|
|
@@ -164,11 +167,40 @@ type WritePlan =
|
|
|
164
167
|
| { readonly kind: 'skip' }
|
|
165
168
|
| { readonly kind: 'conflict'; readonly finding: Finding };
|
|
166
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The author's rules live in a slice's `entity.ts` and `policy.ts`: `--force` as the first thing
|
|
172
|
+
* offered deleted them, when an existing slice almost always means the next primitive belongs in it.
|
|
173
|
+
*/
|
|
174
|
+
const conflictFix = (
|
|
175
|
+
path: string,
|
|
176
|
+
absolute: string,
|
|
177
|
+
invocation: string,
|
|
178
|
+
name: string | undefined,
|
|
179
|
+
): string => {
|
|
180
|
+
const [slice, module] = path.split('/').slice(-2);
|
|
181
|
+
// The caller's own name, never `<name>`: a placeholder pasted into a shell is a redirect. With no
|
|
182
|
+
// name to hand back, the one line that needs none.
|
|
183
|
+
if (
|
|
184
|
+
slice === undefined ||
|
|
185
|
+
name === undefined ||
|
|
186
|
+
(module !== 'entity.ts' && module !== 'policy.ts')
|
|
187
|
+
)
|
|
188
|
+
return `${invocation} --force # overwrites ${path}, or pass a different name`;
|
|
189
|
+
const into = `--feature ${quoteArg(slice)}`;
|
|
190
|
+
const named = quoteArg(name);
|
|
191
|
+
// A read needs the slice's table (X_FEATURE_REQUIRED), so it is offered only beside one.
|
|
192
|
+
const read = existsSync(join(dirname(absolute), 'entity.ts'))
|
|
193
|
+
? `or x g query ${named} ${into}`
|
|
194
|
+
: `a read needs x g entity ${quoteArg(slice)} first`;
|
|
195
|
+
return `x g action ${named} ${into} # ${read}: ${commentSafe(path)} is the author's`;
|
|
196
|
+
};
|
|
197
|
+
|
|
167
198
|
function planFile(
|
|
168
199
|
file: GeneratedFile,
|
|
169
200
|
absolute: string,
|
|
170
201
|
force: boolean,
|
|
171
202
|
invocation: string,
|
|
203
|
+
name: string | undefined,
|
|
172
204
|
): WritePlan {
|
|
173
205
|
// A foundation file belongs to the slice, not to the generator that needs it: several generators
|
|
174
206
|
// emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
|
|
@@ -189,7 +221,7 @@ function planFile(
|
|
|
189
221
|
// The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
|
|
190
222
|
// when run, and a `fix:` is copied and pasted verbatim. Same construction as
|
|
191
223
|
// `generate-kinds.ts`'s `assertSurfaceSupported`.
|
|
192
|
-
fix:
|
|
224
|
+
fix: conflictFix(file.path, absolute, invocation, name),
|
|
193
225
|
docs: ERROR_DOCS_URL,
|
|
194
226
|
at: file.path,
|
|
195
227
|
},
|
|
@@ -204,6 +236,7 @@ async function planAll(
|
|
|
204
236
|
files: readonly GeneratedFile[],
|
|
205
237
|
force: boolean,
|
|
206
238
|
invocation: string,
|
|
239
|
+
name: string | undefined,
|
|
207
240
|
): Promise<readonly WritePlan[]> {
|
|
208
241
|
const plans: WritePlan[] = [];
|
|
209
242
|
for (const file of files) {
|
|
@@ -211,7 +244,7 @@ async function planAll(
|
|
|
211
244
|
plans.push(
|
|
212
245
|
file.merge === 'json'
|
|
213
246
|
? await planJsonMerge(file, absolute)
|
|
214
|
-
: planFile(file, absolute, force, invocation),
|
|
247
|
+
: planFile(file, absolute, force, invocation, name),
|
|
215
248
|
);
|
|
216
249
|
}
|
|
217
250
|
return plans;
|
|
@@ -230,8 +263,10 @@ export async function planWrites(
|
|
|
230
263
|
files: readonly GeneratedFile[],
|
|
231
264
|
force: boolean,
|
|
232
265
|
invocation = 'x g <kind> <name>',
|
|
266
|
+
/** The name the caller gave, handed back in a conflict's fix; absent, that fix names none. */
|
|
267
|
+
name?: string,
|
|
233
268
|
): Promise<WriteReport> {
|
|
234
|
-
const plans = await planAll(root, files, force, invocation);
|
|
269
|
+
const plans = await planAll(root, files, force, invocation, name);
|
|
235
270
|
const conflicts = conflictsOf(plans);
|
|
236
271
|
if (conflicts.length > 0) return { written: [], conflicts };
|
|
237
272
|
return {
|
|
@@ -259,8 +294,10 @@ export async function writeFiles(
|
|
|
259
294
|
* shape, not a runnable line, and every generator path supplies the real one.
|
|
260
295
|
*/
|
|
261
296
|
invocation = 'x g <kind> <name>',
|
|
297
|
+
/** `planWrites`' `name`. */
|
|
298
|
+
name?: string,
|
|
262
299
|
): Promise<WriteReport> {
|
|
263
|
-
const plans = await planAll(root, files, force, invocation);
|
|
300
|
+
const plans = await planAll(root, files, force, invocation, name);
|
|
264
301
|
const conflicts = conflictsOf(plans);
|
|
265
302
|
if (conflicts.length > 0) return { written: [], conflicts };
|
|
266
303
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// An app's own websocket upgrade, made a build error: `deviceSocket()` is the one way an app serves
|
|
2
|
+
// a long-lived socket, and a hand-rolled `server.upgrade(` or `Bun.serve({ websocket })` skips the
|
|
3
|
+
// token check before the handshake, the per-actor cap, the beat, the frame limit and the drain.
|
|
4
|
+
// Pure over the `boundaries` step's one read; masked, so a comment or a string never matches.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
|
|
7
|
+
import type { SourceFile } from './app-boundaries';
|
|
8
|
+
import { commentSafe } from './comment-safe';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
|
|
11
|
+
/** `Bun.serve(` — its argument is read for a `websocket` key. */
|
|
12
|
+
const SERVE = /\bBun\s*\.\s*serve\s*\(/g;
|
|
13
|
+
/** A `websocket` KEY in an object literal: `websocket: x`, or the shorthand `{ websocket }`. */
|
|
14
|
+
const WEBSOCKET_KEY = /\bwebsocket\s*[:,}]/;
|
|
15
|
+
/**
|
|
16
|
+
* Bun's upgrade, by the names its two halves carry: the server (`server.upgrade(`, `srv.upgrade(`)
|
|
17
|
+
* or the request it takes (`x.upgrade(request`). `plans.upgrade(org)` is somebody's business.
|
|
18
|
+
*/
|
|
19
|
+
const UPGRADE = /\b(?:server|srv)\s*\.\s*upgrade\s*\(|\.\s*upgrade\s*\(\s*(?:req|request)\b/g;
|
|
20
|
+
/** An inline handler set — `websocket: {` — whatever server it is handed to. */
|
|
21
|
+
const HANDLERS = /\bwebsocket\s*:\s*\{/g;
|
|
22
|
+
|
|
23
|
+
/** The index closing the `(` at `open`, or the end of the text. Exact over `maskLiterals` output. */
|
|
24
|
+
function closeOf(masked: string, open: number): number {
|
|
25
|
+
let depth = 0;
|
|
26
|
+
for (let index = open; index < masked.length; index += 1) {
|
|
27
|
+
if (masked[index] === '(') depth += 1;
|
|
28
|
+
else if (masked[index] === ')') {
|
|
29
|
+
depth -= 1;
|
|
30
|
+
if (depth === 0) return index;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return masked.length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Where each hand-rolled upgrade starts, as an offset into `masked`. */
|
|
37
|
+
function handRolledAt(masked: string): readonly number[] {
|
|
38
|
+
const at: number[] = [];
|
|
39
|
+
for (const match of masked.matchAll(SERVE)) {
|
|
40
|
+
const open = match.index + match[0].length - 1;
|
|
41
|
+
if (WEBSOCKET_KEY.test(masked.slice(open, closeOf(masked, open)))) at.push(match.index);
|
|
42
|
+
}
|
|
43
|
+
for (const pattern of [UPGRADE, HANDLERS]) {
|
|
44
|
+
for (const match of masked.matchAll(pattern)) at.push(match.index);
|
|
45
|
+
}
|
|
46
|
+
return at;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 1-based line of `offset`; `maskLiterals` keeps every newline, so the count is the source's. */
|
|
50
|
+
const lineOf = (masked: string, offset: number): number =>
|
|
51
|
+
masked.slice(0, offset).split('\n').length;
|
|
52
|
+
|
|
53
|
+
/** One `X_SOCKET_HAND_ROLLED` per app file that serves a socket itself, at its first line. */
|
|
54
|
+
export function handRolledSocketFindings(files: readonly SourceFile[]): readonly Finding[] {
|
|
55
|
+
return files.flatMap(({ path, source }): Finding[] => {
|
|
56
|
+
if (!source.includes('upgrade') && !source.includes('websocket')) return [];
|
|
57
|
+
const masked = maskLiterals(source);
|
|
58
|
+
const first = Math.min(...handRolledAt(masked));
|
|
59
|
+
if (!Number.isFinite(first)) return [];
|
|
60
|
+
const line = lineOf(masked, first);
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
code: 'X_SOCKET_HAND_ROLLED',
|
|
64
|
+
cause: `${path}:${String(line)} upgrades a websocket itself, so the token check before the handshake, the per-actor cap, the heartbeat, the frame limit and the drain are the app's to remember — and the boot never serves it on the web listener`,
|
|
65
|
+
fix: `x verify --only boundaries --json # in ${commentSafe(path)}, delete the upgrade and declare the socket: export const link = deviceSocket({ path, resolveToken, onFrame }) from @ultimat3/realtime/server`,
|
|
66
|
+
docs: ERROR_DOCS_URL,
|
|
67
|
+
at: path,
|
|
68
|
+
meta: { line },
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Every shape in an app's source whose literal is a catalog key. Here and not in `@ultimat3/i18n`:
|
|
2
|
+
// tier 1 must not name `blocks` or `defineMail`, and this package already sits over both. Passed by
|
|
3
|
+
// `scanSource` only, so the framework's own catalog scan (`scripts/lib/i18n-scan.ts`) keeps its
|
|
4
|
+
// callees: `mail.*` lives in `mail/src/catalog.ts`, not in `catalogs/en.json`.
|
|
5
|
+
|
|
6
|
+
import type { ExtractOptions } from '@ultimat3/i18n';
|
|
7
|
+
|
|
8
|
+
export const APP_KEY_SHAPES = {
|
|
9
|
+
callees: [
|
|
10
|
+
't',
|
|
11
|
+
'$t',
|
|
12
|
+
'translate',
|
|
13
|
+
// The key is the first argument of each (`mail/src/blocks.ts`). `divider` takes none.
|
|
14
|
+
// `i18n-app-key-shapes.test.ts` reads `blocks` itself, so a new block reds until listed here.
|
|
15
|
+
'blocks.heading',
|
|
16
|
+
'blocks.paragraph',
|
|
17
|
+
'blocks.button',
|
|
18
|
+
'blocks.callout',
|
|
19
|
+
'blocks.detail',
|
|
20
|
+
],
|
|
21
|
+
properties: [
|
|
22
|
+
// A push payload's and an admin screen's keys. Unambiguous anywhere: nothing else is `*Key`.
|
|
23
|
+
{ name: 'titleKey' },
|
|
24
|
+
{ name: 'bodyKey' },
|
|
25
|
+
// `subject:` alone is also a column, a form field and a schema member.
|
|
26
|
+
// The bare name, not `defineMail(`: `defineMail<{ total: string }>({` is the usual spelling.
|
|
27
|
+
{ name: 'subject', whenFileContains: 'defineMail' },
|
|
28
|
+
],
|
|
29
|
+
} as const satisfies ExtractOptions;
|
package/src/i18n-audit.ts
CHANGED
|
@@ -19,13 +19,15 @@ import {
|
|
|
19
19
|
missingFrom,
|
|
20
20
|
nestCatalog,
|
|
21
21
|
} from '@ultimat3/i18n';
|
|
22
|
+
import { APP_KEY_SHAPES } from './i18n-app-key-shapes';
|
|
22
23
|
import { isLoudMiss, loudMiss } from './i18n-registration';
|
|
23
24
|
import { posixRelative } from './posix-path';
|
|
24
25
|
import { eachSourceFile, isTest } from './source-files';
|
|
25
26
|
import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Every
|
|
29
|
+
* Every key the app's own source names: a `t()` call, a mail block or subject, a push payload
|
|
30
|
+
* (`APP_KEY_SHAPES`). `source-files.ts` (`eachSourceFile`) is the one
|
|
29
31
|
* glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
|
|
30
32
|
* here would mean this command and `x verify` disagree on what "the app's source" is. Test files
|
|
31
33
|
* are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
|
|
@@ -40,7 +42,10 @@ export async function scanSource(root: string): Promise<Extraction> {
|
|
|
40
42
|
for await (const file of eachSourceFile(root)) {
|
|
41
43
|
if (!isTest(file) && !isGuard(file)) files.push(file);
|
|
42
44
|
}
|
|
43
|
-
const extraction = await extractFromFiles(
|
|
45
|
+
const extraction = await extractFromFiles(
|
|
46
|
+
files.map((file) => join(root, file)),
|
|
47
|
+
APP_KEY_SHAPES,
|
|
48
|
+
);
|
|
44
49
|
const toRelative = (file: string): string => posixRelative(root, file);
|
|
45
50
|
return {
|
|
46
51
|
usages: extraction.usages.map((usage) => ({ ...usage, file: toRelative(usage.file) })),
|
|
@@ -115,7 +120,9 @@ export async function auditApp(root: string): Promise<AuditFacts> {
|
|
|
115
120
|
const [extraction, catalogs] = await Promise.all([scanSource(root), loadCatalogs(root)]);
|
|
116
121
|
const ignoreUnused = runtimeKeyPatterns(extraction);
|
|
117
122
|
return {
|
|
118
|
-
|
|
123
|
+
// `reference` is what sees a key no static shape names: `t(\`plans.\${x}\`)` reads the same
|
|
124
|
+
// catalog in every locale, so a key `en` holds alone is a raw key on another locale's page.
|
|
125
|
+
report: auditCatalogs({ extraction, catalogs, ignoreUnused, reference: DEFAULT_LOCALE }),
|
|
119
126
|
catalogs,
|
|
120
127
|
extraction,
|
|
121
128
|
ignoreUnused,
|
package/src/i18n-registration.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
catalogRegistrationGaps,
|
|
16
16
|
catalogsNeverRegistered,
|
|
17
17
|
catalogUnregistered,
|
|
18
|
+
familyIncomplete,
|
|
18
19
|
pluralVariantsOf,
|
|
19
20
|
registeredLocales,
|
|
20
21
|
} from '@ultimat3/i18n';
|
|
@@ -26,6 +27,7 @@ import type { DuplicateProbe } from './duplicate-packages';
|
|
|
26
27
|
import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
|
|
27
28
|
import { auditApp } from './i18n-audit';
|
|
28
29
|
import { localeParityFindings } from './i18n-locale-parity';
|
|
30
|
+
import { mailPreviewFindings } from './mail-preview-findings';
|
|
29
31
|
import type { Finding } from './output';
|
|
30
32
|
import { findingFrom } from './output';
|
|
31
33
|
import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
@@ -105,7 +107,7 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
105
107
|
duplicate === undefined
|
|
106
108
|
? { ...finding, ...unregisteredFix(gap.locale, index), at: catalogPath(gap.locale) }
|
|
107
109
|
: // The registry's own fix names a source edit — move the `defineCatalogs()` call — that an
|
|
108
|
-
// agent following it performs on a file that is already right (
|
|
110
|
+
// agent following it performs on a file that is already right (one adopter, 2026-09-05).
|
|
109
111
|
// With a duplicate on disk the cause IS the install, so the finding says so and the fix
|
|
110
112
|
// is the one the duplicate carries.
|
|
111
113
|
{
|
|
@@ -261,7 +263,9 @@ export function withPlaceholdersMissing(
|
|
|
261
263
|
const locales = report.locales.map((audit) => {
|
|
262
264
|
const catalog = catalogs[audit.locale] ?? {};
|
|
263
265
|
const known = new Set(audit.missing);
|
|
264
|
-
|
|
266
|
+
// `required`, not `used`: `x i18n sync` seeds a reference-only key as a marker too, and a marker
|
|
267
|
+
// that closed the finding would put `⟦…⟧` on the page under a green gate.
|
|
268
|
+
const placeheld = report.required.filter(
|
|
265
269
|
(key) => !known.has(key) && answersWithPlaceholder(catalog, key),
|
|
266
270
|
);
|
|
267
271
|
if (placeheld.length === 0) return audit;
|
|
@@ -276,12 +280,21 @@ export function withPlaceholdersMissing(
|
|
|
276
280
|
* the same finding, and a second construction of it is two renderers of one fact waiting to drift.
|
|
277
281
|
*/
|
|
278
282
|
export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
279
|
-
|
|
283
|
+
const missing = report.locales
|
|
280
284
|
.filter((audit) => audit.missing.length > 0)
|
|
281
285
|
.map((audit) => ({
|
|
282
286
|
...findingFrom(catalogMissingKeys(audit.locale, audit.missing)),
|
|
283
287
|
at: catalogPath(audit.locale),
|
|
284
288
|
}));
|
|
289
|
+
// A select or ordinal family with no `_other` renders `⟦key⟧` for every value it does not name:
|
|
290
|
+
// the same loud miss, one family wide, so the same gate refuses it.
|
|
291
|
+
const incomplete = report.locales
|
|
292
|
+
.filter((audit) => audit.incomplete.length > 0)
|
|
293
|
+
.map((audit) => ({
|
|
294
|
+
...findingFrom(familyIncomplete(audit.locale, audit.incomplete)),
|
|
295
|
+
at: catalogPath(audit.locale),
|
|
296
|
+
}));
|
|
297
|
+
return [...missing, ...incomplete];
|
|
285
298
|
}
|
|
286
299
|
|
|
287
300
|
/**
|
|
@@ -300,5 +313,7 @@ export async function catalogFindings(root: string): Promise<readonly Finding[]>
|
|
|
300
313
|
...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
|
|
301
314
|
...registration.findings,
|
|
302
315
|
...localeParityFindings(catalogs),
|
|
316
|
+
// The app is loaded by the audit above, so every `defineMail({ preview })` is registered.
|
|
317
|
+
...mailPreviewFindings(Object.keys(catalogs)),
|
|
303
318
|
];
|
|
304
319
|
}
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,8 @@ export { renderJobTable } from './jobs-table';
|
|
|
113
113
|
export { msg } from './messages';
|
|
114
114
|
export { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
115
115
|
export { parseMigrationSql, readMigrations } from './migrations';
|
|
116
|
+
export type { NewShape } from './new-shapes';
|
|
117
|
+
export { NEW_SHAPES } from './new-shapes';
|
|
116
118
|
export type { CommandResult, Finding, JsonValue } from './output';
|
|
117
119
|
export { exitCodeFor, findingFrom, render, renderFinding, renderHuman } from './output';
|
|
118
120
|
export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
|
package/src/island-bundle.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
realtimeIslandEntry,
|
|
37
37
|
realtimeKind,
|
|
38
38
|
} from './island-realtime';
|
|
39
|
-
import type
|
|
39
|
+
import { appPersistsRecords, type IslandRealtime } from './island-realtime-state';
|
|
40
40
|
import type { Runtimes } from './island-runtime';
|
|
41
41
|
import { loadingRuntime, mergeBuilds, runtimeBuilder, runtimeShared } from './island-runtime';
|
|
42
42
|
import { solidDedupePlugin } from './island-solid-dedupe';
|
|
@@ -130,7 +130,7 @@ export async function discoverIslands(root: string): Promise<readonly string[]>
|
|
|
130
130
|
/**
|
|
131
131
|
* One `Bun.build` over `files`. With `splitting`, a module two of them import becomes one shared
|
|
132
132
|
* chunk, fetched once by a page that renders both — a page rendering two islands that shared an
|
|
133
|
-
* upload helper downloaded it twice without (
|
|
133
|
+
* upload helper downloaded it twice without (one adopter, 2026-09-29: 55.5 kB for ~34 kB of
|
|
134
134
|
* code). Without, `files` is one island and its chunk is self-contained. Either way a route's cost
|
|
135
135
|
* is the graph walk `measureDocumentJs` does off the emitted files.
|
|
136
136
|
*/
|
|
@@ -144,9 +144,10 @@ async function buildAll(
|
|
|
144
144
|
// Inside the refusal: the realtime probe PARSES the island's graph, and a file that will not
|
|
145
145
|
// parse rejected with a raw `AggregateError: Failed to scan imports` — out of the web boot, with
|
|
146
146
|
// no code, no file and no fix — before `Bun.build` ever ran to raise the one below.
|
|
147
|
+
const persists = appPersistsRecords();
|
|
147
148
|
const realtime = await Promise.all(
|
|
148
149
|
files.map((file) =>
|
|
149
|
-
realtimeKind(root, file).catch((error: unknown) => {
|
|
150
|
+
realtimeKind(root, file, persists).catch((error: unknown) => {
|
|
150
151
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
151
152
|
}),
|
|
152
153
|
),
|