@ultimat3/cli 24.0.0 → 25.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.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
package/CLAUDE.md CHANGED
@@ -52,14 +52,14 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
52
52
  | `guards.ts` / `guard-sources.ts` | an app's `guards/*.ts`, discovered, run on `boundaries`, held to the error contract; every `check(root, sources)` shares ONE walk, read and Sass compile per file (`GuardSources`) |
53
53
  | `templates/scaffold-guards.ts` | `SHIPPED` — the one list of shipped guards: `x new` writes all, `x g guard <name>` writes one, `doctor-guards.ts` lists the ones an app lacks (a listing, never a finding). A new one is a `templates/guard-<name>.ts` + a row + `guard-<name>.test.ts`; `scaffold-guards-style.test.ts` holds embedded scales to `@ultimat3/ui`, the framework's own sheets to the rules, and both tracked apps byte-equal to the templates |
54
54
  | `error-contract.ts` / `ts-scan.ts` / `fix-scan.ts` / `fix-imports.ts` / `fix-command.ts` / `fix-path.ts` | the `errors` step: every `fix:` names a runnable command, call or existing file |
55
- | `workspace-checks.ts` / `workspace-graph.ts` / `tsconfig-references.ts` / `publish-closure.ts` | `package-shape`, `filesize`. A published package's `files` negates `*.test.ts` and `*-fixture.ts`, and no entry point (`exports`, `bin`) may reach a `-fixture.ts` — `X_PACKAGE_SHAPE` names the file to rename |
55
+ | `workspace-checks.ts` / `test-only-shipped.ts` / `workspace-graph.ts` / `tsconfig-references.ts` / `publish-closure.ts` | `package-shape`, `filesize`. A published package's `files` negates `*.test.ts` and `*-fixture.ts`, and no entry point (`exports`, `bin`) may reach a `-fixture.ts` — `X_PACKAGE_SHAPE` names the file to rename; a `src/` module only tests import must be `*-fixture.ts` (`X_PACKAGE_TEST_ONLY_SHIPPED`) |
56
56
  | `app-permissions.ts` / `permission-grants.ts` / `app-permissions-borrowed.ts` / `app-permissions-site.ts` | `policy`: every permission granted or required is declared, every one required is granted by some role (`X_PERMISSION_UNGRANTED`), and every one an app rule requires is declared by an app module, not only by a package (`X_PERMISSION_BORROWED`, read off `permissionDeclarationSites()`) |
57
57
  | `job-registration.ts` | `manifest`: no job or task under a positional `anonymous-*` name (`X_JOB_UNREGISTERED`) |
58
58
  | `unscanned-admin.ts` | `manifest`: a `defineAdmin()` outside the app scan (`apps/*/src/`) is `X_ADMIN_UNSCANNED`, with the `git mv` that mounts it |
59
59
  | `verify-role-load.ts` / `verify-role-load-probe.ts` | `manifest`: a child imports the app as a worker does, then the rest module by module; a registration only a document-reaching module makes is `X_ROLE_LOAD_INCOMPLETE` with the module and the import the API index lacks |
