@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/messages.ts
CHANGED
|
@@ -86,6 +86,9 @@ const CATALOG = {
|
|
|
86
86
|
'cli.dev.mail.refused': 'mail=refused({detail})',
|
|
87
87
|
'cli.dev.hmr': 'reloaded {file} in {ms}ms',
|
|
88
88
|
'cli.dev.restart': 'restarting: {reason} — a module that defines a primitive holds the old code',
|
|
89
|
+
// The restart's wait, said while it waits: a save never cancels a running job (issue #677).
|
|
90
|
+
'cli.dev.restartWaiting':
|
|
91
|
+
'restart waits for {count} running job(s) to finish — no new job is claimed; Ctrl-C stops now (a job still running is cut off at the drain deadline and requeued)',
|
|
89
92
|
// A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
|
|
90
93
|
// reader knows why the boot paused, and never a finding.
|
|
91
94
|
'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
|
|
@@ -143,9 +146,6 @@ const CATALOG = {
|
|
|
143
146
|
'cli.jobs.deadLetters': '{count} dead letter(s):',
|
|
144
147
|
'cli.jobs.depth':
|
|
145
148
|
'{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
|
|
146
|
-
'cli.jobs.drained': 'drained {count} job(s) from {from} to {to}',
|
|
147
|
-
'cli.jobs.drainedPartial':
|
|
148
|
-
'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
|
|
149
149
|
'cli.jobs.listed': '{count} job(s)',
|
|
150
150
|
'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
|
|
151
151
|
'cli.jobs.noError': 'no error recorded',
|
|
@@ -156,24 +156,30 @@ const CATALOG = {
|
|
|
156
156
|
'cli.jobs.resumed': 'queue {queue} resumed',
|
|
157
157
|
'cli.jobs.retried': 'job {id} re-queued — {state}',
|
|
158
158
|
'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
|
|
159
|
-
'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
|
|
160
159
|
'cli.manifest.blocked': 'manifest not written — {count} module(s) did not load',
|
|
161
160
|
'cli.manifest.fresh': 'manifest is fresh',
|
|
162
161
|
'cli.manifest.stale': 'manifest is stale',
|
|
163
162
|
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
164
163
|
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
165
164
|
'cli.mcp.scopes': ' scopes {scopes}',
|
|
166
|
-
// `
|
|
167
|
-
// and it is the only spelling that is right on a fresh clone — it
|
|
168
|
-
// `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no
|
|
169
|
-
// drift step is red until it has), migrates and seeds. The four-command line
|
|
170
|
-
// `x dev` off a tree where nothing had installed the CLI yet, and skipped the
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
165
|
+
// `bun run setup` and nothing else: the scaffold ships it (`bin/setup.ts`), `README.md` and
|
|
166
|
+
// `bun run check` both name it, and it is the only spelling that is right on a fresh clone — it
|
|
167
|
+
// installs, writes `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no
|
|
168
|
+
// migration, so the drift step is red until it has), migrates and seeds. The four-command line
|
|
169
|
+
// this replaced named `x dev` off a tree where nothing had installed the CLI yet, and skipped the
|
|
170
|
+
// seed entirely.
|
|
171
|
+
// `bun run dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
|
|
172
|
+
// nowhere else, so a bare `x` is not on PATH in the shell it is pasted into (proved with
|
|
173
|
+
// `env -i PATH=… command -v x`); `bun run` puts that directory on PATH for the script. And
|
|
174
|
+
// `bun run`, never a `bin/` path: Bun runs the line identically in PowerShell, cmd and bash.
|
|
175
|
+
// `{cd}` is the resolved target as the caller's shell reaches it — `--dir` included, relative to
|
|
176
|
+
// the cwd when under it, shell-quoted (`cmd-new.ts`'s `cdTarget`). `cd {name}` entered the wrong
|
|
177
|
+
// directory, or none, whenever `--dir` put the app somewhere else.
|
|
178
|
+
'cli.new.done': 'created {name} — next: cd {cd} && bun run setup && bun run dev',
|
|
179
|
+
// `--dry-run`'s own summary: nothing landed, so "created" was a claim an agent branching on the
|
|
180
|
+
// summary would act on. `{dir}` is the resolved target, `--dir` included.
|
|
181
|
+
'cli.new.dryRun':
|
|
182
|
+
'would create {name} in {dir} — nothing written; after a real run: cd {cd} && bun run setup && bun run dev',
|
|
177
183
|
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
178
184
|
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
179
185
|
// command is a broken one — the same split `Finding.fix` already makes.
|
|
@@ -197,6 +203,8 @@ const CATALOG = {
|
|
|
197
203
|
'cli.registry.described': '{kind} {name}',
|
|
198
204
|
'cli.routes.count': '{count} routes',
|
|
199
205
|
'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
|
|
206
|
+
'cli.auth.sealed':
|
|
207
|
+
'sealed {sealed} second-factor secret(s); {already} already sealed, {skipped} changed mid-run and left alone',
|
|
200
208
|
'cli.tasks.count': '{count} task(s)',
|
|
201
209
|
'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
|
|
202
210
|
'cli.affected.count':
|
|
@@ -233,6 +241,10 @@ const CATALOG = {
|
|
|
233
241
|
'cli.shot.pageError': ' threw {message} {at}',
|
|
234
242
|
'cli.shot.picture': ' picture {path}',
|
|
235
243
|
'cli.shot.verdict': ' verdict {path}',
|
|
244
|
+
// `--viewport`: one route at several sizes. The summary is ONE size's verdict, so it says which
|
|
245
|
+
// size; each size line leads with its size and the per-size detail lines sit indented beneath.
|
|
246
|
+
'cli.shot.viewport.summary': '{size}: {summary}',
|
|
247
|
+
'cli.shot.viewport.line': '{size} {summary}',
|
|
236
248
|
// `--island`. A component capture reports per STATE, so the summary counts pictures and the
|
|
237
249
|
// lines name one state each; nothing here restates a fact `--json` does not carry.
|
|
238
250
|
'cli.shot.island.ok':
|
|
@@ -302,7 +314,7 @@ const CATALOG = {
|
|
|
302
314
|
// A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
|
|
303
315
|
// ones are named, because "which suite has nothing to run here?" is the question a green gate
|
|
304
316
|
// over a missing suite has to answer on its own line. Whole sentences per case rather than a
|
|
305
|
-
// clause the caller glues on
|
|
317
|
+
// clause the caller glues on: a catalog clause cannot know the sentence it lands in.
|
|
306
318
|
'cli.verify.passSkipped':
|
|
307
319
|
'{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
|
|
308
320
|
'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
|
|
@@ -313,6 +325,7 @@ const CATALOG = {
|
|
|
313
325
|
// tests ran. Each test's own name carries the reason it skipped, and `bun test` prints only the
|
|
314
326
|
// counts — so the line says how many and `x test <type>` prints the names.
|
|
315
327
|
'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
|
|
328
|
+
'cli.verify.warnings': '{count} warning(s) — --verbose or --json lists them',
|
|
316
329
|
// The other shape the same counts can take: a suite that reported no test at all, skipped ones
|
|
317
330
|
// included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
|
|
318
331
|
// no skipped test to read a reason off, so the files the step selected hold no test.
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
finiteCount,
|
|
8
|
+
type HealthPayload,
|
|
9
|
+
healthzPayload,
|
|
8
10
|
logger,
|
|
9
11
|
METRICS_CONTENT_TYPE,
|
|
10
12
|
METRICS_PATH,
|
|
11
13
|
markListening,
|
|
12
14
|
metricsText,
|
|
15
|
+
readyzPayload,
|
|
13
16
|
startProcessMetrics,
|
|
14
17
|
stringField,
|
|
15
18
|
UltimateError,
|
|
16
19
|
} from '@ultimat3/core';
|
|
17
20
|
import { neighbouringPort } from './flag-number';
|
|
21
|
+
import { quoteArg } from './shell-quote';
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
@@ -45,7 +49,7 @@ export class MetricsPortInUseError extends UltimateError {
|
|
|
45
49
|
super({
|
|
46
50
|
code: 'X_PORT_IN_USE',
|
|
47
51
|
cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
|
|
48
|
-
fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
|
|
52
|
+
fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(input.port)))} x dev --json`,
|
|
49
53
|
});
|
|
50
54
|
}
|
|
51
55
|
}
|
|
@@ -111,7 +115,7 @@ export function bindScrapePort<T>(
|
|
|
111
115
|
logger.warn('ultimate metrics port taken', {
|
|
112
116
|
port,
|
|
113
117
|
cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
|
|
114
|
-
fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
|
|
118
|
+
fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(port)))} x dev --json`,
|
|
115
119
|
});
|
|
116
120
|
return bind(0);
|
|
117
121
|
}
|
|
@@ -123,6 +127,18 @@ export interface MetricsEndpoint {
|
|
|
123
127
|
stop(): void;
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/**
|
|
131
|
+
* `/healthz` and `/readyz` for the roles whose only socket this is — `worker`, `scheduler`,
|
|
132
|
+
* `replicator` — so the replicator's readiness check (`role-replicator.ts`) is read by a probe.
|
|
133
|
+
* The verdict only, never the report: this port is unauthenticated, and check names are topology.
|
|
134
|
+
*/
|
|
135
|
+
function healthResponse(payload: HealthPayload, role: string): Response {
|
|
136
|
+
return Response.json(
|
|
137
|
+
{ state: payload.body.state, ready: payload.body.ready, role },
|
|
138
|
+
{ status: payload.status, headers: { 'cache-control': 'no-store' } },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
/**
|
|
127
143
|
* Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
|
|
128
144
|
* `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
|
|
@@ -142,7 +158,16 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
|
|
|
142
158
|
port: candidate,
|
|
143
159
|
hostname: options.hostname ?? 'localhost',
|
|
144
160
|
fetch(request: Request): Response {
|
|
145
|
-
|
|
161
|
+
const url = new URL(request.url);
|
|
162
|
+
const role = options.role ?? 'unknown';
|
|
163
|
+
if (url.pathname === '/healthz') return healthResponse(healthzPayload(), role);
|
|
164
|
+
if (url.pathname === '/readyz') {
|
|
165
|
+
return healthResponse(
|
|
166
|
+
readyzPayload({ deep: url.searchParams.get('deep') === '1' }),
|
|
167
|
+
role,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (url.pathname !== METRICS_PATH) {
|
|
146
171
|
return new Response('not found', { status: 404 });
|
|
147
172
|
}
|
|
148
173
|
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
|
@@ -24,6 +24,6 @@ export function migrationExtensions(migrations: readonly Migration[]): readonly
|
|
|
24
24
|
return [...names].sort();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/** The same list for an app on disk — what an embedded boot hands `
|
|
27
|
+
/** The same list for an app on disk — what an embedded boot hands `pgliteClient`. */
|
|
28
28
|
export const appExtensions = async (root: string): Promise<readonly string[]> =>
|
|
29
29
|
migrationExtensions(await readMigrations(root));
|
package/src/otlp-export.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
|
36
36
|
* merge into process-global state, so stopping the timer and dropping the drain hooks left the
|
|
37
37
|
* first boot's exporter configured: a second `serveApp` in the same process exported its spans
|
|
38
38
|
* into a released exporter — queued against a collector nothing will flush to, on a timer nothing
|
|
39
|
-
* clears. `
|
|
39
|
+
* clears. `dev-boot.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
|
|
40
40
|
* never unconditionally: `x dev` configures a trace RECORDER before calling this, and a boot that
|
|
41
41
|
* installed no exporter must not uninstall one it never owned.
|
|
42
42
|
*/
|
|
@@ -52,7 +52,7 @@ export function startOtlpExport(env: Env = process.env): () => void {
|
|
|
52
52
|
const exporter = otlpSpanExporter({ endpoint: traces });
|
|
53
53
|
configureTelemetry({ exporter });
|
|
54
54
|
// Pushed first, so the reversed run below applies it LAST — after the drain hook is dropped,
|
|
55
|
-
// the same order `
|
|
55
|
+
// the same order `dev-boot.ts` releases the recorder in.
|
|
56
56
|
releases.push(() => configureTelemetry({ exporter: noopExporter }));
|
|
57
57
|
releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
|
|
58
58
|
logger.info('ultimate otlp traces', { endpoint: traces });
|
package/src/output.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface Finding {
|
|
|
29
29
|
* and must never be run as this CLI's own instruction.
|
|
30
30
|
*/
|
|
31
31
|
readonly source?: 'ci-log';
|
|
32
|
+
/**
|
|
33
|
+
* Structured facts behind `cause`, for a `--json` reader that would otherwise parse the sentence:
|
|
34
|
+
* `X_VERIFY_STEP_TIMEOUT` carries the processes it killed and the files still in flight. Never
|
|
35
|
+
* the only home of a fact — the human render prints `cause` and `fix`, and both already say it.
|
|
36
|
+
*/
|
|
37
|
+
readonly meta?: { readonly [key: string]: JsonValue };
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface StepResult {
|
|
@@ -44,6 +50,12 @@ export interface StepResult {
|
|
|
44
50
|
* coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
|
|
45
51
|
*/
|
|
46
52
|
readonly output?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Advice that does not fail the step — a file inside `filesize`'s band below the ceiling. Unlike
|
|
55
|
+
* `output`, carried by both renderers on a PASS too: advice a green run hides reaches nobody
|
|
56
|
+
* until it has become the failure it warns about. One line of text per warning.
|
|
57
|
+
*/
|
|
58
|
+
readonly warnings?: readonly string[];
|
|
47
59
|
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
48
60
|
readonly workers?: number;
|
|
49
61
|
/** Why the width is what it is — `4 workers (budget 4.0 GB)`. */
|
|
@@ -224,6 +236,14 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
224
236
|
` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}${why(step)}`,
|
|
225
237
|
);
|
|
226
238
|
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
239
|
+
// Escaped like every other line text reaches fd 1 through: a warning names a file path. A
|
|
240
|
+
// green terminal run shows the count, not the list: `filesize`'s band alone is ~150 lines,
|
|
241
|
+
// and a wall of advice on every run is read by nobody. `--json` always carries the list.
|
|
242
|
+
const warnings = step.warnings ?? [];
|
|
243
|
+
if (verbose) for (const warning of warnings) out.push(` ! ${singleLine(warning)}`);
|
|
244
|
+
else if (warnings.length > 0) {
|
|
245
|
+
out.push(` ! ${msg('cli.verify.warnings', { count: warnings.length })}`);
|
|
246
|
+
}
|
|
227
247
|
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
228
248
|
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
229
249
|
// already split on its real newlines rather than carrying them inside one entry.
|
|
@@ -250,6 +270,10 @@ export function renderJson(result: CommandResult, verbose = false): string {
|
|
|
250
270
|
durationMs: step.durationMs,
|
|
251
271
|
skipped: step.skipped === true,
|
|
252
272
|
findings: step.findings,
|
|
273
|
+
// Never gated on `ok` or `--verbose`, unlike `output` below: see `StepResult.warnings`.
|
|
274
|
+
...(step.warnings === undefined || step.warnings.length === 0
|
|
275
|
+
? {}
|
|
276
|
+
: { warnings: [...step.warnings] }),
|
|
253
277
|
...(step.workers === undefined ? {} : { workers: step.workers }),
|
|
254
278
|
...(step.widthReason === undefined ? {} : { widthReason: step.widthReason }),
|
|
255
279
|
...(step.shard === undefined
|
package/src/page-navigation.ts
CHANGED
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
// check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
|
|
7
7
|
// `site/` page that did not ask stays 0kb (axiom 6).
|
|
8
8
|
|
|
9
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
10
|
-
// necessity `theme-boot.ts` records.
|
|
11
|
-
import { join } from 'node:path';
|
|
12
9
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
13
10
|
import type { Ctx, NavigationSurface } from '@ultimat3/core';
|
|
14
|
-
import { CLIENT_NAVIGATION_SCOPE_HEADER
|
|
11
|
+
import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
|
|
15
12
|
import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
|
|
16
13
|
import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
|
|
17
14
|
import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
|
|
@@ -22,8 +19,7 @@ import {
|
|
|
22
19
|
routeEntries,
|
|
23
20
|
} from '@ultimat3/render';
|
|
24
21
|
import { ssrHeaders } from '@ultimat3/render/server';
|
|
25
|
-
import {
|
|
26
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
22
|
+
import { loadAppConfig } from './app-config-load';
|
|
27
23
|
import { FrameworkScriptBuildFailedError } from './errors';
|
|
28
24
|
import {
|
|
29
25
|
buildNavigationScript,
|
|
@@ -56,26 +52,14 @@ export interface NavigationDeclaration {
|
|
|
56
52
|
readonly surfaces: readonly NavigationSurface[];
|
|
57
53
|
}
|
|
58
54
|
|
|
59
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
60
|
-
typeof value === 'object' && value !== null;
|
|
61
|
-
|
|
62
|
-
const isSurface = (value: unknown): value is NavigationSurface =>
|
|
63
|
-
NAVIGATION_SURFACES.some((surface) => surface === value);
|
|
64
|
-
|
|
65
55
|
/**
|
|
66
|
-
* `navigation.client` and `name`,
|
|
67
|
-
*
|
|
56
|
+
* `navigation.client` and `name`, off the one loader (`app-config-load.ts`) — no surfaces, and the
|
|
57
|
+
* app called `app`, for a root with no config file.
|
|
68
58
|
*/
|
|
69
59
|
export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
74
|
-
if (!isRecord(config)) return { app: 'app', surfaces: [] };
|
|
75
|
-
const app = typeof config['name'] === 'string' ? config['name'] : 'app';
|
|
76
|
-
const navigation = config['navigation'];
|
|
77
|
-
const client = isRecord(navigation) ? navigation['client'] : undefined;
|
|
78
|
-
return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
|
|
60
|
+
const config = await loadAppConfig(root);
|
|
61
|
+
if (config === undefined) return { app: 'app', surfaces: [] };
|
|
62
|
+
return { app: config.name, surfaces: config.navigation.client };
|
|
79
63
|
}
|
|
80
64
|
|
|
81
65
|
/**
|
package/src/page-speculation.ts
CHANGED
|
@@ -5,42 +5,27 @@
|
|
|
5
5
|
// the next page's document on pointer rest, so a full-page navigation between prerendered pages
|
|
6
6
|
// paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
|
|
7
7
|
|
|
8
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
9
|
-
// necessity `theme-boot.ts` records.
|
|
10
|
-
import { join } from 'node:path';
|
|
11
8
|
import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
|
|
12
|
-
import { DEFAULT_SPECULATION, localeSegment
|
|
9
|
+
import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
|
|
13
10
|
import { cspHashSource } from '@ultimat3/http';
|
|
14
|
-
import {
|
|
11
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
15
12
|
import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
|
|
16
13
|
import {
|
|
17
|
-
|
|
14
|
+
describePages,
|
|
18
15
|
renderHead,
|
|
19
16
|
routeEntries,
|
|
20
17
|
speculationPattern,
|
|
21
18
|
speculationRulesBody,
|
|
22
19
|
speculationRulesTag,
|
|
23
20
|
} from '@ultimat3/render';
|
|
24
|
-
import {
|
|
25
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
26
|
-
|
|
27
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
-
typeof value === 'object' && value !== null;
|
|
21
|
+
import { loadAppConfig } from './app-config-load';
|
|
29
22
|
|
|
30
23
|
/**
|
|
31
|
-
* `navigation.speculation`
|
|
32
|
-
*
|
|
33
|
-
* validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
|
|
34
|
-
* REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
|
|
35
|
-
* quietly served as `'moderate'` is a rule the app did not write.
|
|
24
|
+
* `navigation.speculation` off the one loader (`app-config-load.ts`), which holds it to core's
|
|
25
|
+
* `resolveSpeculation` — the default (on, `'moderate'`) for a root with no config file.
|
|
36
26
|
*/
|
|
37
27
|
export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
|
|
38
|
-
|
|
39
|
-
if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
|
|
40
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
41
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
42
|
-
const navigation = isRecord(config) ? config['navigation'] : undefined;
|
|
43
|
-
return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
|
|
28
|
+
return (await loadAppConfig(root))?.navigation.speculation ?? DEFAULT_SPECULATION;
|
|
44
29
|
}
|
|
45
30
|
|
|
46
31
|
/**
|
|
@@ -82,15 +67,18 @@ export interface PageSpeculationInput {
|
|
|
82
67
|
readonly client: readonly NavigationSurface[];
|
|
83
68
|
readonly entries?: readonly RouteEntry[];
|
|
84
69
|
readonly described?: readonly RouteDescriptor[];
|
|
85
|
-
/**
|
|
86
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The NON-default routed locales' URL segments — `otherLocaleSegments(appLocaleSet)`, the set the
|
|
72
|
+
* manifest, the worker and the prerender read. Every caller passes it: no build reads the
|
|
73
|
+
* ambient locale config.
|
|
74
|
+
*/
|
|
75
|
+
readonly localeSegments: readonly string[];
|
|
87
76
|
}
|
|
88
77
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.filter((segment) => segment !== fallback);
|
|
78
|
+
/** Every routed locale but the default, as the URL segment a prefix is spelled in. */
|
|
79
|
+
export const otherLocaleSegments = (set: AppLocaleSet): readonly string[] => {
|
|
80
|
+
const fallback = localeSegment(set.defaultLocale);
|
|
81
|
+
return set.locales.map(localeSegment).filter((segment) => segment !== fallback);
|
|
94
82
|
};
|
|
95
83
|
|
|
96
84
|
/**
|
|
@@ -101,9 +89,9 @@ const otherLocaleSegments = (): readonly string[] => {
|
|
|
101
89
|
export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
|
|
102
90
|
if (input.config.prefetch === false) return undefined;
|
|
103
91
|
const entries = input.entries ?? routeEntries();
|
|
104
|
-
const described = new Map((input.described ??
|
|
92
|
+
const described = new Map((input.described ?? describePages()).map((one) => [one.file, one]));
|
|
105
93
|
const client = new Set<string>(input.client);
|
|
106
|
-
const segments = input.localeSegments
|
|
94
|
+
const segments = input.localeSegments;
|
|
107
95
|
const include = entries
|
|
108
96
|
.filter((entry) => {
|
|
109
97
|
const facts = described.get(entry.file);
|
package/src/parse.ts
CHANGED
|
@@ -8,14 +8,6 @@ import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './err
|
|
|
8
8
|
// anywhere while still refusing with a fix line a shell reads as one argument.
|
|
9
9
|
import { quoteArg } from './shell-quote';
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
|
|
13
|
-
* package's exported surface and removing it would be a major for a rename. One implementation
|
|
14
|
-
* behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
|
|
15
|
-
* used to carry. New callers import `nearestName` from core.
|
|
16
|
-
*/
|
|
17
|
-
export const nearest = nearestName;
|
|
18
|
-
|
|
19
11
|
export type FlagValue = string | boolean;
|
|
20
12
|
|
|
21
13
|
export interface FlagSpec {
|
package/src/permission-grants.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { listActions } from '@ultimat3/action';
|
|
|
7
7
|
import type { Policy } from '@ultimat3/policy';
|
|
8
8
|
import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
|
|
9
9
|
import { listQueries } from '@ultimat3/query';
|
|
10
|
-
import {
|
|
10
|
+
import { describePages, routeEntries } from '@ultimat3/render';
|
|
11
11
|
import type { Finding } from './output';
|
|
12
12
|
import { quoteArg } from './shell-quote';
|
|
13
13
|
|
|
@@ -46,12 +46,12 @@ export function requirements(): readonly UngrantedRequirement[] {
|
|
|
46
46
|
// A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
|
|
47
47
|
// screen decides on every permission of the mount, not only the coarse gate its route config
|
|
48
48
|
// carries, so each one is a requirement: a hand-written entity nobody granted `<table>:read`
|
|
49
|
-
// for was a green gate and a 403 on its first open. `
|
|
49
|
+
// for was a green gate and a 403 on its first open. `describePages()` is memoized by the
|
|
50
50
|
// registry, so this loop reads a list every other step already built.
|
|
51
51
|
// One requirement per permission per mount, naming the first route that asks it: `posts:write`
|
|
52
52
|
// gates the create form and the edit form, and one missing grant is one finding.
|
|
53
53
|
const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
|
|
54
|
-
for (const route of
|
|
54
|
+
for (const route of describePages()) {
|
|
55
55
|
if (route.mount === undefined) continue;
|
|
56
56
|
for (const permission of route.mount.permissions) {
|
|
57
57
|
const key = `${route.mount.by} ${permission}`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// One answer to "this host path, written with `/`". `relative()` and `Bun.Glob` answer in the
|
|
2
|
+
// host's separator, so on Windows a `\` reached an import specifier (`x fix` wrote
|
|
3
|
+
// `import '..\\foo'`, which breaks every Linux teammate), a fix line and a sort key. Every such
|
|
4
|
+
// result passes through here — `bun run posix-relative` refuses a `relative(` that does not.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; `node:path` owns `relative`, and a test hands in its `win32` half to
|
|
7
|
+
// prove the Windows answer on a Linux runner.
|
|
8
|
+
import * as hostPath from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one method `posixRelative` asks of a path module — structural, not `PlatformPath`: the
|
|
12
|
+
* scaffold's typecheck resolves `node:path` without that name exported (TS2305).
|
|
13
|
+
*/
|
|
14
|
+
export interface RelativeHost {
|
|
15
|
+
readonly relative: (from: string, to: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `path` with every `\` written as `/`. Unconditional rather than keyed to the host's `sep`: a key,
|
|
20
|
+
* a specifier and a fix line are POSIX on every host, and a Windows-shaped path handed to a Linux
|
|
21
|
+
* process (a CI log, a test fixture) must come out the same as it would on Windows.
|
|
22
|
+
*/
|
|
23
|
+
export function toPosix(path: string): string {
|
|
24
|
+
return path.replaceAll('\\', '/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `relative(from, to)` in POSIX form. `host` is the platform's own path module but for a test. */
|
|
28
|
+
export function posixRelative(from: string, to: string, host: RelativeHost = hostPath): string {
|
|
29
|
+
return toPosix(host.relative(from, to));
|
|
30
|
+
}
|
package/src/prerender-locales.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// paths and files `renderStatic` computed; every other locale's are served at `/<locale>/<path>`
|
|
3
3
|
// and written to `<locale>/<file>` — the layout every static host resolves with no rewrite rule.
|
|
4
4
|
|
|
5
|
-
import { localeSegment,
|
|
5
|
+
import { localeSegment, localizePath } from '@ultimat3/core';
|
|
6
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
6
7
|
import type { StaticArtifact } from '@ultimat3/render/server';
|
|
7
8
|
|
|
8
9
|
/** A `StaticArtifact` placed for one locale: its served path and its file under `<locale>/`. */
|
|
@@ -10,8 +11,7 @@ export type LocalizedArtifact = StaticArtifact & { readonly locale: string };
|
|
|
10
11
|
|
|
11
12
|
/** The default's artifacts first, so a report lists the unprefixed page before its translations. */
|
|
12
13
|
export async function localizedArtifacts(
|
|
13
|
-
locales:
|
|
14
|
-
defaultLocale: string,
|
|
14
|
+
{ locales, defaultLocale }: AppLocaleSet,
|
|
15
15
|
renderIn: (locale: string) => Promise<readonly StaticArtifact[]>,
|
|
16
16
|
): Promise<readonly LocalizedArtifact[]> {
|
|
17
17
|
const out: LocalizedArtifact[] = [];
|
|
@@ -21,7 +21,9 @@ export async function localizedArtifacts(
|
|
|
21
21
|
out.push({
|
|
22
22
|
...artifact,
|
|
23
23
|
locale,
|
|
24
|
-
path: isDefault
|
|
24
|
+
path: isDefault
|
|
25
|
+
? artifact.path
|
|
26
|
+
: localizePath(artifact.path, locale, locales, defaultLocale),
|
|
25
27
|
outputPath: isDefault
|
|
26
28
|
? artifact.outputPath
|
|
27
29
|
: `${localeSegment(locale)}/${artifact.outputPath}`,
|
package/src/prerender-out.ts
CHANGED
|
@@ -1,25 +1,52 @@
|
|
|
1
1
|
// The export directory is the build's own, and emptied before every build: `prerenderSite` only
|
|
2
2
|
// ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
|
|
3
|
-
//
|
|
3
|
+
// Emptied only when it is provably the build's: absent, empty, or marked by an earlier build —
|
|
4
|
+
// `--out apps` deleted the app's pages and `--out .x` its embedded dev database.
|
|
4
5
|
|
|
5
|
-
import { rm } from 'node:fs/promises'; // why: Bun has no recursive
|
|
6
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
6
|
+
import { lstat, readdir, rm } from 'node:fs/promises'; // why: Bun has no recursive delete or readdir.
|
|
7
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
|
|
7
8
|
import { UltimateError } from '@ultimat3/core';
|
|
8
9
|
|
|
10
|
+
/** Written into every export directory a build empties: the proof the next build may empty it. */
|
|
11
|
+
export const EXPORT_MARKER = '.x-export';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The export directory `x build --target static` chooses itself. Exempt from the marker: a build
|
|
15
|
+
* from before the marker existed left it full and unmarked, and the build owns this path by name.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_EXPORT = join('.x', 'static');
|
|
18
|
+
|
|
19
|
+
const MARKER_TEXT =
|
|
20
|
+
'Written by x build --target static. This directory is emptied before every static build.\n';
|
|
21
|
+
|
|
9
22
|
/** True when `dir` is `root` itself or one of its ancestors. */
|
|
10
23
|
const containsRoot = (dir: string, root: string): boolean => {
|
|
11
24
|
const up = relative(resolve(dir), resolve(root));
|
|
12
25
|
return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
|
|
13
26
|
};
|
|
14
27
|
|
|
15
|
-
/**
|
|
28
|
+
/** Why `out` is not the build's to empty, or `undefined` when it is. */
|
|
29
|
+
async function unsafeReason(out: string, root: string): Promise<string | undefined> {
|
|
30
|
+
if (containsRoot(out, root)) return 'is the app root or holds it';
|
|
31
|
+
const stat = await lstat(out).catch(() => undefined);
|
|
32
|
+
if (stat === undefined) return undefined;
|
|
33
|
+
if (!stat.isDirectory()) return 'is not a directory';
|
|
34
|
+
const entries = await readdir(out);
|
|
35
|
+
if (entries.length === 0 || entries.includes(EXPORT_MARKER)) return undefined;
|
|
36
|
+
if (resolve(out) === resolve(root, DEFAULT_EXPORT)) return undefined;
|
|
37
|
+
return `already holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} and no ${EXPORT_MARKER} a previous build wrote`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Empties `out` for a fresh export and marks it, or refuses a directory the build did not write. */
|
|
16
41
|
export async function clearPrerenderOut(out: string, root: string): Promise<void> {
|
|
17
|
-
|
|
42
|
+
const reason = await unsafeReason(out, root);
|
|
43
|
+
if (reason !== undefined) {
|
|
18
44
|
throw new UltimateError({
|
|
19
|
-
code: '
|
|
20
|
-
cause: `the static export directory ${resolve(out)}
|
|
45
|
+
code: 'X_BUILD_OUT_UNSAFE',
|
|
46
|
+
cause: `the static export directory ${resolve(out)} ${reason}, and every build empties its export directory first`,
|
|
21
47
|
fix: 'x build --target static --out .x/static --json',
|
|
22
48
|
});
|
|
23
49
|
}
|
|
24
50
|
await rm(out, { recursive: true, force: true });
|
|
51
|
+
await Bun.write(join(out, EXPORT_MARKER), MARKER_TEXT);
|
|
25
52
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What `x build --target static` reports: every page it wrote, every route it did not and why, and
|
|
2
|
+
// where the stats and the inventory landed. Split from `prerender.ts`, which decides what is
|
|
3
|
+
// written; this file only names the shapes the report carries.
|
|
4
|
+
|
|
5
|
+
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
6
|
+
|
|
7
|
+
export interface PrerenderedPage {
|
|
8
|
+
/** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
|
|
9
|
+
readonly route: string;
|
|
10
|
+
/** The URL the page is served at — `/en/pricing` for a non-default locale. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
/** The locale the page was rendered in. */
|
|
13
|
+
readonly locale: string;
|
|
14
|
+
/** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
|
|
15
|
+
readonly file: string;
|
|
16
|
+
readonly hash: string;
|
|
17
|
+
readonly bytes: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PrerenderReport {
|
|
21
|
+
readonly out: string;
|
|
22
|
+
readonly buildId: string;
|
|
23
|
+
readonly pages: readonly PrerenderedPage[];
|
|
24
|
+
/**
|
|
25
|
+
* Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
|
|
26
|
+
* #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
|
|
27
|
+
* screenshot tool pointed at the directory filed "the island did not mount" against a route that
|
|
28
|
+
* had never been in the artifact. The reason is what tells an author whether an edit exists.
|
|
29
|
+
*/
|
|
30
|
+
readonly skipped: readonly SkippedRoute[];
|
|
31
|
+
/**
|
|
32
|
+
* Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
|
|
33
|
+
* the gate then reports for each; this is the half that says WHY, which a per-route finding read
|
|
34
|
+
* off a stats file cannot know.
|
|
35
|
+
*/
|
|
36
|
+
readonly unmeasured: readonly UnmeasuredRoute[];
|
|
37
|
+
/** Where the measured stats landed, for the `budgets` gate step to read. */
|
|
38
|
+
readonly stats: string;
|
|
39
|
+
/** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
|
|
40
|
+
readonly report: string;
|
|
41
|
+
/** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
|
|
42
|
+
readonly islands: readonly string[];
|
|
43
|
+
/** Surface stylesheets emitted, one file each — the CSS half of the same question. */
|
|
44
|
+
readonly styles: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* What the service worker could not express, and what its precache manifest weighs too much of.
|
|
47
|
+
*
|
|
48
|
+
* `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
|
|
49
|
+
* `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
|
|
50
|
+
* that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
|
|
51
|
+
* has to reach the build's own report. Empty for an app with no service worker.
|
|
52
|
+
*/
|
|
53
|
+
readonly serviceWorkerWarnings: readonly string[];
|
|
54
|
+
/** What this build could not get right on its own — today, a production build with no origin. */
|
|
55
|
+
readonly warnings: readonly string[];
|
|
56
|
+
/** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
|
|
57
|
+
readonly origin: string;
|
|
58
|
+
}
|