@ultimat3/cli 21.0.0 → 22.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 (236) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +23 -17
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +22 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/sync-url.ts +21 -1
  178. package/src/templates/action.ts +30 -16
  179. package/src/templates/entity.ts +12 -7
  180. package/src/templates/index.ts +1 -1
  181. package/src/templates/job.ts +10 -7
  182. package/src/templates/policy.ts +20 -2
  183. package/src/templates/resource-create.ts +127 -0
  184. package/src/templates/resource-form-island.ts +49 -12
  185. package/src/templates/resource.ts +10 -4
  186. package/src/templates/scaffold-app.ts +4 -1
  187. package/src/templates/scaffold-auth.ts +3 -1
  188. package/src/templates/scaffold-container-compose.ts +184 -0
  189. package/src/templates/scaffold-container.ts +24 -159
  190. package/src/templates/scaffold-dashboard-example.ts +2 -2
  191. package/src/templates/scaffold-db-package.ts +16 -3
  192. package/src/templates/scaffold-demo-org.ts +41 -0
  193. package/src/templates/scaffold-entries.ts +1 -1
  194. package/src/templates/scaffold-env.ts +6 -0
  195. package/src/templates/scaffold-helm-templates.ts +66 -7
  196. package/src/templates/scaffold-helm.ts +27 -5
  197. package/src/templates/scaffold-i18n.ts +15 -10
  198. package/src/templates/scaffold-repo.ts +13 -10
  199. package/src/templates/scaffold-roles.ts +38 -10
  200. package/src/templates/slice-foundation.ts +1 -1
  201. package/src/templates/wrap.ts +4 -1
  202. package/src/test-passes.ts +2 -1
  203. package/src/test-workers.ts +26 -0
  204. package/src/ts-scan.ts +3 -6
  205. package/src/tsconfig-references.ts +1 -2
  206. package/src/verify-checks.ts +41 -39
  207. package/src/verify-e2e.ts +10 -7
  208. package/src/verify-run.ts +11 -1
  209. package/src/verify-step.ts +3 -3
  210. package/src/verify-tests.ts +1 -26
  211. package/src/verify-typecheck.ts +28 -0
  212. package/src/web-binding.ts +2 -2
  213. package/src/workspace-graph.ts +10 -33
  214. package/src/cdp-browser.ts +0 -94
  215. package/src/cdp-connection.ts +0 -247
  216. package/src/cdp-e2e-page.ts +0 -180
  217. package/src/cdp-e2e-session.ts +0 -199
  218. package/src/cdp-errors.ts +0 -56
  219. package/src/cdp-launch.ts +0 -193
  220. package/src/cdp-offline-script.ts +0 -73
  221. package/src/cdp-pipe.ts +0 -77
  222. package/src/e2e-app.ts +0 -103
  223. package/src/e2e-browser-handle.ts +0 -55
  224. package/src/e2e-dom-fixture.ts +0 -117
  225. package/src/e2e-driver.ts +0 -117
  226. package/src/e2e-errors.ts +0 -117
  227. package/src/e2e-evaluate.ts +0 -156
  228. package/src/e2e-locator.ts +0 -86
  229. package/src/e2e-page.ts +0 -153
  230. package/src/e2e-preload.ts +0 -64
  231. package/src/e2e-probe.ts +0 -23
  232. package/src/e2e-selection.ts +0 -182
  233. package/src/e2e-spawn.ts +0 -169
  234. package/src/measurement-actor.ts +0 -26
  235. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  236. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
package/src/drift.ts CHANGED
@@ -11,7 +11,10 @@
11
11
 
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
- import { ERROR_DOCS_URL } from '@ultimat3/core';
14
+ // `canonicalJson` is core's, the one serializer every content hash in the framework uses. A private
15
+ // copy here wrote `"key":null` for an unset optional field where core drops the key, so the schema
16
+ // hash agreed with nothing else (plan 101 slice 18 h; 22.0.0 re-stamps every sidecar once).
17
+ import { canonicalJson, ERROR_DOCS_URL } from '@ultimat3/core';
15
18
  import { describeEntities } from '@ultimat3/entity';
16
19
  import { countDeclaredEntities } from './app-entities';