60
- | `async-pages.ts` / `live-routes.ts` / `budgets.ts` | `budgets` riders: an async `Page` with no `load`, a live read no island imports, bytes per route |
61
- | `app-agents-md.ts` / `app-env.ts` / `app-openapi.ts` / `app-manifest.ts` | `manifest` and `contract-diff` |
62
- | `schema-drift.ts` / `drift.ts` / `drift-replica-identity.ts` / `db-destructive.ts` / `db-ungeneratable.ts` | `drift`: snapshot vs declarations (`REPLICA IDENTITY FULL` a params channel or `subscribes:` needs included), the source hash (core's `canonicalJson`), the header markers |
60
+ | `async-pages.ts` / `live-routes.ts` / `budgets.ts` / `budgets-unmeasured.ts` | `budgets` riders: an async `Page` with no `load`, a live read no island imports, bytes per route; an unweighed route's finding names a refused measurement actor and `defineMeasurementActor()` |
61
+ | `app-agents-md.ts` / `app-env.ts` / `app-openapi.ts` / `app-manifest.ts` | `manifest` and `contract-diff`. `framework-env.ts` is the ONE list of framework-required deploy secrets `.env.example`, `x env check` and `x doctor` read |
62
+ | `schema-drift.ts` / `drift.ts` / `drift-replica-identity.ts` / `db-destructive.ts` / `db-ungeneratable.ts` | `drift`: snapshot vs declarations (`REPLICA IDENTITY FULL` a params channel needs included — never a `subscribes:` table, #518), the source hash (core's `canonicalJson`), the header markers |
63
63
  | `schema-dump-drift.ts` | `drift`'s fourth rail, the only one that boots a database: committed `packages/db/schema/` vs a scratch replay, and that dump loaded back (`X_SCHEMA_DUMP_DRIFT`) |
64
64
  | `i18n-registration.ts` / `i18n-audit.ts` / `admin-catalog-keys.ts` | `i18n`; a mounted admin's keys are `AdminApp.catalogKeys()`, asked of every app catalog — never re-derived here (`X_CATALOG_MISSING_KEYS`) |
65
65
  | `error-unthrown.ts` / `unthrown-codes.ts` | host check: a registered code nothing throws is listed in `UNTHROWN_CODES` (a row's wording waives nothing); a listed code thrown again is `X_ERROR_CODE_UNTHROWN_STALE` |
@@ -70,7 +70,10 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
70
70
  |---|---|
71
71
  | `cmd-generate.ts` / `generate-files.ts` / `generate-kinds.ts` / `generate-write.ts` | argv → pure file list → writes (conflict-checked, `merge: 'json'` / `'if-absent'`) |
72
72
  | `generate-feature.ts` | a `--feature` naming no slice is `X_FEATURE_UNKNOWN`; nothing invents an entity |
73
- | `generate-shadow.ts` | a name whose type spelling a planned file also uses as a global or local type (`promise`, `row`) is `X_CLI_BAD_FLAG`, decided on the planned files |
73
+ | `generate-shadow.ts` | a name whose type spelling a planned file also uses as a global or local type (`promise`, `row`), or whose camel VALUE a planned file both imports and declares (`x g job job`, TS2440), is `X_CLI_BAD_FLAG`, decided on the planned files |
74
+ | `generate-plural.ts` | a planned `entity.ts` in an already-plural slice (`posts` → `postses`) is `X_CLI_BAD_FLAG`; the fix names the singular |
75
+ | `generate-catalog-locales.ts` | `--locales` never starts a catalog in an app with any: a missing non-default locale is `X_CLI_BAD_FLAG`, `fix: x i18n add <locale>`; non-default values land `⟦…⟧`-marked (`seedCatalog`) |
76
+ | `app-name.ts` | `x new`'s slug is asked of core's `defineConfig`, never a copied pattern; refused with a suggested name (`9lives` → `app-9lives`, `a` → `a-app`, cut at 64) |
74
77
  | `generate-grants.ts` | a written `policy.ts` grants `:read` to `member`, `:write` to `admin` in `apps/web/shared/roles.ts`; a written `entity.ts` declares its table's `<table>:read\|write\|delete` there and grants all three to `admin` — what the admin asks for the screen that entity just became |
75
78
  | `api-registration.ts` | a written job or task is listed in `apps/web/api/index.ts` |
76
79
  | `handle-registration.ts` / `workspace-dep-edit.ts` / `templates/scaffold-db-client.ts` | a written entity joins `const entities = { … }` in `packages/db/src/client.ts` (the typed handle every generated `repo.ts` reads), with the two manifest lines the new imports need; no anchor is `X_DB_HANDLE_UNREGISTERED` carrying the lines. `cmd-generate.ts`'s `nextSteps` prints what the new table owes |
@@ -86,18 +89,21 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
86
89
  | `serve.ts` / `serve-boot.ts` / `serve-entry.ts` / `serve-env.ts` | the production entry: roles from `ROLE`, bindings from env, `ROLE=migrate` = `x db migrate`. `serve-boot.ts` (services + roles) is ONE `await import()` in `serveApp`, so `migrate` loads neither |
87
90
  | `role-load.ts` / `document-graph.ts` / `serve-web.ts` | a role imports what it runs: `ROLE_LOADS` (declared over `Role`) — `worker`/`scheduler` import the API index and every module reaching no `.tsx`/stylesheet, verified against `x.manifest.json`'s jobs and tasks (`X_ROLE_LOAD_INCOMPLETE` imports everything); `serve-web.ts` is the web surface, behind `await import()`. `scanAppModules(root, { track: false })` is the container's scan — no reload graph, no error-code walk. Per-role module pins and `NEVER_AT_BOOT`: `serve-graph.test.ts` |
88
91
  | `module-imports.ts` | the files a module's source imports, resolved once for both graphs: `referenced` (reload) and `evaluated` (document) |
89
- | `cmd-dev.ts` / `dev-route-table.ts` | `x dev`: every role in one process, `/_x`, the watcher |
92
+ | `cmd-dev.ts` / `dev-boot.ts` / `dev-rebuild.ts` / `dev-route-table.ts` | `x dev`: the command (`cmd-dev.ts`, re-exports `startDev`); the boot — every role in one process, `/_x`, the watcher (`dev-boot.ts`); one save rebuilt as one generation (`dev-rebuild.ts`) |
90
93
  | `runtime-bindings.ts` | which service each binding points at (embedded or external); events follow `realtime.transport` / `urlEnv` |
91
94
  | `runtime-queue.ts` / `runtime-services.ts` | the db + queue pair; everything else and every ambient accessor |
92
95
  | `framework-schema-apply.ts` / `framework-schema-stamp.ts` | the framework tables: `ROLE=migrate`, `x dev` and every CLI command APPLY them in one transaction behind `MIGRATION_LOCK_KEY` (`pg_advisory_xact_lock`) with a bounded `lock_timeout`, then stamp the build on `x_jobs`' table comment; a serving role on an external database runs no DDL and VERIFIES the stamp (`X_FRAMEWORK_SCHEMA_UNAPPLIED`, a major skew is a warning). A comment, never a table: a new framework table moves every app's schema dump |
93
96
  | `runtime-isr.ts` | the ISR controller both boots serve through, ATTACHED to `invalidateTags` and released on stop (`attachedIsr`); `appRoutes` without one builds and attaches its own |
94
97
  | `runtime-mfa-warning.ts` / `runtime-idempotency-scope.ts` | boot warnings an operator can act on: unsealed MFA secrets (web role, `x auth seal-mfa`), a per-process idempotency store after the app loaded |
95
98
  | `app-config-load.ts` | the ONE reader of an app's `app.config.ts`: `loadAppConfig(root)` — the `config` export through core's `defineConfig`, so validated and default-merged; `undefined` with no file; no `config` object is `X_CONFIG_INVALID`. Every boot fact (`app-auth.ts`, `theme-boot.ts`, `site-config.ts`, `pwa-artifacts.ts`, …) reads a section off it, and `startServices` loads it once. An `import(` of the config module anywhere else is `X_CONFIG_IMPORT_OUTSIDE_LOADER` (`scripts/lib/config-import.ts`) |
99
+ | `theme-brand.ts` | the ONE reader of an app's theme: `export const brand` in `apps/web/shared/theme.ts` (ui's `defineTheme`), inlined after the surface stylesheet with its hash in `style-src`; none → nothing emitted |
96
100
  | `runtime-jobs.ts` / `runtime-realtime.ts` / `runtime-notify-retention.ts` / `runtime-cache.ts` / `runtime-purge.ts` / `runtime-replica.ts` | `app.config.ts` sections the boot obeys (`*Of(config)` over the one loaded config), and what each wires |
101
+ | `runtime-jobs.ts` (queues) | union of `jobs.queues` + registered queues; `WORKER_QUEUES` set = the EXACT set, `jobs.worker.queue-unserved` names the rest. `jobs.concurrency` passes as written |
102
+ | `serve-retire.ts` / `dev-restart-retire.ts` | the worker retire — `stop()`: claim nothing, abort nothing — on SIGUSR2 under `runRole` (armed before boot, then drain + exit 0; none on win32; the chart's `roles.worker.retireSeconds` preStop sends it), and before a supervised `x dev` restart's drain (#677) |
97
103
  | `role-start.ts` / `role-start-types.ts` / `role-realtime.ts` | `--role` selection and start/stop for `web`, `sync`, `worker`, `scheduler`; `realtime.enabled: false` drops `sync` and `replicator` |
98
104
  | `role-wake.ts` | the worker's LISTEN session (`@ultimat3/jobs`' `startQueueWake`) — worker role only, only over a client that `canListen` — and `queue_wake_live` |
99
105
  | `role-sync.ts` / `role-replicator.ts` / `runtime-live-feed.ts` | the sync node, the change feed |
100
- | `runtime-render.ts` / `runtime-assets.ts` / `runtime-storage.ts` / `runtime-hooks.ts` / `api-routes.ts` | the HTTP surface: pages, `/icons` + `/media`, `/_storage`, authz, the app's API — plus its bearer mounts (`apiMountRoutes`) and pages' bound `POST`s (`pagePostRoutes`), mounted by BOTH boots |
106
+ | `runtime-render.ts` / `runtime-isr-outcome.ts` / `route-islands.ts` / `runtime-assets.ts` / `runtime-storage.ts` / `runtime-hooks.ts` / `api-routes.ts` | the HTTP surface: pages (an `isr` hit never runs `load`, and a `load` redirect is answered per request, never stored or shared — `runtime-isr-outcome.ts`; `route-islands.ts` is a render's island collector and realtime boot), `/icons` + `/media`, `/_storage`, authz, the app's API — plus its bearer mounts (`apiMountRoutes`) and pages' bound `POST`s (`pagePostRoutes`), mounted by BOTH boots |
101
107
  | `stored-object-headers.ts` | how `/_storage` and `/media` present a stored object: inline only for raster images, audio and video; every other type (`text/html`, SVG, XML, PDF) is `content-disposition: attachment` with a `sandbox` CSP, which http's security stage keeps beside the app's |
102
108
  | `runtime-overrides.ts` | the one field a host hands the framework a driver through — and `routes`, the plain routes for a wire format no primitive speaks (OAuth token endpoint) |
103
109
  | `app-openapi.ts` | `openapi.json` (complete when `defineApi({ openapi })` is declared) and each bearer mount's own document; staleness for all of them |
@@ -105,7 +111,8 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
105
111
  | `page-navigation.ts` | the client router for `navigation.client` surfaces — built, routed and named ONCE for `x dev`, the container and the static export; none opted in builds nothing |
106
112
  | `page-speculation.ts` | Speculation Rules for documents WITHOUT the router (`navigation.speculation`): the allow-list of pure-read pages, the one tag, and its `script-src` hash — composed once for all three writers. An allow-list, never "every link but": a prefetch is a real GET with cookies |
107
113
  | `static-document.ts` | a served `static` page: `304` on a matching `If-None-Match`, and (container only, `memoStatic`) the document kept after its first render. Never `x dev` — a save moves the stylesheet URL the kept document names |
108
- | `island-bundle.ts` / `island-store.ts` / `island-realtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; the image build writes a verified store the container loads |
114
+ | `island-bundle.ts` / `island-store.ts` / `island-sources.ts` / `island-realtime.ts` / `island-runtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; the image build writes a verified store the container loads — each chunk's recorded source files (`island-sources.ts`) are rehashed at read, so an edit since the build is a rebuild, never the old chunk. `island-runtime.ts` builds realtime's page runtime ONCE per build as `/islands/page-runtime.<id>.js` (resolved through `@ultimat3/realtime/page-runtime`), which a realtime island awaits where no page boot is rendered |
115
+ | `local-cli.ts` / `local-cli-handoff.ts` | a global `x` hands over to the app's own CLI (`local-cli.ts` decides whether); the hand-off forwards SIGTERM/SIGHUP at once and a SIGINT only if the child is still running after a 1 s grace (a terminal Ctrl-C already reached it), and exits with the child's code |
109
116
  | `serve-prebuilt.ts` / `serve-prebuilt-paths.ts` | the image's prebuilt store (`node_modules/.cache/ultimate`, never `.x/` — state, tmpfs): every role adopts its compiled stylesheets before importing the app; a boot that built an island or ran Sass logs `X_IMAGE_NOT_PREBUILT` once |
110
117
  | `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, the watcher, the reload, the lock, the port |
111
118
  | `dev-dashboard-guard.ts` / `loopback-host.ts` | `/_x` answers a loopback `Host` only (`X_DEV_HOST_REFUSED`, 421 — DNS rebinding); the SQL panel runs on a same-origin `POST /_x/db` only, through admin's `readOnlySql`. Checked in the route, never left to the app's `csrf` config |
package/README.md CHANGED
@@ -16,14 +16,14 @@ Commands and the `x verify` step count, `As of 2026-08`:
16
16
  | `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
17
17
  | `x db gen\|migrate\|reset\|branch\|backfill` | everything DB | `branch` = copy-on-write clone + preview URL; `backfill` dry-runs unless `--write`. `x db studio` is **planned** — it parses, and exits `X_NOT_IMPLEMENTED` naming `/_x`'s db panel |
18
18
  | `x mcp serve` | `@ultimat3/mcp`'s 18 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
19
- | `x doctor` | environment, ports, drift, PWA prerequisites, `APP_URL` | every finding carries a fix command; probes the port `x dev` binds ([which one](../../wiki/CLI-Reference.md#x-doctor)) |
19
+ | `x doctor` | environment, ports, drift, PWA prerequisites, `APP_URL` | every finding carries a fix command; probes the port `x dev` binds ([which one](../../wiki/CLI-Reference.md#x-doctor)); the framework's deploy secrets only where the machine names `staging`/`production` |
20
20
  | `x deploy` | container deploy plan | compose or helm; zero platform primitives |
21
21
  | `x manifest` / `x routes` | generated facts | `x.manifest.json`, `openapi.json`, route table |
22
22
  | `x actions` / `x queries` / `x entities` | the declaration registries | `list` and `describe <name>`, straight off the registries |
23
23
  | `x tasks list\|show` | cron tasks | timezone and next run, off `registeredTasks()` |
24
- | `x jobs ls\|show\|retry\|cancel\|drain` | the queue | depth, dead letters, step traces, `retry --from-step`, `cancel --reason`, `drain --to` |
24
+ | `x jobs ls\|show\|retry\|cancel\|rm\|promote\|pause\|resume` | the queue | depth, dead letters, step traces, `retry --from-step`, `cancel --reason`; `ls --json` pages as `rows`/`nextCursor`/`hasMore`; `drain` is planned (`X_NOT_IMPLEMENTED`) |
25
25
  | `x test [type]` | one of the six test types, or all | same type rule as the gate; `--filter`, `--sample N` |
26
- | `x env check\|example` | the typed environment `envSchema` declares | and the `.env.example` rendered from it |
26
+ | `x env check\|example` | the typed environment `envSchema` declares | and the `.env.example` rendered from it, plus the framework's deploy-required secrets (`ULTIMATE_CURSOR_SECRET`, `STORAGE_SIGNING_SECRET` unless `S3_ENDPOINT` is set), which `check` asks for wherever the boot refuses them — outside `development`/`test`, or with no environment named |
27
27
  | `x auth seal-mfa` | one-shot auth maintenance | seals every `x_users.mfa_secret` still in the clear; idempotent, `{ sealed, alreadySealed, skipped }` |
28
28
  | `x secrets show\|init\|edit\|set\|rotate` | the committed encrypted secrets | decrypted into the `envSchema` variables of the same names |
29
29
  | `x policy list\|explain <subject>` | which clause decided a permission, and why | five packages print `x policy explain` as a denial's `fix:` |
@@ -36,6 +36,31 @@ Everything in [CLI reference](../../wiki/CLI-Reference.md)'s planned table is al
36
36
  and exits `X_NOT_IMPLEMENTED` with a `fix:` naming the closest shipped command — "not built yet"
37
37
  and "not a command" are different facts.
38
38
 
39
+ ## `x build`: who a weighed page renders as
40
+
41
+ `x build --target static` renders every budgeted `app/` route it cannot publish only to weigh it,
42
+ then discards the document. That render runs as core's `measurementActor()`: the actor
43
+ `defineMeasurementActor()` declares in `app.config.ts`, else a **service actor holding `*` with no
44
+ `orgId` and no roles**. A page whose policy names a role, or whose `load` reads a tenant-scoped
45
+ entity, is refused under the default, and the `budgets` step reports it as `X_BUDGET_UNMEASURED`
46
+ with the actor, the refusal's code and cause (`X_FORBIDDEN`, `X_UNAUTHENTICATED`,
47
+ `X_TENANCY_ACTOR_ORG_REQUIRED`), and the edit that weighs it:
48
+
49
+ ```ts
50
+ // app.config.ts — the one file every build imports; never used for a published site/ artifact
51
+ import { defineMeasurementActor, userActor } from '@ultimat3/core';
52
+
53
+ /** The org your dev seed creates. */
54
+ const DEMO_ORG_ID = '00000000-0000-4000-8000-000000000001';
55
+
56
+ defineMeasurementActor(() => userActor({ id: 'measure', orgId: DEMO_ORG_ID, roles: ['dev'] }));
57
+ ```
58
+
59
+ Pick the org your dev seed creates and the most-privileged role your gated pages require: the
60
+ weigh measures the page a holder of that role is served. Any other refusal (a missing database, a
61
+ `TypeError` in `load`) keeps the generic `fix:` — the `unmeasured` list of
62
+ `x build --target static --json` — with the code already in the cause.
63
+
39
64
  ## Which `x` runs
40
65
 
41
66
  **The app's own.** `x` is a workspace dependency, and `bunx x` / the `package.json` scripts resolve
@@ -136,7 +161,7 @@ that name where there is one (`x new` writes all seventeen), the blank template
136
161
  | `cmd-*.ts` | one command group each |
137
162
  | `templates/` | scaffolding as typed string modules, not copied fixtures |
138
163
  | `app-load.ts` | import an app's modules so the framework registries hold it |
139
- | `app-mcp.ts` | the app's own MCP endpoint: `apps/<app>/mcp.ts` exports `mcp`, and both boots mount `POST config.ai.mcp.path` through this one call |
164
+ | `app-mcp.ts` | the app's own MCP endpoint: `apps/<app>/mcp.ts` exports `mcp`, and both boots mount `POST` at each endpoint's own `defineAppMcp({ path })` through this one call |
140
165
  | `app-runtime.ts` | the app's `RuntimeOverrides`: `apps/<app>/runtime.ts` exports `runtime`, read by `x dev` and by `runRole` when its caller passed none |
141
166
  | `local-cli.ts` | which `x` runs: a global CLI inside an app hands over to the app's own, because a second module instance is an empty registry |
142
167
  | `measurement-actor.ts` | the actor a weigh-and-discard render runs as — every permission, never served |
@@ -146,7 +171,7 @@ that name where there is one (`x new` writes all seventeen), the blank template
146
171
  | `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
147
172
  | `app-transport.ts` / `browser-transport.ts` | one browser transport, on `boundaries` in every app: a raw `fetch(`, `new WebSocket(` or `new XMLHttpRequest(` in an island's closure is `X_BROWSER_TRANSPORT_BYPASS`; a server barrel there is `X_BROWSER_SERVER_BARREL` |
148
173
  | `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
149
- | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each |
174
+ | `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it. `ROLE`, `PORT` and `HOST` are read from `env`; `role`, `port` and `hostname` on `ServeOptions` override each. `ROLE=worker` also retires on SIGUSR2 (`serve-retire.ts`, 25.0.0): stop claiming, finish every held job, abort nothing, drain, exit 0 — what the chart's opt-in `roles.worker.retireSeconds` `preStop` sends; a no-op on Windows ([Deployment](../../wiki/Deployment.md#retiring-a-worker-before-sigterm)) |
150
175
  | `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
151
176
  | `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT`; opening it starts the `process_*` series, labelled by `startMetricsEndpoint({ role })` |
152
177
  | `role-load.ts` / `document-graph.ts` / `serve-web.ts` | what each container role imports: `web`, `sync` and `replicator` every app module; `worker` and `scheduler` the API index plus every module that reaches no component or stylesheet |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "24.0.0",
3
+ "version": "25.1.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "engines": {
33
- "bun": ">=1.4.0"
33
+ "bun": ">=1.4.2"
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "24.0.0",
43
- "@ultimat3/admin": "24.0.0",
44
- "@ultimat3/ai": "24.0.0",
45
- "@ultimat3/auth": "24.0.0",
46
- "@ultimat3/cache": "24.0.0",
47
- "@ultimat3/core": "24.0.0",
48
- "@ultimat3/db": "24.0.0",
49
- "@ultimat3/entity": "24.0.0",
50
- "@ultimat3/flags": "24.0.0",
51
- "@ultimat3/http": "24.0.0",
52
- "@ultimat3/i18n": "24.0.0",
53
- "@ultimat3/jobs": "24.0.0",
54
- "@ultimat3/mail": "24.0.0",
55
- "@ultimat3/manifest": "24.0.0",
56
- "@ultimat3/mcp": "24.0.0",
57
- "@ultimat3/money": "24.0.0",
58
- "@ultimat3/notify": "24.0.0",
59
- "@ultimat3/policy": "24.0.0",
60
- "@ultimat3/pwa": "24.0.0",
61
- "@ultimat3/query": "24.0.0",
62
- "@ultimat3/realtime": "24.0.0",
63
- "@ultimat3/render": "24.0.0",
64
- "@ultimat3/schema": "24.0.0",
65
- "@ultimat3/seo": "24.0.0",
66
- "@ultimat3/storage": "24.0.0",
67
- "@ultimat3/testing": "24.0.0",
68
- "@ultimat3/time": "24.0.0",
42
+ "@ultimat3/action": "25.1.0",
43
+ "@ultimat3/admin": "25.1.0",
44
+ "@ultimat3/ai": "25.1.0",
45
+ "@ultimat3/auth": "25.1.0",
46
+ "@ultimat3/cache": "25.1.0",
47
+ "@ultimat3/core": "25.1.0",
48
+ "@ultimat3/db": "25.1.0",
49
+ "@ultimat3/entity": "25.1.0",
50
+ "@ultimat3/flags": "25.1.0",
51
+ "@ultimat3/http": "25.1.0",
52
+ "@ultimat3/i18n": "25.1.0",
53
+ "@ultimat3/jobs": "25.1.0",
54
+ "@ultimat3/mail": "25.1.0",
55
+ "@ultimat3/manifest": "25.1.0",
56
+ "@ultimat3/mcp": "25.1.0",
57
+ "@ultimat3/money": "25.1.0",
58
+ "@ultimat3/notify": "25.1.0",
59
+ "@ultimat3/policy": "25.1.0",
60
+ "@ultimat3/pwa": "25.1.0",
61
+ "@ultimat3/query": "25.1.0",
62
+ "@ultimat3/realtime": "25.1.0",
63
+ "@ultimat3/render": "25.1.0",
64
+ "@ultimat3/schema": "25.1.0",
65
+ "@ultimat3/seo": "25.1.0",
66
+ "@ultimat3/storage": "25.1.0",
67
+ "@ultimat3/testing": "25.1.0",
68
+ "@ultimat3/time": "25.1.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -2,7 +2,7 @@
2
2
  // walked here — the admin owns the one derivation, so a key `defineAdmin` would draw and the
3
3
  // `i18n` step audits can never be two spellings of one rule.
4
4
 
5
- import { describeRoutes } from '@ultimat3/render';
5
+ import { describePages } from '@ultimat3/render';
6
6
 
7
7
  /**
8
8
  * The keys of every admin this process declared. Asked of the route table first: an app with no
@@ -10,7 +10,7 @@ import { describeRoutes } from '@ultimat3/render';
10
10
  * the dynamic import below is a cache hit.
11
11
  */
12
12
  export async function mountedAdminKeys(): Promise<readonly string[]> {
13
- if (!describeRoutes().some((route) => route.mount?.by === 'defineAdmin')) return [];
13
+ if (!describePages().some((route) => route.mount?.by === 'defineAdmin')) return [];
14
14
  const { adminMounts } = await import('@ultimat3/admin');
15
15
  return [...new Set(adminMounts().flatMap((app) => app.catalogKeys()))].sort();
16
16
  }
package/src/affected.ts CHANGED
@@ -181,7 +181,9 @@ export async function gitRoot(runner: Runner, cwd: string, command: string): Pro
181
181
  throw new UltimateError({
182
182
  code: 'X_CLI_UNEXPECTED',
183
183
  cause: `x ${command} reads its diff from git and "git rev-parse --show-toplevel" exited ${result.code} in ${cwd}: ${singleLine(execOutput(result))}`,
184
- fix: `run x ${command} from inside a git checkout — confirm with: git rev-parse --show-toplevel`,
184
+ // Repeating `git rev-parse` HERE cannot help — it is what failed; the fix names how to get INTO
185
+ // a checkout from anywhere, then re-run.
186
+ fix: `cd "$(git -C '<a directory inside your checkout>' rev-parse --show-toplevel)" # then run x ${command} there — ${cwd} is not inside a git checkout`,
185
187
  });
186
188
  }
187
189
 
@@ -25,6 +25,7 @@ import { commentSafe } from './comment-safe';
25
25
  import { scanRuntimeImports, stripShebang } from './import-scan';
26
26
  import type { Finding } from './output';
27
27
  import { hasPathSegment } from './path-segments';
28
+ import { toPosix } from './posix-path';
28
29
  import { quoteArg } from './shell-quote';
29
30
 
30
31
  export const BOUNDARY_CODES = [
@@ -61,11 +62,21 @@ export const boundaryCodeOf = (rule: BoundaryRule): BoundaryCode => CODE_OF[rule
61
62
 
62
63
  const isRoute = (path: string): boolean => /\/(page|layout|route)\.[cm]?tsx?$/.test(path);
63
64
  const isService = (path: string): boolean => /\/service\.[cm]?ts$/.test(path);
65
+ /**
66
+ * A slice's `repo.ts` counts: it is the typed handle one hop away, and a page reading it skips the
67
+ * query that carries the read's policy, bound and cache tag — the scaffold's own dashboard showed
68
+ * every post to a viewer holding `dashboard:read` alone. Matched as a whole module name, resolved
69
+ * (`…/repo.ts`), NodeNext-spelled (`./repo.js`, `.mjs`, `.cjs`) or bare (`../post/repo`), so
70
+ * `report` and `repository` are names, not repos.
71
+ */
72
+ const isRepoSpecifier = (specifier: string): boolean =>
73
+ /(^|\/)repo(\/index)?(\.[cm]?[jt]sx?)?$/.test(specifier);
64
74
  const isDbSpecifier = (specifier: string): boolean =>
65
75
  /(^|\/)packages\/db($|\/)/.test(specifier) ||
66
76
  specifier.endsWith('/db') ||
67
77
  /^@[^/]+\/db$/.test(specifier) ||
68
- specifier === 'drizzle-orm';
78
+ specifier === 'drizzle-orm' ||
79
+ isRepoSpecifier(specifier);
69
80
  /**
70
81
  * `node:http` and `node:https` are HTTP too, and the anchor could not reach them: `http` had to
71
82
  * follow a `/` or start the string, and a `node:` specifier has a colon there. So the one spelling
@@ -104,7 +115,9 @@ export function resolveSpecifier(
104
115
  * the form `CANDIDATE_SUFFIXES` resolves and the form every app source already writes.
105
116
  */
106
117
  export function relativeSpecifier(fromFile: string, target: string): string {
107
- const path = relative(dirname(fromFile), target).replace(/\.[cm]?tsx?$/, '');
118
+ // Through `toPosix` even over the POSIX `relative`: a key spelt `apps\web\…` is ONE segment to
119
+ // it, and the specifier came out `./apps\web\…` — an import no host resolves.
120
+ const path = relative(dirname(toPosix(fromFile)), toPosix(target)).replace(/\.[cm]?tsx?$/, '');
108
121
  return path.startsWith('.') ? path : `./${path}`;
109
122
  }
110
123
 
@@ -190,7 +203,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
190
203
  if (isRoute(file.path) && isDbSpecifier(specifier)) {
191
204
  findings.push({
192
205
  code: 'X_BOUNDARY_ROUTE_TO_DB',
193
- cause: `route imports the database ("${specifier}") — routes call actions and queries`,
206
+ cause: `route imports the database ("${specifier}")${isRepoSpecifier(specifier) ? ', through a repo' : ''} — routes call actions and queries`,
194
207
  fix: generate('query', file.path, 'then call it from'),
195
208
  docs: ERROR_DOCS_URL,
196
209
  at: file.path,
@@ -232,7 +245,7 @@ export async function readAppSources(root: string): Promise<readonly SourceFile[
232
245
  const glob = new Bun.Glob(pattern);
233
246
  for await (const path of glob.scan({ cwd: root, absolute: false })) {
234
247
  if (hasPathSegment(path, 'node_modules') || path.includes('.test.')) continue;
235
- const posix = path.split('\\').join('/');
248
+ const posix = toPosix(path);
236
249
  files.push({ path: posix, source: await Bun.file(joinPath(root, posix)).text() });
237
250
  }
238
251
  }
@@ -30,9 +30,9 @@ export async function appConfigExport(root: string, name: string): Promise<unkno
30
30
  /**
31
31
  * `root`'s `AppConfig`, or `undefined` when the root has no `app.config.ts` — not an app, and each
32
32
  * caller's "nothing declared" answer is its own. A file that IS there is held to `defineConfig`'s
33
- * one validator: a hand-built `config` object, or one resolved through an older core, reaches the
34
- * boot validated and defaulted exactly as one built by `defineConfig` does, which re-merges to
35
- * itself. A file with no `config` object is refused rather than read as "every default".
33
+ * one validator: a hand-built `config` object reaches the boot validated and defaulted exactly as
34
+ * one built by `defineConfig` does, which re-merges to itself. One resolved through a 24.x core is
35
+ * refused (its defaults wrote keys 25.0.0 removed): install every `@ultimat3/*` at one version. A file with no `config` object is refused rather than read as "every default".
36
36
  */
37
37
  export async function loadAppConfig(root: string): Promise<AppConfig | undefined> {
38
38
  const path = join(root, APP_CONFIG_FILE);
package/src/app-env.ts CHANGED
@@ -5,14 +5,10 @@
5
5
  // why: Bun exposes no path API — the two files this module reads are joined to the app root.
6
6
  import { join } from 'node:path';
7
7
  import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
8
- import {
9
- checkEnvExample,
10
- ENV_EXAMPLE_PATH,
11
- ERROR_DOCS_URL,
12
- renderEnvExample,
13
- } from '@ultimat3/core';
8
+ import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL, parseEnvKeys } from '@ultimat3/core';
14
9
  import { appConfigExport } from './app-config-load';
15
10
  import { APP_CONFIG_FILE } from './app-root';
11
+ import { appEnvExample, FRAMEWORK_SECRETS } from './framework-env';
16
12
  import type { Finding } from './output';
17
13
  import { findingFrom } from './output';
18
14
 
@@ -53,14 +49,17 @@ export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined
53
49
  return isEnvSchema(declared) ? declared : undefined;
54
50
  }
55
51
 
56
- /** The bytes `.env.example` must hold. Deterministic, so a rewrite that changes nothing diffs to nothing. */
57
- export const envExampleFor = (schema: EnvSchema): string => renderEnvExample(schema);
52
+ /**
53
+ * The bytes `.env.example` must hold: the app's declaration, then the framework's deploy-required
54
+ * keys (`framework-env.ts`). Deterministic, so a rewrite that changes nothing diffs to nothing.
55
+ */
56
+ export const envExampleFor = (schema: EnvSchema): string => appEnvExample(schema);
58
57
 
59
58
  const driftFinding = (cause: string): Finding => ({
60
59
  code: 'X_ENV_EXAMPLE_DRIFT',
61
60
  cause,
62
- // The generator, not the assertion: `assertEnvExample`'s own fix is a `Bun.write(…)` call for
63
- // an app that has a schema object in scope, and a gate reader has a shell.
61
+ // The generator, never a `Bun.write(…)` call: that needs a schema object in scope, and a gate
62
+ // reader has a shell.
64
63
  fix: 'x env example',
65
64
  docs: ERROR_DOCS_URL,
66
65
  at: ENV_EXAMPLE_PATH,
@@ -91,12 +90,20 @@ export async function envExampleFindings(root: string): Promise<readonly Finding
91
90
  }
92
91
  const text = await file.text();
93
92
  if (text === expected) return [];
94
- const report = checkEnvExample(schema, text);
93
+ const present = new Set(parseEnvKeys(text));
94
+ const missing = Object.keys(schema).filter((key) => !present.has(key));
95
+ // The framework's keys are owed by every deployed app, whatever its schema says (#679), and an
96
+ // example written before they were part of the contract is missing exactly these.
97
+ const owed = FRAMEWORK_SECRETS.map((secret) => secret.key).filter(
98
+ (key) => !Object.hasOwn(schema, key) && !present.has(key),
99
+ );
95
100
  return [
96
101
  driftFinding(
97
- report.missing.length > 0
98
- ? `${ENV_EXAMPLE_PATH} does not declare ${report.missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
99
- : `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
102
+ missing.length > 0
103
+ ? `${ENV_EXAMPLE_PATH} does not declare ${missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
104
+ : owed.length > 0
105
+ ? `${ENV_EXAMPLE_PATH} does not declare ${owed.join(', ')}, which the framework refuses to boot without outside development/test`
106
+ : `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
100
107
  ),
101
108
  ];
102
109
  }
package/src/app-load.ts CHANGED
@@ -3,12 +3,12 @@
3
3
  // `registerQueries` / `registerRoute` name the rest — so `x manifest`, `x routes` and `x verify`
4
4
  // read exactly the tables the running server reads.
5
5
 
6
- // Bun ships no `Bun.*` path API: `relative`/`sep` turn an absolute scan hit into the app-root-
7
- // relative POSIX path every finding and every manifest fact is keyed by.
8
- import { join, relative, resolve, sep } from 'node:path';
6
+ // Bun ships no `Bun.*` path API: `join`/`resolve` reach the files a scan imports.
7
+ import { join, resolve } from 'node:path';
9
8
  import { isAction, isMutator, listActions, registerActions } from '@ultimat3/action';
10
9
  import { describeEntities, registeredEntities } from '@ultimat3/entity';
11
- import { localeConfig } from '@ultimat3/i18n';
10
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
11
+ import { appLocaleSet, UNDECLARED_LOCALES } from '@ultimat3/i18n/app-catalogs';
12
12
  import { isJobHandle, isTaskHandle, registeredJobs, registeredTasks } from '@ultimat3/jobs';
13
13
  import type { ErrorCodeFact } from '@ultimat3/manifest';
14
14
  import { isQuery, listQueries, registerQueries } from '@ultimat3/query';
@@ -40,6 +40,7 @@ import { collectDeclaredCodes } from './error-contract';
40
40
  import type { Finding } from './output';
41
41
  import { findingFrom } from './output';
42
42
  import { hasPathSegment } from './path-segments';
43
+ import { posixRelative, toPosix } from './posix-path';
43
44
  import { siteAssetTable } from './site-assets';
44
45
  import { isTest } from './source-files';
45
46
 
@@ -83,13 +84,13 @@ export interface LoadedApp {
83
84
  /** Every `X_*` code the app's source declares, by code — the one fact no registry holds. */
84
85
  readonly errorCodes: readonly ErrorCodeFact[];
85
86
  /**
86
- * The locale the app falls back to. `packages/i18n/src/index.ts` is inside the import loop, and
87
- * `defineCatalogs()` configures `@ultimat3/i18n` on its way through — so this is the framework's
88
- * own answer, read back from `localeConfig()`, and never a regex over the app's source, which
89
- * only ever matched the one `defineCatalogs({ default: '…' })` spelling it anticipated. An app
90
- * whose i18n module would not import leaves the framework default (`en`) and a finding saying so.
87
+ * The app's locales, default first — `@ultimat3/i18n`'s `appLocaleSet`, the ONE reader of
88
+ * `defineCatalogs()`, asked once here so every step after the load reads one answer. Never
89
+ * `localeConfig()` read blind (it answers an earlier import's declaration for an app that made
90
+ * none) and never a regex over the source. An app with no catalogs, or whose catalog module would
91
+ * not import (the scan's finding already says so), is `UNDECLARED_LOCALES`.
91
92
  */
92
- readonly defaultLocale: string;
93
+ readonly locales: AppLocaleSet;
93
94
  /** Modules that would not import, and primitives that would not register. */
94
95
  readonly findings: readonly Finding[];
95
96
  }
@@ -121,6 +122,17 @@ export function resetAppLoad(): void {
121
122
  resetReloadGraph();
122
123
  }
123
124
 
125
+ /**
126
+ * One glob's hits in code-unit order of their POSIX spelling — never the host's. Sorted raw, `\`
127
+ * (0x5C) sorts after a digit or a capital where `/` (0x2F) sorts before one, so Windows imported
128
+ * `app/a1/page.tsx` before `app/a/page.tsx` and Linux after: one app, two cascades.
129
+ */
130
+ export function sortModulePaths(paths: readonly string[]): string[] {
131
+ const keyed = paths.map((path) => ({ path, key: toPosix(path) }));
132
+ keyed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
133
+ return keyed.map((one) => one.path);
134
+ }
135
+
124
136
  /**
125
137
  * Every module path the app globs match, SORTED — with the API index first. `Bun.Glob` answers in
126
138
  * directory order, which is the filesystem's — ext4 hashes names with a per-filesystem seed — so
@@ -144,7 +156,7 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
144
156
  if (!seen.has(absolute)) matched.push(absolute);
145
157
  seen.add(absolute);
146
158
  }
147
- found.push(...matched.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
159
+ found.push(...sortModulePaths(matched));
148
160
  }
149
161
  // Resolved, like the glob's own answers: `loadApp('.')` is a legal call.
150
162
  const index = resolve(root, API_INDEX);
@@ -196,7 +208,7 @@ export async function scanAppModules(root: string, options: ScanOptions): Promis
196
208
  // file it holds, so this loop imported none of them and the app registered nothing.
197
209
  // The ROOT-RELATIVE path is tested, never the absolute one: an app checked out under
198
210
  // `~/work/my.test.app` matched `.test.` on every file and loaded none of them.
199
- const file = relative(root, absolute).split(sep).join('/');
211
+ const file = posixRelative(root, absolute);
200
212
  if (hasPathSegment(absolute, 'node_modules') || isTest(file)) continue;
201
213
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
202
214
  continue;
@@ -249,16 +261,28 @@ export async function loadApp(root: string): Promise<LoadedApp> {
249
261
  ) {
250
262
  findings.push(emptyAppFinding(root));
251
263
  }
252
- // Read after the loop, never before it: `configureLocales` runs on the app's own import.
253
264
  return {
254
265
  root,
255
266
  files: scan.files,
256
267
  errorCodes: await appErrorCodes(root),
257
- defaultLocale: localeConfig().fallback,
268
+ locales: await loadedLocales(root),
258
269
  findings,
259
270
  };
260
271
  }
261
272
 
273
+ /**
274
+ * After the scan, never before it: the scan imported the catalog module, so the set it exports is
275
+ * what `loadAppCatalogs` recognises. A module that would not import is the scan's finding, not a
276
+ * second one here.
277
+ */
278
+ async function loadedLocales(root: string): Promise<AppLocaleSet> {
279
+ try {
280
+ return await appLocaleSet(root);
281
+ } catch {
282
+ return UNDECLARED_LOCALES;
283
+ }
284
+ }
285
+
262
286
  /** No primitive in any registry this scan fills — a process that registered some itself is not empty. */
263
287
  const registersNothing = (): boolean =>
264
288
  listActions().length === 0 &&
@@ -21,7 +21,7 @@ import {
21
21
  import { knownPermissions } from '@ultimat3/policy';
22
22
  import { describeQueries } from '@ultimat3/query';
23
23
  import type { RouteDescriptor } from '@ultimat3/render';
24
- import { describeRoutes } from '@ultimat3/render';
24
+ import { describePages } from '@ultimat3/render';
25
25
  import { loadApp } from './app-load';
26
26
  import { AppPackageInvalidError } from './errors';
27
27
  import type { Finding } from './output';
@@ -105,7 +105,7 @@ type UrlRoute = RouteDescriptor & { readonly surface: 'site' | 'app' | 'api' };
105
105
  const hasUrl = (route: RouteDescriptor): route is UrlRoute => route.surface !== 'shared';
106
106
 
107
107
  const routeFacts = (): readonly RouteFact[] =>
108
- describeRoutes()
108
+ describePages()
109
109
  .filter(hasUrl)
110
110
  .map((route) => ({
111
111
  url: route.path,