@ultimat3/cli 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +11 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +9 -0
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +41 -81
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +7 -1
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1314 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
package/src/dev-lock.ts
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
import { linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { stringField, UltimateError } from '@ultimat3/core';
|
|
20
|
-
import {
|
|
20
|
+
import { reusedPid, startedOf } from './dev-lock-identity';
|
|
21
|
+
import { type PortHolder, portHolder } from './dev-lock-port-holder';
|
|
22
|
+
import { stealStale } from './dev-lock-steal';
|
|
21
23
|
import { portPairAfter } from './flag-number';
|
|
22
24
|
import { quoteArg } from './shell-quote';
|
|
23
25
|
|
|
@@ -30,8 +32,16 @@ export interface DevLock {
|
|
|
30
32
|
readonly url: string;
|
|
31
33
|
/** ISO 8601. Only ever printed — a stale lock is decided by the pid, never by the clock. */
|
|
32
34
|
readonly startedAt: string;
|
|
35
|
+
/** The holder's OS start time (`dev-lock-identity.ts`): a reused pid does not match it. */
|
|
36
|
+
readonly started?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
export { netstatListener, portHolder, tasklistImage } from './dev-lock-port-holder';
|
|
40
|
+
// Re-exported: the port refusal's holder, split out at the file ceiling.
|
|
41
|
+
export type { PortHolder };
|
|
42
|
+
// Re-exported: `cmd-stop.ts` asks the same question before it signals.
|
|
43
|
+
export { reusedPid };
|
|
44
|
+
|
|
35
45
|
export const lockPath = (stateDir: string): string => join(stateDir, DEV_LOCK_FILE);
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -47,6 +57,7 @@ export const parseLock = (raw: string): DevLock | null => {
|
|
|
47
57
|
port: value.port,
|
|
48
58
|
url: typeof value.url === 'string' ? value.url : `http://localhost:${value.port}`,
|
|
49
59
|
startedAt: typeof value.startedAt === 'string' ? value.startedAt : '',
|
|
60
|
+
...(typeof value.started === 'string' ? { started: value.started } : {}),
|
|
50
61
|
};
|
|
51
62
|
} catch {
|
|
52
63
|
return null;
|
|
@@ -115,7 +126,8 @@ export class DevAlreadyRunningError extends UltimateError {
|
|
|
115
126
|
super({
|
|
116
127
|
code: 'X_DEV_ALREADY_RUNNING',
|
|
117
128
|
cause: `pid ${input.lock.pid} is already running x dev on ${input.lock.url} and holds ${input.stateDir}${single}`,
|
|
118
|
-
|
|
129
|
+
// `x stop`, not `kill <pid>`: a kill returns before the drain ends and the next boot refuses.
|
|
130
|
+
fix: `use the one already running at ${input.lock.url}, or stop it: x stop --json`,
|
|
119
131
|
meta: { pid: input.lock.pid, port: input.lock.port, stateDir: input.stateDir },
|
|
120
132
|
});
|
|
121
133
|
}
|
|
@@ -162,82 +174,6 @@ export class DevStateUnwritableError extends UltimateError {
|
|
|
162
174
|
}
|
|
163
175
|
}
|
|
164
176
|
|
|
165
|
-
/** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
|
|
166
|
-
export interface PortHolder {
|
|
167
|
-
readonly pid?: number;
|
|
168
|
-
/** The process name, e.g. `bun`. Enough to recognise "that is my other project". */
|
|
169
|
-
readonly command?: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Who holds the port. `ss` first because it is present on every Linux box the framework targets and
|
|
174
|
-
* needs no elevation for your own processes; `lsof` is the macOS answer; `netstat -ano` plus
|
|
175
|
-
* `tasklist` the Windows one.
|
|
176
|
-
*
|
|
177
|
-
* Through `exec.ts`, like every other subprocess the CLI runs, so a test injects a fake `Runner`
|
|
178
|
-
* rather than racing a real socket.
|
|
179
|
-
*
|
|
180
|
-
* EVERY PROBE IS CAUGHT SEPARATELY. `exec` refuses a missing program with `X_CLI_UNEXPECTED`, and
|
|
181
|
-
* letting that escape would mean a box without `ss` gets the CLI's catch-all instead of
|
|
182
|
-
* `X_PORT_IN_USE` — the exact substitution this module exists to end. A missing `ss` must fall
|
|
183
|
-
* through to `lsof`, and a box with none still gets the refusal, just without a pid in it.
|
|
184
|
-
*/
|
|
185
|
-
export const portHolder = async (port: number, runner: Runner = exec): Promise<PortHolder> => {
|
|
186
|
-
const probe = async (command: readonly string[]): Promise<string> => {
|
|
187
|
-
try {
|
|
188
|
-
const result = await runner(command, { cwd: process.cwd() });
|
|
189
|
-
return result.stdout;
|
|
190
|
-
} catch {
|
|
191
|
-
return '';
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
const parsed = /users:\(\("([^"]+)",pid=(\d+)/.exec(
|
|
196
|
-
await probe(['ss', '-lptnH', `sport = :${port}`]),
|
|
197
|
-
);
|
|
198
|
-
const name = parsed?.[1];
|
|
199
|
-
if (parsed !== null && name !== undefined) return { command: name, pid: Number(parsed[2]) };
|
|
200
|
-
|
|
201
|
-
const lines = (
|
|
202
|
-
await probe(['lsof', '-nP', `-iTCP:${String(port)}`, '-sTCP:LISTEN', '-Fpc'])
|
|
203
|
-
).split('\n');
|
|
204
|
-
const pid = lines.find((line) => line.startsWith('p'))?.slice(1);
|
|
205
|
-
const command = lines.find((line) => line.startsWith('c'))?.slice(1);
|
|
206
|
-
if (pid !== undefined && /^\d+$/.test(pid)) {
|
|
207
|
-
return command === undefined ? { pid: Number(pid) } : { command, pid: Number(pid) };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Windows has neither: `netstat -ano` names the listening pid, `tasklist` its image.
|
|
211
|
-
const listener = netstatListener(await probe(['netstat', '-ano', '-p', 'TCP']), port);
|
|
212
|
-
if (listener === undefined) return {};
|
|
213
|
-
const image = tasklistImage(
|
|
214
|
-
await probe(['tasklist', '/FI', `PID eq ${String(listener)}`, '/FO', 'CSV', '/NH']),
|
|
215
|
-
);
|
|
216
|
-
return image === undefined ? { pid: listener } : { command: image, pid: listener };
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* The pid `netstat -ano` reports LISTENING on `port`, any local address: `TCP 127.0.0.1:3000
|
|
221
|
-
* 0.0.0.0:0 LISTENING 4242`, and `[::1]:3000` for IPv6. The state column is the word Windows
|
|
222
|
-
* prints in English on every locale this was read on; an unrecognised line is skipped.
|
|
223
|
-
*/
|
|
224
|
-
export function netstatListener(text: string, port: number): number | undefined {
|
|
225
|
-
for (const line of text.split(/\r?\n/)) {
|
|
226
|
-
const [proto, local, , state, pid] = line.trim().split(/\s+/);
|
|
227
|
-
if (proto !== 'TCP' || state !== 'LISTENING' || pid === undefined || !/^\d+$/.test(pid)) {
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (local?.endsWith(`:${String(port)}`) === true) return Number(pid);
|
|
231
|
-
}
|
|
232
|
-
return undefined;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** The image name off `tasklist /FO CSV /NH`'s one row — `"bun.exe","4242",…` — or `undefined`. */
|
|
236
|
-
export function tasklistImage(text: string): string | undefined {
|
|
237
|
-
const name = /^"([^"]+)","\d+"/m.exec(text.trim())?.[1];
|
|
238
|
-
return name === undefined || name === '' ? undefined : name;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
177
|
/**
|
|
242
178
|
* The port is bound by something that is not us. Named separately from the lock case because the
|
|
243
179
|
* remedy is different: a stranger's port is worked around with `--port`, while a second `x dev` on
|
|
@@ -333,11 +269,23 @@ function claimExclusive(stateDir: string, path: string, lock: DevLock): boolean
|
|
|
333
269
|
}
|
|
334
270
|
}
|
|
335
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The lock's bytes right now, or `null` when there is no file to read. Total: a rival's steal may
|
|
274
|
+
* remove the file between `exists()` and the read, and that is "absent", never a bare `ENOENT`.
|
|
275
|
+
*/
|
|
276
|
+
const readRaw = async (path: string): Promise<string | null> => {
|
|
277
|
+
const file = Bun.file(path);
|
|
278
|
+
try {
|
|
279
|
+
return (await file.exists()) ? await file.text() : null;
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
336
285
|
/** Whatever is on disk right now, or `null` if it is absent or half-written. */
|
|
337
286
|
const readLock = async (path: string): Promise<DevLock | null> => {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
return parseLock(await file.text());
|
|
287
|
+
const raw = await readRaw(path);
|
|
288
|
+
return raw === null ? null : parseLock(raw);
|
|
341
289
|
};
|
|
342
290
|
|
|
343
291
|
export interface PreflightInput {
|
|
@@ -363,6 +311,62 @@ export interface PreflightResult {
|
|
|
363
311
|
release(): void;
|
|
364
312
|
}
|
|
365
313
|
|
|
314
|
+
/** Rounds of claim-or-clear before the lock is reported unreadable, and the wait on a busy steal. */
|
|
315
|
+
const CLAIM_ROUNDS = 200;
|
|
316
|
+
const STEAL_BUSY_MS = 5;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The claim, clearing a stale lock on the way: `true` when one was cleared. Each round claims, or
|
|
320
|
+
* reads the holder and refuses a live one, or clears a stale one under `dev.lock.steal`
|
|
321
|
+
* (`stealStale`) and goes round again — so a lock that changed hands mid-round is judged afresh,
|
|
322
|
+
* never deleted by path. A rival's fresh claim then reads as live and is refused; an unlink after
|
|
323
|
+
* an unguarded read deleted it, and two boots shared one `.x/pgdata`.
|
|
324
|
+
*/
|
|
325
|
+
async function claimOrClearStale(
|
|
326
|
+
input: PreflightInput,
|
|
327
|
+
path: string,
|
|
328
|
+
mine: DevLock,
|
|
329
|
+
alive: (pid: number) => boolean,
|
|
330
|
+
): Promise<boolean> {
|
|
331
|
+
let clearedStale = false;
|
|
332
|
+
let held: DevLock | null = null;
|
|
333
|
+
for (let round = 0; round < CLAIM_ROUNDS; round += 1) {
|
|
334
|
+
if (claimExclusive(input.stateDir, path, mine)) return clearedStale;
|
|
335
|
+
const raw = await readRaw(path);
|
|
336
|
+
const now = raw === null ? null : parseLock(raw);
|
|
337
|
+
// `held` keeps the last identity read: a half-written file parses as `null` for microseconds.
|
|
338
|
+
held = now ?? held;
|
|
339
|
+
if (now !== null && alive(now.pid) && !(await reusedPid(now))) {
|
|
340
|
+
throw new DevAlreadyRunningError({
|
|
341
|
+
lock: now,
|
|
342
|
+
stateDir: input.stateDir,
|
|
343
|
+
...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// Stale: a hard kill, or a file nothing here wrote. No bytes read (a directory at the lock's
|
|
347
|
+
// path, say) leaves nothing to compare, so nothing is removed and the claim decides alone.
|
|
348
|
+
clearedStale = true;
|
|
349
|
+
if (raw === null) continue;
|
|
350
|
+
const outcome = stealStale({
|
|
351
|
+
path,
|
|
352
|
+
judged: raw,
|
|
353
|
+
claim: (guard) => claimExclusive(input.stateDir, guard, mine),
|
|
354
|
+
alive: isProcessAlive,
|
|
355
|
+
});
|
|
356
|
+
if (outcome === 'busy') await Bun.sleep(STEAL_BUSY_MS);
|
|
357
|
+
}
|
|
358
|
+
// NEVER `mine`, which is what it fell back to: with an unparseable lock that could not be
|
|
359
|
+
// cleared, both reads answered `null` and the refusal named THIS pid as the holder — `kill
|
|
360
|
+
// <self>` as the remedy for a file nobody could read.
|
|
361
|
+
const holder = (await readLock(path)) ?? held;
|
|
362
|
+
if (holder === null) throw new DevLockUnreadableError({ path, stateDir: input.stateDir });
|
|
363
|
+
throw new DevAlreadyRunningError({
|
|
364
|
+
lock: holder,
|
|
365
|
+
stateDir: input.stateDir,
|
|
366
|
+
...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
366
370
|
/**
|
|
367
371
|
* Run before anything boots, and it CLAIMS: it returns holding the directory, never having merely
|
|
368
372
|
* looked at it.
|
|
@@ -374,8 +378,8 @@ export interface PreflightResult {
|
|
|
374
378
|
* records. The claim closes the window it names.
|
|
375
379
|
*
|
|
376
380
|
* The stale path still exists and is still normal (a hard kill leaves a lock behind): the file is
|
|
377
|
-
* removed and the claim retried
|
|
378
|
-
* cleared slot in that instant, which is a refusal and not a third boot.
|
|
381
|
+
* removed (`claimOrClearStale`) and the claim retried. A retry that ALSO loses is another boot
|
|
382
|
+
* that claimed the cleared slot in that instant, which is a refusal and not a third boot.
|
|
379
383
|
*/
|
|
380
384
|
export const preflight = async (input: PreflightInput): Promise<PreflightResult> => {
|
|
381
385
|
const alive = input.alive ?? isProcessAlive;
|
|
@@ -396,43 +400,9 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
396
400
|
// is what the refusal prints, and it is the address the boot is about to ask for.
|
|
397
401
|
url: `http://${input.hostname}:${input.port}`,
|
|
398
402
|
startedAt: new Date().toISOString(),
|
|
403
|
+
...(await startedOf(process.pid)),
|
|
399
404
|
};
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!claimExclusive(input.stateDir, path, mine)) {
|
|
403
|
-
const held = await readLock(path);
|
|
404
|
-
if (held !== null && alive(held.pid)) {
|
|
405
|
-
throw new DevAlreadyRunningError({
|
|
406
|
-
lock: held,
|
|
407
|
-
stateDir: input.stateDir,
|
|
408
|
-
...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
// Stale: a hard kill, or a file nothing here wrote. Removing it is what makes the next boot
|
|
412
|
-
// work, and `unlinkSync` because a lock that outlives its own cleanup is the bug being fixed.
|
|
413
|
-
try {
|
|
414
|
-
unlinkSync(path);
|
|
415
|
-
} catch {
|
|
416
|
-
// Already gone, or not ours to remove. The claim below is what decides.
|
|
417
|
-
}
|
|
418
|
-
clearedStale = true;
|
|
419
|
-
if (!claimExclusive(input.stateDir, path, mine)) {
|
|
420
|
-
// Lost the race for the slot we just cleared. `held` is the best identity available — a
|
|
421
|
-
// half-written file parses as `null` for microseconds — and refusing on a stale pid beats
|
|
422
|
-
// the alternative, which is two processes writing one single-writer data directory.
|
|
423
|
-
//
|
|
424
|
-
// NEVER `mine`, which is what it fell back to: with an unparseable lock and a failed
|
|
425
|
-
// `unlinkSync`, both reads answer `null` and the refusal named THIS pid as the holder —
|
|
426
|
-
// `kill <self>` as the remedy for a file nobody could read.
|
|
427
|
-
const holder = (await readLock(path)) ?? held;
|
|
428
|
-
if (holder === null) throw new DevLockUnreadableError({ path, stateDir: input.stateDir });
|
|
429
|
-
throw new DevAlreadyRunningError({
|
|
430
|
-
lock: holder,
|
|
431
|
-
stateDir: input.stateDir,
|
|
432
|
-
...(input.embeddedDb === undefined ? {} : { embeddedDb: input.embeddedDb }),
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
405
|
+
const clearedStale = await claimOrClearStale(input, path, mine, alive);
|
|
436
406
|
|
|
437
407
|
if (bound(input.port, input.hostname)) {
|
|
438
408
|
// Released before the throw: the remedy this refusal prints is `x dev --port <n>`, and a claim
|
|
@@ -455,11 +425,13 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
|
|
|
455
425
|
* with the address the server actually bound, which is the one field the preflight could not know.
|
|
456
426
|
*/
|
|
457
427
|
export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
|
|
428
|
+
const stamped: DevLock =
|
|
429
|
+
lock.started === undefined ? { ...lock, ...(await startedOf(lock.pid)) } : lock;
|
|
458
430
|
// Staged and renamed over the claim, never rewritten in place: a truncate-then-write leaves an
|
|
459
431
|
// empty lock for an instant, and a preflight reading it then would clear a live claim as stale.
|
|
460
432
|
const path = lockPath(stateDir);
|
|
461
433
|
const staged = stagingPath(path);
|
|
462
|
-
writeFileSync(staged, lockText(
|
|
434
|
+
writeFileSync(staged, lockText(stamped));
|
|
463
435
|
renameSync(staged, path);
|
|
464
436
|
};
|
|
465
437
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The `/_x` mail panel's templates source: every registered mail, its declared `preview` rendered
|
|
2
|
+
// in each of the app's locales by `@ultimat3/mail`'s `previewMail` — the same render
|
|
3
|
+
// `x mail preview` prints. Dates in a preview render in UTC, named: there is no ambient zone.
|
|
4
|
+
|
|
5
|
+
import type { MailTemplateFact, MailTemplatePreviewFact } from '@ultimat3/admin/dev';
|
|
6
|
+
import { InternalError, renderFixShellArg, renderThrowable, stringField } from '@ultimat3/core';
|
|
7
|
+
import { mailFor, previewMail, registeredMailIds } from '@ultimat3/mail';
|
|
8
|
+
|
|
9
|
+
/** The zone a preview's dates render in: a sample has no reader, so no reader's zone. */
|
|
10
|
+
const PREVIEW_ZONE = 'UTC';
|
|
11
|
+
|
|
12
|
+
export function mailTemplateFacts(locales: readonly string[]): readonly MailTemplateFact[] {
|
|
13
|
+
return registeredMailIds().map((id) => {
|
|
14
|
+
const hasPreview = mailFor(id)?.preview !== undefined;
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
hasPreview,
|
|
18
|
+
previews: hasPreview ? locales.map((locale) => previewIn(id, locale)) : [],
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A preview function's data is parsed at each render, so one locale's can be refused while the
|
|
25
|
+
* others render: that refusal is this cell's, shown with its fix, never the whole panel's.
|
|
26
|
+
*/
|
|
27
|
+
function previewIn(id: string, locale: string): MailTemplatePreviewFact {
|
|
28
|
+
try {
|
|
29
|
+
const preview = previewMail(id, { locale, tz: PREVIEW_ZONE });
|
|
30
|
+
return {
|
|
31
|
+
locale,
|
|
32
|
+
ok: true,
|
|
33
|
+
subject: preview.subject,
|
|
34
|
+
html: preview.html,
|
|
35
|
+
text: preview.text,
|
|
36
|
+
missing: preview.misses.map((miss) => miss.key),
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
locale,
|
|
41
|
+
ok: false,
|
|
42
|
+
error: {
|
|
43
|
+
code: stringField(error, 'code') ?? InternalError.code,
|
|
44
|
+
cause: stringField(error, 'cause') ?? renderThrowable(error),
|
|
45
|
+
fix:
|
|
46
|
+
stringField(error, 'fix') ??
|
|
47
|
+
`x mail preview ${renderFixShellArg(id, '<mail-id>')} --locale ${renderFixShellArg(locale, '<locale>')} --json`,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/dev-rebuild.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// supervisor as a restart. Coalesced, so a tick arriving mid-build is one trailing rebuild.
|
|
4
4
|
|
|
5
5
|
import type { Manifest } from '@ultimat3/manifest';
|
|
6
|
+
import { beginDeviceSocketGeneration } from '@ultimat3/realtime/server';
|
|
6
7
|
import type { IsrController } from '@ultimat3/render/server';
|
|
7
8
|
import { appManifest } from './app-manifest';
|
|
8
9
|
import type { StalePin } from './app-reload-graph';
|
|
@@ -42,17 +43,36 @@ export interface DevRebuildOptions {
|
|
|
42
43
|
readonly onRestart?: (pins: readonly StalePin[]) => void;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* A save that declared a `deviceSocket()` path the listener did not bind: Bun's websocket paths
|
|
48
|
+
* are fixed when it starts, so only a new process serves it — `restartFinding`'s code and fix.
|
|
49
|
+
*/
|
|
50
|
+
export function unservedDeviceFinding(path: string, file: string): Finding {
|
|
51
|
+
return {
|
|
52
|
+
code: 'X_DEV_RESTART_REQUIRED',
|
|
53
|
+
cause: `deviceSocket('${path}') was declared after x dev bound its listener, which serves only the device paths it booted with`,
|
|
54
|
+
fix: 'x stop && x dev # the listener binds its websocket paths once, at boot',
|
|
55
|
+
at: file,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The watcher's `onChange`: rebuild `state` for the saved `file`, never rejecting. `refreshDevices`
|
|
61
|
+
* is the running roles' (`RunningRoles.refreshDevices`): the rescan is a new device generation, so
|
|
62
|
+
* a re-evaluated `deviceSocket()` replaces its path's old value instead of colliding with it.
|
|
63
|
+
*/
|
|
46
64
|
export function devRebuilder(
|
|
47
65
|
options: DevRebuildOptions,
|
|
48
66
|
state: DevState,
|
|
49
67
|
isr: IsrController,
|
|
68
|
+
refreshDevices: () => Promise<readonly string[]>,
|
|
50
69
|
): ReloadTrigger {
|
|
51
70
|
// One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
|
|
52
71
|
// trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
|
|
53
72
|
return coalesceReloads(
|
|
54
73
|
async (file) => {
|
|
55
74
|
const started = performance.now();
|
|
75
|
+
beginDeviceSocketGeneration();
|
|
56
76
|
const [{ manifest, findings }, islands] = await Promise.all([
|
|
57
77
|
appManifest(options.root),
|
|
58
78
|
buildIslands(options.root),
|
|
@@ -60,14 +80,18 @@ export function devRebuilder(
|
|
|
60
80
|
state.manifest = manifest;
|
|
61
81
|
state.appFindings = findings;
|
|
62
82
|
state.islands = islands;
|
|
83
|
+
const unserved = await refreshDevices();
|
|
63
84
|
for (const path of isr.store().paths()) isr.store().delete(path);
|
|
64
85
|
state.reloads += 1;
|
|
65
86
|
// Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
|
|
66
87
|
const pins = takeStalePins();
|
|
88
|
+
const [device] = unserved;
|
|
67
89
|
state.reloadFinding =
|
|
68
90
|
pins.length > 0 && options.onRestart === undefined
|
|
69
91
|
? restartFinding(options.root, pins)
|
|
70
|
-
: undefined
|
|
92
|
+
: device === undefined
|
|
93
|
+
? undefined
|
|
94
|
+
: unservedDeviceFinding(device, file);
|
|
71
95
|
if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
|
|
72
96
|
else options.onReload?.(file, Math.round(performance.now() - started));
|
|
73
97
|
},
|
package/src/dev-route-table.ts
CHANGED
|
@@ -11,7 +11,10 @@ import type { IsrController } from '@ultimat3/render/server';
|
|
|
11
11
|
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
12
12
|
import type { Storage } from '@ultimat3/storage';
|
|
13
13
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
14
|
+
import { loadAppConfig } from './app-config-load';
|
|
14
15
|
import { mountAppMcp } from './app-mcp';
|
|
16
|
+
import { clientErrorsSurface } from './client-errors-routes';
|
|
17
|
+
import { clientScriptHeads, clientVitalsSurface } from './client-vitals-route';
|
|
15
18
|
import type { DevDashboardInput } from './dev-dashboard';
|
|
16
19
|
import { devDashboardRoutes } from './dev-dashboard';
|
|
17
20
|
import { errorPageStyleBodies } from './error-page-csp';
|
|
@@ -37,6 +40,7 @@ import { serviceWorkerRoutes } from './sw-routes';
|
|
|
37
40
|
import type { ThemeBoot } from './theme-boot';
|
|
38
41
|
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
39
42
|
import { loadThemeBrand } from './theme-brand';
|
|
43
|
+
import { type ZoneBoot, zoneBoot } from './zone-boot';
|
|
40
44
|
|
|
41
45
|
export interface DevRouteTableInput {
|
|
42
46
|
readonly root: string;
|
|
@@ -71,6 +75,8 @@ export interface DevRouteTable {
|
|
|
71
75
|
readonly theme: ThemeBoot;
|
|
72
76
|
/** The speculation rules, admitted the same way — `undefined` when no document carries any. */
|
|
73
77
|
readonly speculation: PageSpeculation | undefined;
|
|
78
|
+
/** The zone script (`time.detect`), admitted the same way — `undefined` when the app left it off. */
|
|
79
|
+
readonly zone: ZoneBoot | undefined;
|
|
74
80
|
/**
|
|
75
81
|
* Every inline `<style>` body these routes put in a document: the app's error pages' and its
|
|
76
82
|
* brand's (`theme-brand.ts`) — what `startRoles({ inlineStyles })` hashes into `style-src`.
|
|
@@ -138,6 +144,13 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
138
144
|
const mcpMount = await mountAppMcp(input.root);
|
|
139
145
|
// `serve-boot.ts`'s answer: the app's declared disks when it has any, read per request.
|
|
140
146
|
const served = servedStorage(input.storage);
|
|
147
|
+
// `clientErrors.enabled` or nothing: off, the route is a 404 and no document names the script.
|
|
148
|
+
const appConfig = await loadAppConfig(input.root);
|
|
149
|
+
const reporting = appConfig?.clientErrors;
|
|
150
|
+
const zone = zoneBoot(appConfig?.time);
|
|
151
|
+
const clientErrors = clientErrorsSurface(reporting, input.buildId);
|
|
152
|
+
// Real-user timings: their own switch (`clientErrors.vitals.enabled`), the same door.
|
|
153
|
+
const vitals = clientVitalsSurface(reporting?.vitals);
|
|
141
154
|
const routes: readonly Route[] = [
|
|
142
155
|
...devDashboardRoutes(input.dashboard),
|
|
143
156
|
// The same API table the container serves: a read that answers here and 404s in production
|
|
@@ -156,6 +169,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
156
169
|
...(pwa === undefined ? {} : { pwa }),
|
|
157
170
|
}),
|
|
158
171
|
...storageRoutes({ storage: served }),
|
|
172
|
+
...(clientErrors?.routes ?? []),
|
|
173
|
+
...(vitals?.routes ?? []),
|
|
159
174
|
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
160
175
|
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
161
176
|
...islandRoutes(() => input.islands()),
|
|
@@ -205,6 +220,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
205
220
|
...(pwa === undefined
|
|
206
221
|
? {}
|
|
207
222
|
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
223
|
+
...clientScriptHeads(clientErrors, vitals, zone),
|
|
208
224
|
}),
|
|
209
225
|
];
|
|
210
226
|
|
|
@@ -212,6 +228,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
212
228
|
routes,
|
|
213
229
|
theme,
|
|
214
230
|
speculation,
|
|
231
|
+
zone,
|
|
215
232
|
inlineStyles,
|
|
216
233
|
mcpPath: mcpMount.path,
|
|
217
234
|
mcpPaths: mcpMount.paths,
|
package/src/dev-supervisor.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { StalePin } from './app-reload-graph';
|
|
9
9
|
import { devSpec } from './cmd-dev-spec';
|
|
10
|
-
import type {
|
|
10
|
+
import type { DevChildLeaving, DevDrainMessage } from './dev-child-watch';
|
|
11
11
|
import {
|
|
12
12
|
DEV_SUPERVISOR_PID_ENV,
|
|
13
13
|
devHardExitMs,
|
|
@@ -267,7 +267,7 @@ export function childRestart(
|
|
|
267
267
|
if (env[DEV_CHILD_ENV] !== '1') {
|
|
268
268
|
return { options: {}, exit: () => undefined, adopt: () => undefined };
|
|
269
269
|
}
|
|
270
|
-
let leaving: 'restart' |
|
|
270
|
+
let leaving: 'restart' | DevChildLeaving | undefined;
|
|
271
271
|
let worker: RetiringWorker | null = null;
|
|
272
272
|
let restarting = false;
|
|
273
273
|
const watched =
|
|
@@ -279,6 +279,9 @@ export function childRestart(
|
|
|
279
279
|
return {
|
|
280
280
|
options: {
|
|
281
281
|
onRestart: (pins) => {
|
|
282
|
+
// Already stopping for a signal (`x stop`, Ctrl-C) or a gone supervisor or root: a save
|
|
283
|
+
// landing during that drain must not respawn a child its operator asked to end.
|
|
284
|
+
if (leaving !== undefined && leaving !== 'restart') return;
|
|
282
285
|
writeErrorLine(msg('cli.dev.restart', { reason: restartReason(root, pins) }));
|
|
283
286
|
leaving ??= 'restart';
|
|
284
287
|
watched?.stopping(DEV_RESTART_EXIT_CODE);
|
package/src/dev-watch-tree.ts
CHANGED
|
@@ -47,14 +47,17 @@ export interface WatchTree {
|
|
|
47
47
|
const DEFAULT_DEBOUNCE_MS = 30;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Every directory under `
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* Every directory at or under `from` (`''`, the app root, by default) an event could be a source
|
|
51
|
+
* change in, `from` first, every path root-relative. Ignored directories are not descended, so a
|
|
52
|
+
* `node_modules` tree costs one `readdir` and nothing else. A symlinked directory is deliberately
|
|
53
|
+
* not followed: `Dirent.isDirectory()` is false for one, and a workspace symlink pointing back into
|
|
54
|
+
* the checkout would otherwise be walked twice. Root-relative because the ignore set is the ROOT's
|
|
55
|
+
* `.gitignore`: a walk rooted at a new directory asked an anchored `/build` about `build`, and hid
|
|
56
|
+
* `apps/web/build/` when `apps/web` appeared after boot.
|
|
54
57
|
*/
|
|
55
|
-
export function admittedDirectories(root: string, ignore: DevIgnore): readonly string[] {
|
|
56
|
-
const admitted: string[] = [
|
|
57
|
-
const pending: string[] = [
|
|
58
|
+
export function admittedDirectories(root: string, ignore: DevIgnore, from = ''): readonly string[] {
|
|
59
|
+
const admitted: string[] = [from];
|
|
60
|
+
const pending: string[] = [from];
|
|
58
61
|
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
59
62
|
for (const entry of childrenOf(join(root, next))) {
|
|
60
63
|
if (!entry.isDirectory()) continue;
|
|
@@ -156,9 +159,7 @@ export function watchTree(options: WatchTreeOptions): WatchTree {
|
|
|
156
159
|
|
|
157
160
|
/** The admitted directories at or under `directory`, discovered rather than assumed. */
|
|
158
161
|
const subtree = (directory: string): readonly string[] =>
|
|
159
|
-
admittedDirectories(
|
|
160
|
-
found === '' ? directory : `${directory}/${found}`,
|
|
161
|
-
);
|
|
162
|
+
admittedDirectories(root, ignore, directory);
|
|
162
163
|
|
|
163
164
|
/** The whole set, re-derived: a directory the author just ignored gives its descriptor back. */
|
|
164
165
|
const reconcile = (): void => {
|
package/src/dev-watch.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { pathSegments } from './path-segments';
|
|
|
30
30
|
* - `node_modules` — an install, not an edit. `x dev` cannot reload for a dependency change: the
|
|
31
31
|
* modules are already in this process's cache.
|
|
32
32
|
* - `.personal` — an app's uncommitted local state, written by scripts while the dev server runs.
|
|
33
|
-
* - `.claude` — agent scratch, session logs and worktrees.
|
|
33
|
+
* - `.claude` — agent scratch, session logs and worktrees. One adopter keeps two entire copies of
|
|
34
34
|
* the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
|
|
35
35
|
*
|
|
36
36
|
* `dist` and `coverage` were here and are deliberately not: both are ordinary build output that
|
package/src/dispatch.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// whether it is printed as text or JSON and what the process exits with. Errors take the same
|
|
3
3
|
// path as results, so a failure is machine-readable exactly like a success.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// why: a synchronous bin probe at the one place a result is rendered; Bun has no path helpers.
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
8
|
import { setLogStream } from '@ultimat3/core';
|
|
7
9
|
import { requireAppRoot, requireBunVersion } from './app-root';
|
|
8
10
|
import { createHelpCommand } from './cmd-help';
|
|
@@ -13,6 +15,7 @@ import type { Runner } from './exec';
|
|
|
13
15
|
import { exec } from './exec';
|
|
14
16
|
import type { CommandResult } from './output';
|
|
15
17
|
import { exitCodeFor, findingFrom, render } from './output';
|
|
18
|
+
import type { ReaderOptions } from './output-findings';
|
|
16
19
|
import type { ParsedArgs } from './parse';
|
|
17
20
|
import { parseArgs, wantsJson } from './parse';
|
|
18
21
|
import { commandFor, SPECS } from './registry';
|
|
@@ -56,11 +59,29 @@ export const sinkFor = (
|
|
|
56
59
|
): ((line: string) => void) =>
|
|
57
60
|
result.stream === 'stderr' ? (options.writeError ?? options.write) : options.write;
|
|
58
61
|
|
|
62
|
+
/** `x` where Bun links it: on POSIX, and the shim it writes on Windows. */
|
|
63
|
+
const LOCAL_BINS = ['x', 'x.exe'];
|
|
64
|
+
|
|
59
65
|
const resolveCwd = (cwd: string, flag: string | undefined): string => {
|
|
60
66
|
if (flag === undefined) return cwd;
|
|
61
67
|
return isAbsolute(flag) ? flag : resolve(cwd, flag);
|
|
62
68
|
};
|
|
63
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether this reader's fixes should name `bunx x`. Only where it resolves to the app's own CLI: with
|
|
72
|
+
* no local bin, `bunx x` fetches whatever npm calls `x`, and a global `x` is what ran anyway. An
|
|
73
|
+
* entry point with its own name (`invocation`, `create-ultimate`) already names its runner.
|
|
74
|
+
*/
|
|
75
|
+
const readerFor = (cwd: string, invocation: string | undefined): ReaderOptions => {
|
|
76
|
+
if (invocation !== undefined) return {};
|
|
77
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
78
|
+
if (LOCAL_BINS.some((bin) => existsSync(join(dir, 'node_modules', '.bin', bin)))) {
|
|
79
|
+
return { bunxRunner: true };
|
|
80
|
+
}
|
|
81
|
+
if (dirname(dir) === dir) return {};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
64
85
|
const errorResult = (command: string, error: unknown): CommandResult => ({
|
|
65
86
|
ok: false,
|
|
66
87
|
command,
|
|
@@ -74,13 +95,14 @@ const errorResult = (command: string, error: unknown): CommandResult => ({
|
|
|
74
95
|
* end without terminating the test runner.
|
|
75
96
|
*/
|
|
76
97
|
export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
98
|
+
const earlyReader = readerFor(options.cwd, options.invocation);
|
|
77
99
|
// Its own branch, ahead of the parse: an unsupported Bun is a fact about the environment and
|
|
78
100
|
// outranks anything argv says, including the planned pre-empt below.
|
|
79
101
|
try {
|
|
80
102
|
requireBunVersion(options.bunVersion);
|
|
81
103
|
} catch (error) {
|
|
82
104
|
const failure = errorResult('x', error);
|
|
83
|
-
sinkFor(failure, options)(render(failure, wantsJson(options.argv)));
|
|
105
|
+
sinkFor(failure, options)(render(failure, wantsJson(options.argv), false, earlyReader));
|
|
84
106
|
return 1;
|
|
85
107
|
}
|
|
86
108
|
|
|
@@ -99,7 +121,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
99
121
|
// `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
|
|
100
122
|
// an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
|
|
101
123
|
const result = errorResult(planned?.name ?? 'x', failure);
|
|
102
|
-
sinkFor(result, options)(render(result, wantsJson(options.argv)));
|
|
124
|
+
sinkFor(result, options)(render(result, wantsJson(options.argv), false, earlyReader));
|
|
103
125
|
return 1;
|
|
104
126
|
}
|
|
105
127
|
|
|
@@ -112,7 +134,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
112
134
|
known: SPECS.map((spec) => spec.name),
|
|
113
135
|
}),
|
|
114
136
|
);
|
|
115
|
-
sinkFor(result, options)(render(result, args.json));
|
|
137
|
+
sinkFor(result, options)(render(result, args.json, false, earlyReader));
|
|
116
138
|
return 1;
|
|
117
139
|
}
|
|
118
140
|
|
|
@@ -134,6 +156,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
134
156
|
options.env,
|
|
135
157
|
await rootEnvChanges({ cwd, processCwd: options.cwd, env: options.env }),
|
|
136
158
|
);
|
|
159
|
+
const reader = readerFor(cwd, options.invocation);
|
|
137
160
|
const ctx: CommandContext = {
|
|
138
161
|
args: helpArgs,
|
|
139
162
|
cwd,
|
|
@@ -164,7 +187,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
164
187
|
// command, which declares nothing: usage for a command must be readable from anywhere.
|
|
165
188
|
if (target.spec.requiresApp === true) requireAppRoot(target.spec.name, ctx.cwd);
|
|
166
189
|
const result = await target.run(ctx);
|
|
167
|
-
sinkFor(result, options)(render(result, args.json, args.flags.get('verbose') === true));
|
|
190
|
+
sinkFor(result, options)(render(result, args.json, args.flags.get('verbose') === true, reader));
|
|
168
191
|
// `x dev` and `x mcp serve --transport http` are still listening here: report first, so the
|
|
169
192
|
// url is on stdout the moment it is reachable, then stay in the process until the drain that
|
|
170
193
|
// stops them. Without this the exit code below is what takes the server down.
|
|
@@ -172,7 +195,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
172
195
|
return exitCodeFor(result);
|
|
173
196
|
} catch (error) {
|
|
174
197
|
const result = errorResult(args.command, error);
|
|
175
|
-
sinkFor(result, options)(render(result, args.json));
|
|
198
|
+
sinkFor(result, options)(render(result, args.json, false, reader));
|
|
176
199
|
return 1;
|
|
177
200
|
}
|
|
178
201
|
}
|