17
20
  import { loadApp } from './app-load';
@@ -23,23 +26,6 @@ import type { Finding } from './output';
23
26
  export const DB_PACKAGE = join('packages', 'db');
24
27
  const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
25
28
 
26
- /**
27
- * Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
28
- * BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
29
- * otherwise move every app's hash and report drift over a framework upgrade nobody made.
30
- */
31
- function canonicalJson(value: unknown): string {
32
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
33
- if (typeof value === 'object' && value !== null) {
34
- const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
35
- a < b ? -1 : a > b ? 1 : 0,
36
- );
37
- return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
38
- }
39
- // `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
40
- return JSON.stringify(value) ?? 'null';
41
- }
42
-
43
29
  /**
44
30
  * What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
45
31
  * see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
@@ -46,22 +46,29 @@ export const CATALOG_PACKAGES = [
46
46
  ] as const;
47
47
 
48
48
  /**
49
- * The two packages the catalog may import WITHOUT `@ultimat3/cli` declaring them: they reach for a
50
- * JSX runtime an app has and a bare CLI process does not, so a hard dependency would make the CLI
51
- * uninstallable where the codes are merely absent today. Every other entry above is a real runtime
52
- * import and must be a declared dependency — `error-catalog.test.ts` holds the list to exactly that,
53
- * because an undeclared one resolves through workspace symlinks here and through nothing in an
54
- * installed app, where `x errors explain X_FLAG_EXPIRED` then refuses a code the wiki promises.
49
+ * The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them. `admin` and `ui`
50
+ * reach for a JSX runtime an app has and a bare CLI process does not, so a hard dependency would
51
+ * make the CLI uninstallable where the codes are merely absent today. `scraping` is an app's own
52
+ * choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
53
+ * answer where the app installed it and are reported `unavailable` where it did not. Every other
54
+ * entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
55
+ * holds the list to exactly that, because an undeclared one resolves through workspace symlinks
56
+ * here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
57
+ * refuses a code the wiki promises.
55
58
  */
56
- export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/admin', '@ultimat3/ui'];
59
+ export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
60
+ '@ultimat3/admin',
61
+ '@ultimat3/scraping',
62
+ '@ultimat3/ui',
63
+ ];
57
64
 
58
65
  export interface ErrorCatalog {
59
66
  /** Packages whose codes are now registered. */
60
67
  readonly loaded: readonly string[];
61
68
  /**
62
69
  * Packages this process could not *resolve*, so their codes are absent from the answer. The one
63
- * tolerated case is the optional host: `CATALOG_OPTIONAL_HOSTS` reach for a JSX
64
- * runtime an app has and a bare CLI process does not, and a list silently missing their codes is
70
+ * tolerated case is the optional host (`CATALOG_OPTIONAL_HOSTS`), which a bare CLI process may
71
+ * not have, and a list silently missing their codes is
65
72
  * worse than one that says which packages are missing. A package that resolved and then threw is
66
73
  * a defect, not a host gap, and goes to `failed`.
67
74
  */
@@ -178,30 +178,26 @@ export const CLI_OWNED_ERROR_CODES = [
178
178
  'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
179
179
  'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
180
180
  'X_SHOT_ISLAND_MISSING',
181
- // The browser-backed e2e driver — `e2e-driver.ts` and the three modules under it. Owned by the
182
- // CLI because the ADAPTER is: `@ultimat3/testing` declares `PageLike` and may not import a
183
- // browser, `@ultimat3/scraping` owns the browser and may not import the harness, and neither
184
- // package can name a failure that only exists where the two meet.
185
- 'X_E2E_EVALUATE_UNSUPPORTED',
186
- 'X_E2E_EVALUATE_CAPTURED',
187
- 'X_E2E_EVALUATE_THREW',
188
- 'X_E2E_LOCATOR_EMPTY',
189
- 'X_E2E_LOCATOR_AMBIGUOUS',
190
- 'X_E2E_SERVICE_WORKER_ABSENT',
191
- 'X_E2E_APP_FAILED',
192
- // The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
193
- // `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
194
- // install a browser, read the browser's own stderr, look at the page, raise a deadline.
195
- 'X_CDP_BROWSER_MISSING',
196
- 'X_CDP_LAUNCH_FAILED',
197
- 'X_CDP_CALL_FAILED',
198
- 'X_CDP_TIMEOUT',
181
+ // The raw-CDP shot page's own refusals (`cdp-shot-errors.ts` holds the classes): which host it
182
+ // may open, which element it was pointed at, and which key it was asked to press.
183
+ 'X_SHOT_HOST_REFUSED',
184
+ 'X_SHOT_ELEMENT_MISSING',
185
+ 'X_SHOT_ELEMENT_UNREADY',
186
+ 'X_SHOT_KEY_INVALID',
199
187
  'X_GH_UNAVAILABLE',
200
188
  'X_GH_NOT_AUTHENTICATED',
201
189
  'X_GH_COMMAND_FAILED',
202
190
  'X_GH_RESPONSE_INVALID',
203
191
  'X_PR_NOT_FOUND',
204
192
  'X_CI_RUN_NOT_FOUND',
193
+ 'X_APP_NAME_EMPTY',
194
+ 'X_APP_EMPTY',
195
+ 'X_BOUNDARY_SURFACE_IMPORT',
196
+ 'X_PERMISSION_UNGRANTED',
197
+ 'X_JOB_UNREGISTERED',
198
+ 'X_FEATURE_UNKNOWN',
199
+ 'X_ROUTE_ASYNC_PAGE',
200
+ 'X_BUDGET_PARAMS_UNDECLARED',
205
201
  ] as const;
206
202
 
207
203
  /**
@@ -316,7 +312,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
316
312
  X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
317
313
  X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
318
314
  X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
319
- X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
315
+ X_SHOT_BROWSER_MISSING: '`x shot` has no browser to launch',
320
316
  X_UI_SHOT_ROUTE_UNKNOWN: 'a ui.* tool was asked for a path no route answers',
321
317
  X_UI_SHOT_ROUTE_UNBUDGETED: 'a ui.* tool refused a route that declares no budget.js',
322
318
  X_UI_INTERACT_STEPS_INVALID: 'a ui.interact step list is over its bounds or malformed',
@@ -330,23 +326,25 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
330
326
  X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
331
327
  X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
332
328
  X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
333
- X_E2E_EVALUATE_UNSUPPORTED: 'a page.evaluate() closure cannot be sent into the browser',
334
- X_E2E_EVALUATE_CAPTURED: 'a page.evaluate() closure named a binding the page does not have',
335
- X_E2E_EVALUATE_THREW: 'an expression an e2e page ran threw inside the browser',
336
- X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
337
- X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
338
- X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
339
- X_E2E_APP_FAILED: 'the app an e2e run spawned did not come up',
340
- X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
341
- X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
342
- X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
343
- X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
329
+ X_SHOT_HOST_REFUSED: "x shot was asked to open a host outside the run's allow list",
330
+ X_SHOT_ELEMENT_MISSING: 'no element matched the selector before the deadline',
331
+ X_SHOT_ELEMENT_UNREADY: 'the element matched and never became ready to act on',
332
+ X_SHOT_KEY_INVALID: 'a key chord the browser cannot press',
344
333
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
345
334
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
346
335
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
347
336
  X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
348
337
  X_PR_NOT_FOUND: 'no pull request for this checkout',
349
338
  X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
339
+ X_APP_NAME_EMPTY: 'the app name has no letters or digits',
340
+ X_APP_EMPTY: 'an app with an app.config.ts registered no primitive',
341
+ X_BOUNDARY_SURFACE_IMPORT: 'a surface imports one SURFACE_SPECS does not allow',
342
+ X_PERMISSION_UNGRANTED: 'an action, query or route requires a permission no role grants',
343
+ X_JOB_UNREGISTERED: 'a job reached the manifest under its positional anonymous name',
344
+ X_FEATURE_UNKNOWN: 'x g --feature names a slice that does not exist',
345
+ X_ROUTE_ASYNC_PAGE: 'a route exports an async Page',
346
+ X_BUDGET_PARAMS_UNDECLARED:
347
+ 'a dynamic route with a budget declares no prerender() paths to weigh it by',
350
348
  };
351
349
 
352
350
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -34,9 +34,8 @@ const isStatus = (status: number): boolean =>
34
34
  /**
35
35
  * The app's page for one status, or `undefined`.
36
36
  *
37
- * Read per REQUEST, never cached at boot, for `favicon.ts`'s reason: `x dev` is a running process
38
- * an author drops a file into, and a reader that captured "there was none" at startup would keep
39
- * answering the framework's page until the server was restarted.
37
+ * One read. How often it is asked is `errorPageHook`'s decision: per request under `x dev`, for
38
+ * `favicon.ts`'s reason, and once per status in a container.
40
39
  */
41
40
  export async function errorPageOverride(root: string, status: number): Promise<string | undefined> {
42
41
  if (!isStatus(status)) return undefined;
@@ -48,11 +47,24 @@ export async function errorPageOverride(root: string, status: number): Promise<s
48
47
  * `ServerHooks.errorPage`, bound to one app root. Installed by `startWeb` so `x dev` and the
49
48
  * container cannot answer a browser differently — the rule `assetRoutes` already holds for
50
49
  * `/favicon.ico`.
50
+ *
51
+ * `perRequest` is `x dev`'s: an author drops a file into a running process. A container's image
52
+ * cannot change under it, so there each status is read ONCE and kept — per request, a burst of 500s
53
+ * during an outage paid a file read each, on the path that was already failing (plan 101, 12 g).
51
54
  */
52
- export const errorPageHook =
53
- (root: string) =>
54
- (status: number): Promise<string | undefined> =>
55
- errorPageOverride(root, status);
55
+ export const errorPageHook = (root: string, options: { readonly perRequest: boolean }) => {
56
+ if (options.perRequest) {
57
+ return (status: number): Promise<string | undefined> => errorPageOverride(root, status);
58
+ }
59
+ const read = new Map<number, Promise<string | undefined>>();
60
+ return (status: number): Promise<string | undefined> => {
61
+ const known = read.get(status);
62
+ if (known !== undefined) return known;
63
+ const once = errorPageOverride(root, status);
64
+ read.set(status, once);
65
+ return once;
66
+ };
67
+ };
56
68
 
57
69
  /**
58
70
  * The document that goes into a static export: the app's file if it has one, the framework's page
package/src/errors.ts CHANGED
@@ -72,6 +72,20 @@ export class AppNameIsPathError extends UltimateError {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * `x new '!!!'`: a name that slugifies to nothing names no directory, and the scaffold landed in
77
+ * the cwd itself — where `--force` then committed every file the user already had there.
78
+ */
79
+ export class AppNameEmptyError extends UltimateError {
80
+ constructor(input: { name: string; invocation: string; flags: readonly string[] }) {
81
+ super({
82
+ code: 'X_APP_NAME_EMPTY',
83
+ cause: `"${input.name}" has no letters or digits, so it names no directory`,
84
+ fix: [input.invocation, '<name-with-letters>', ...input.flags].join(' '),
85
+ });
86
+ }
87
+ }
88
+
75
89
  /**
76
90
  * A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
77
91
  *
package/src/favicon.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  // why: Bun exposes no path-join primitive, and `FAVICON_SOURCE` is app-root-relative, so resolving
7
7
  // it against the root is string work no `Bun.file` overload does — the same necessity
8
- // `dev-assets.ts` records for `ICON_SOURCE`.
8
+ // `runtime-assets.ts` records for `ICON_SOURCE`.
9
9
  import { join } from 'node:path';
10
10
  import { createRaster, encodeImage } from '@ultimat3/core';
11
11
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
@@ -69,7 +69,7 @@ const builtinBytes = (): Uint8Array => {
69
69
  const iconResponse = (bytes: Uint8Array, contentType: string): Response =>
70
70
  applyCacheHeaders(
71
71
  // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
72
- // `SharedArrayBuffer`, which `Response` does not accept — `dev-assets.ts`'s rule, verbatim.
72
+ // `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
73
73
  new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
74
74
  FAVICON_CACHE,
75
75
  );
@@ -4,9 +4,9 @@
4
4
 
5
5
  // `dirname`/`join` are `node:`-only by necessity: Bun exposes no path-join primitive.
6
6
  import { dirname, join } from 'node:path';
7
+ import { endOfLiteral, maskLiterals } from '@ultimat3/core';
7
8
  import type { FixHelper } from './fix-scan';
8
9
  import { scanFixHelpers } from './fix-scan';
9
- import { endOfLiteral, maskLiterals } from './ts-scan';
10
10
 
11
11
  /**
12
12
  * A named import, matched over the MASKED source and anchored at the start of a line, so an
package/src/fix-scan.ts CHANGED
@@ -3,16 +3,9 @@
3
3
  // class's constructor. Split out of `ts-scan.ts` when the third shape and cross-file resolution
4
4
  // (`fix-imports.ts`) took the file past the 500-line ceiling; the masking primitives stay there.
5
5
 
6
+ import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core';
6
7
  import type { FixSite } from './ts-scan';
7
- import {
8
- CLOSERS,
9
- endOfLiteral,
10
- lineIndex,
11
- maskLiterals,
12
- OPENERS,
13
- QUOTES,
14
- valueLiterals,
15
- } from './ts-scan';
8
+ import { CLOSERS, lineIndex, OPENERS, valueLiterals } from './ts-scan';
16
9
 
17
10
  /** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
18
11
  const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
package/src/flag-reads.ts CHANGED
@@ -9,11 +9,10 @@
9
9
 
10
10
  // `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
11
11
  import { join, relative } from 'node:path';
12
- import { ERROR_DOCS_URL } from '@ultimat3/core';
12
+ import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
13
13
  import type { Finding } from './output';
14
14
  import type { CommandSpec, FlagSpec } from './parse';
15
15
  import { GLOBAL_FLAGS } from './parse';
16
- import { stripComments } from './ts-scan';
17
16
 
18
17
  /** A flag as declared, with the command that declares it. */
19
18
  export interface DeclaredFlag {
@@ -0,0 +1,36 @@
1
+ // The one fence for foreign text an agent reads: a PR review body (`x pr review`) and a CI log
2
+ // (`x ci`) are both written by anyone who can comment or push, and rendered bare they arrive in the
3
+ // agent's context indistinguishable from this CLI's own output. A leaf, so both commands share it.
4
+
5
+ const BLOCK_OPEN = '<comment id=';
6
+ const BLOCK_CLOSE = '</comment>';
7
+
8
+ /**
9
+ * The fence, as a READER would parse it rather than as this file spells it.
10
+ *
11
+ * Two literal `replaceAll`s were the whole neutralisation, and markup is not spelled one way:
12
+ * `</comment >`, `</COMMENT>` and `< comment id=` all end or open a block for anything reading
13
+ * tags, and none of the three matched. One pattern over `<`, an optional `/`, and whitespace
14
+ * around a case-insensitive `comment` covers every spelling of the delimiter; the escape goes on
15
+ * the `<`, so what the reviewer wrote after it survives byte for byte.
16
+ */
17
+ const BLOCK_DELIMITER = /<(\s*\/?\s*comment\b)/gi;
18
+
19
+ /**
20
+ * One comment body, fenced and labelled with the thread id it came from — `@ultimat3/ai`'s
21
+ * `documentBlock` (`rag.ts`), applied to the other place foreign text enters an agent's context.
22
+ * `x pr review` exists because an agent cannot read the GitHub web UI, and a review body is
23
+ * written by anyone who can comment on the pull request: rendered as bare indented text it arrived
24
+ * in that agent's context indistinguishable from the command's own output, which is prompt
25
+ * injection with a shell attached.
26
+ *
27
+ * The fence is neutralised INSIDE the payload rather than deleted, so every word the reviewer
28
+ * wrote still reads, and the label is stripped of the three characters that would end the
29
+ * attribute. Influence only, and deliberately not sold as more: a fence tells a reader this text
30
+ * is data, and it can never stop one that decides otherwise.
31
+ */
32
+ export function commentBlock(id: string, lines: readonly string[]): readonly string[] {
33
+ const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
34
+ const body = lines.map((line) => line.replace(BLOCK_DELIMITER, '<\\$1'));
35
+ return [`${BLOCK_OPEN}"${label}">`, ...body, BLOCK_CLOSE];
36
+ }
@@ -78,12 +78,11 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
78
78
  /**
79
79
  * The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
80
80
  *
81
- * `packages/auth/src/tables.ts` exports them "so an app can paste them into a migration", and
82
- * nothing in the framework has ever applied them — while `x db gen` diffs `describeEntities()`
83
- * and these are not `entity()` declarations, so neither half was a file anybody could
84
- * hand-write. `examples/dummy/CLAUDE.md` records the consequence in its own words: nobody can
85
- * hold a session in the reference app. Applied here on exactly the rule the rate-limit and audit
86
- * rows already follow.
81
+ * Until this row nothing applied them, in dev or in production: they are not `entity()`
82
+ * declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
83
+ * into a migration nobody wrote — nobody could hold a session in the reference app. Those
84
+ * exports are gone; `AUTH_TABLES` is the whole DDL, the 1.3 `x_users` upgrade included, applied
85
+ * here on exactly the rule the rate-limit and audit rows already follow.
87
86
  *
88
87
  * `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
89
88
  * second copy is a second thing to keep right when a table is added.
@@ -0,0 +1,42 @@
1
+ // `x g <kind> --feature <f>` writes INTO a slice, and a slice that does not exist is refused rather
2
+ // than invented. It was invented: `x g task nightly --feature reports` laid down an
3
+ // `entity('reports', { title, price })` nobody asked for, and the `drift` step then demanded a
4
+ // migration for the table. Only `x g entity` and `x g resource` create a feature's data.
5
+
6
+ import { existsSync } from 'node:fs'; // why: a synchronous directory probe; Bun.file answers files, not directories.
7
+ import { UltimateError } from '@ultimat3/core';
8
+ import type { Generator } from './generate-kinds';
9
+ import { containedPath } from './generate-write';
10
+ import { quoteArg } from './shell-quote';
11
+
12
+ /** The generators whose `--feature` names an existing slice to write into. */
13
+ const WRITES_INTO_SLICE: ReadonlySet<Generator> = new Set<Generator>([
14
+ 'action',
15
+ 'mutator',
16
+ 'query',
17
+ 'job',
18
+ 'task',
19
+ ]);
20
+
21
+ /** Declared beside its one thrower: `errors.ts` sits at its 500-line ceiling. */
22
+ export class FeatureUnknownError extends UltimateError {
23
+ constructor(input: { readonly feature: string; readonly slice: string }) {
24
+ super({
25
+ code: 'X_FEATURE_UNKNOWN',
26
+ cause: `--feature ${quoteArg(input.feature)} names ${input.slice}, which does not exist — a generator writing into a slice never invents one, or its entity table`,
27
+ fix: `x g resource ${quoteArg(input.feature)}`,
28
+ });
29
+ }
30
+ }
31
+
32
+ /** Refuses a `--feature` whose slice directory is absent, for the kinds that write into one. */
33
+ export function assertFeatureExists(
34
+ root: string,
35
+ kind: Generator,
36
+ feature: string | undefined,
37
+ slice: string,
38
+ ): void {
39
+ if (feature === undefined || !WRITES_INTO_SLICE.has(kind)) return;
40
+ if (existsSync(containedPath(root, slice))) return;
41
+ throw new FeatureUnknownError({ feature, slice });
42
+ }
@@ -56,8 +56,8 @@ export interface GenerateOptions {
56
56
  */
57
57
  readonly sliceErrors?: string;
58
58
  /**
59
- * `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when the feature has no
60
- * entity yet. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
59
+ * `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
60
+ * the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
61
61
  * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
62
62
  * `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
63
63
  * `sliceDir(surface, feature)/entity.ts`.
@@ -84,7 +84,9 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
84
84
  const surface: Surface = options.surface ?? 'app';
85
85
  assertSurfaceSupported(options.kind, surface, options.name);
86
86
  const surfaceDir = DEFAULT_SURFACE_DIR[surface];
87
- const feature = options.feature ?? options.name;
87
+ // Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
88
+ // `app/blog-post/`, so one feature grew two slice directories depending on the generator.
89
+ const feature = kebab(options.feature ?? options.name);
88
90
  const target = { surfaceDir, feature };
89
91
  switch (options.kind) {
90
92
  case 'resource':
@@ -101,6 +103,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
101
103
  actionFiles(options.name, {
102
104
  ...target,
103
105
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
106
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
104
107
  }),
105
108
  );
106
109
  case 'mutator':
@@ -109,6 +112,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
109
112
  ...target,
110
113
  mutator: true,
111
114
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
115
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
112
116
  }),
113
117
  );
114
118
  case 'backfill':
@@ -0,0 +1,83 @@
1
+ // `x g policy` / `x g resource` grant what they declare. A generated `policy.ts` declares
2
+ // `<feature>:read` and `<feature>:write`; nothing granted them, so every generated endpoint
3
+ // answered 403 to every actor a role mints. The edit lands in the scaffold's one role map,
4
+ // `apps/web/shared/roles.ts`: `:read` to `member`, `:write` to `admin` (which inherits member).
5
+
6
+ import { containedPath } from './generate-write';
7
+ import { ROLES_FILE } from './permission-grants';
8
+ import { wrapList } from './templates/wrap';
9
+
10
+ /** One permission to add to one role's `grants`. */
11
+ export interface RoleGrant {
12
+ readonly role: string;
13
+ readonly permission: string;
14
+ }
15
+
16
+ /** A generated policy file: `<surface>/<feature>/policy.ts`, never a test beside it. */
17
+ const POLICY_PATH = /^apps\/[^/]+\/[^/]+\/(?<feature>[a-z0-9-]+)\/policy\.ts$/;
18
+
19
+ /**
20
+ * The grants a written file set implies: the lowest role that makes sense for each verb. `member`
21
+ * reads, `admin` writes — the scaffold's dev actor is `admin`, so both reach it through
22
+ * `inherits`, and a `member` cookie still cannot write.
23
+ */
24
+ export function grantsForWritten(written: readonly string[]): readonly RoleGrant[] {
25
+ return written.flatMap((path) => {
26
+ const feature = POLICY_PATH.exec(path)?.groups?.['feature'];
27
+ return feature === undefined
28
+ ? []
29
+ : [
30
+ { role: 'member', permission: `${feature}:read` },
31
+ { role: 'admin', permission: `${feature}:write` },
32
+ ];
33
+ });
34
+ }
35
+
36
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+
38
+ /**
39
+ * `source` with each grant added to its role's `grants: [...]`, re-wrapped the way Biome prints
40
+ * it. A role or a `grants` array the scaffold's shape does not have is left alone and returned in
41
+ * `skipped` — the `policy` step's X_PERMISSION_UNGRANTED names the edit, so a guess here would be
42
+ * a second, worse answer.
43
+ */
44
+ export function insertGrants(
45
+ source: string,
46
+ grants: readonly RoleGrant[],
47
+ ): { readonly source: string; readonly skipped: readonly RoleGrant[] } {
48
+ let next = source;
49
+ const skipped: RoleGrant[] = [];
50
+ for (const grant of grants) {
51
+ const role = new RegExp(`\\n ${escapeRegExp(grant.role)}: \\{`).exec(next);
52
+ const open = role === null ? -1 : next.indexOf('grants: [', role.index);
53
+ const close = open === -1 ? -1 : next.indexOf(']', open);
54
+ const blockEnd = role === null ? -1 : next.indexOf('\n },', role.index);
55
+ if (role === null || open === -1 || close === -1 || (blockEnd !== -1 && open > blockEnd)) {
56
+ skipped.push(grant);
57
+ continue;
58
+ }
59
+ const current = [...next.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
60
+ if (current.includes(grant.permission)) continue;
61
+ const entries = [...current, grant.permission].map((permission) => `'${permission}'`);
62
+ const lineStart = next.lastIndexOf('\n', open) + 1;
63
+ const indent = next.slice(lineStart, open);
64
+ const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length);
65
+ next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`;
66
+ }
67
+ return { source: next, skipped };
68
+ }
69
+
70
+ /** Performs `insertGrants` on the app's role map. Answers the paths it rewrote. */
71
+ export async function grantGeneratedPermissions(
72
+ root: string,
73
+ written: readonly string[],
74
+ ): Promise<readonly string[]> {
75
+ const grants = grantsForWritten(written);
76
+ const file = containedPath(root, ROLES_FILE);
77
+ if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
78
+ const before = await Bun.file(file).text();
79
+ const { source } = insertGrants(before, grants);
80
+ if (source === before) return [];
81
+ await Bun.write(file, source);
82
+ return [ROLES_FILE];
83
+ }
@@ -10,6 +10,7 @@ import {
10
10
  UnknownCommandError,
11
11
  } from './errors';
12
12
  import type { Surface } from './templates';
13
+ import { camel, kebab } from './templates/naming';
13
14
 
14
15
  export const GENERATORS = [
15
16
  'resource',
@@ -106,11 +107,62 @@ export function readSurface(raw: string | undefined, kind: Generator, name: stri
106
107
  * is a command that runs.
107
108
  */
108
109
  export function readName(raw: string | undefined, kind: Generator): string {
109
- if (raw !== undefined) return raw;
110
- throw new MissingPositionalError({
110
+ if (raw === undefined) {
111
+ throw new MissingPositionalError({
112
+ command: `g ${kind}`,
113
+ positional: 'name',
114
+ example: exampleFor(kind),
115
+ });
116
+ }
117
+ refusePath(raw, 'name', kind);
118
+ refuseBadIdentifier(raw, kind);
119
+ return raw;
120
+ }
121
+
122
+ /** `--feature`, refused where it is a path: it is a DIRECTORY under the surface, one segment. */
123
+ export function readFeature(raw: string | undefined, kind: Generator): string | undefined {
124
+ if (raw !== undefined) refusePath(raw, 'feature', kind);
125
+ return raw;
126
+ }
127
+
128
+ /** A name is one directory segment: `x g action ../../../evil` wrote outside `apps/`. */
129
+ function refusePath(raw: string, flag: string, kind: Generator): void {
130
+ if (!/[\\/]/.test(raw) && raw.trim() !== '..' && raw.trim() !== '.') return;
131
+ throw new BadFlagError({
132
+ flag,
133
+ command: `g ${kind}`,
134
+ reason: `"${raw}" is a path, and a generator ${flag} is one directory segment under the surface`,
135
+ fix: exampleFor(kind),
136
+ });
137
+ }
138
+
139
+ /** Strict-mode reserved words: `export const delete = …` does not parse. */
140
+ const RESERVED = new Set(
141
+ (
142
+ 'break case catch class const continue debugger default delete do else enum export extends ' +
143
+ 'false finally for function if import in instanceof new null return super switch this throw ' +
144
+ 'true try typeof var void while with yield let static implements interface package private ' +
145
+ 'protected public await arguments eval'
146
+ ).split(' '),
147
+ );
148
+
149
+ /**
150
+ * The name becomes `camel(name)` in emitted source, so it has to be an identifier there: not a
151
+ * reserved word, not starting with a digit. The fix is the same run with a name that is one — the
152
+ * kind appended to a reserved word, prepended to a leading digit.
153
+ */
154
+ function refuseBadIdentifier(raw: string, kind: Generator): void {
155
+ const identifier = camel(raw);
156
+ const digit = /^[0-9]/.test(identifier);
157
+ if (identifier !== '' && !digit && !RESERVED.has(identifier)) return;
158
+ const noun = kind.split(':').at(-1) ?? kind;
159
+ const suggestion = digit ? `${noun}-${kebab(raw)}` : `${kebab(raw)}-${noun}`;
160
+ throw new BadFlagError({
161
+ flag: 'name',
111
162
  command: `g ${kind}`,
112
- positional: 'name',
113
- example: exampleFor(kind),
163
+ reason: `"${raw}" becomes the identifier "${identifier}" in generated source, which ${digit ? 'starts with a digit' : identifier === '' ? 'is empty' : 'is a reserved word'}`,
164
+ // Joined, never spliced: the suggestion is kebab output, and the rule reads the shape.
165
+ fix: ['x g', kind, suggestion].join(' '),
114
166
  });
115
167
  }
116
168