@ultimat3/cli 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
package/src/exec.ts CHANGED
@@ -32,6 +32,12 @@ export interface ExecOptions {
32
32
  */
33
33
  readonly env?: Readonly<Record<string, string | undefined>>;
34
34
  readonly stdin?: string;
35
+ /**
36
+ * Told the child's pid the moment it exists. A gate step's deadline records it here
37
+ * (`verify-deadline.ts`): on Windows no process's environment is readable, so the tag sweep
38
+ * finds nothing and the pid recorded at spawn is the only handle on what the step started.
39
+ */
40
+ readonly onSpawn?: (pid: number) => void;
35
41
  }
36
42
 
37
43
  export type Runner = (command: readonly string[], options: ExecOptions) => Promise<ExecResult>;
@@ -112,6 +118,8 @@ export const exec: Runner = async (command, options) => {
112
118
  });
113
119
  }
114
120
  const proc = spawnOrRefuse([head, ...rest], options);
121
+ // Outside the refusal: a throwing observer is the caller's bug, never "could not run".
122
+ options.onSpawn?.(proc.pid);
115
123
  const [stdout, stderr, code] = await Promise.all([
116
124
  new Response(proc.stdout).text(),
117
125
  new Response(proc.stderr).text(),
package/src/favicon.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // it against the root is string work no `Bun.file` overload does — the same necessity
8
8
  // `runtime-assets.ts` records for `ICON_SOURCE`.
9
9
  import { join } from 'node:path';
10
- import { createRaster, encodeImage } from '@ultimat3/core';
10
+ import { blankRaster, encodeImage } from '@ultimat3/core';
11
11
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
12
12
  import { applyCacheHeaders } from '@ultimat3/http';
13
13
 
@@ -47,7 +47,7 @@ const FAVICON_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
47
47
  * is a third rung under a mechanism that has exactly two.
48
48
  */
49
49
  export function defaultFavicon(): Uint8Array {
50
- const raster = createRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
50
+ const raster = blankRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
51
51
  const { pixels } = raster;
52
52
  for (let i = 0; i < pixels.length; i += 4) {
53
53
  pixels[i] = MARK_LEVEL;
@@ -91,7 +91,15 @@ export interface FixCitation {
91
91
  * names two commands (`x db migrate, then confirm with x db query "…" --json`), and charging the
92
92
  * second command's flags to the first would report a finding on the wrong half of the sentence.
93
93
  */
94
- export function fixCitations(fix: string): readonly FixCitation[] {
94
+ /**
95
+ * The repo's in-repo spelling of the CLI, `bun run x -- <command>`, read as the citation it is —
96
+ * `x <command>` — so a fix written the way CLAUDE.md prescribes is resolved, not skipped.
97
+ */
98
+ export const asXCitation = (text: string): string =>
99
+ text.replace(/(^|[\s;|&("'`])bun\s+run\s+x\s+--(?=\s)/g, '$1x');
100
+
101
+ export function fixCitations(written: string): readonly FixCitation[] {
102
+ const fix = asXCitation(written);
95
103
  const matches = [...fix.matchAll(CITATION)].filter((match) => match[1] !== undefined);
96
104
  return matches.map((match, index) => {
97
105
  const start = match.index + match[0].length;
@@ -0,0 +1,148 @@
1
+ // Single responsibility: the keys the FRAMEWORK refuses to boot without once deployed — the one
2
+ // list `.env.example`, `x env check` and `x doctor` all read. An app's `envSchema` names its own
3
+ // variables; these are the framework's, and a deploy contract that omits them crash-loops every
4
+ // role one key at a time (#679).
5
+
6
+ import type { EnvSchema } from '@ultimat3/core';
7
+ import {
8
+ CURSOR_SECRET_FIX,
9
+ CURSOR_SECRET_KEY,
10
+ devSecretsRefused,
11
+ ENV_EXAMPLE_PATH,
12
+ ERROR_DOCS_URL,
13
+ renderEnvExample,
14
+ tryResolveEnvironment,
15
+ usesDevCursorSecret,
16
+ } from '@ultimat3/core';
17
+ import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
18
+ import type { Finding } from './output';
19
+ import { storageIsExternal } from './runtime-bindings';
20
+
21
+ type EnvTable = Readonly<Record<string, string | undefined>>;
22
+
23
+ /**
24
+ * Does a process booting on `env` refuse the shipped development secrets? Core's
25
+ * `devSecretsRefused` — the boot's own rule, which fails closed: a table naming no environment is
26
+ * production. The ONE predicate `x env check` and `x doctor` gate on, so neither can call green an
27
+ * environment the boot refuses. Throws `X_ENVIRONMENT_INVALID` on an unknown `ULTIMATE_ENV`.
28
+ */
29
+ export const deployed = (env: EnvTable): boolean => devSecretsRefused({ env });
30
+
31
+ /**
32
+ * Does `env` NAME a deployed environment — `ULTIMATE_ENV`, else `NODE_ENV`, resolving to
33
+ * `staging` or `production`? `x doctor`'s gate, and deliberately not `deployed`: the two commands
34
+ * answer different questions. `x env check` asks what a deployed boot does with a table, and the
35
+ * boot fails closed, so an unnamed table is a deploy there. `x doctor` asks about THIS machine,
36
+ * and a developer shell that names nothing is not a deploy — reporting the shipped keys there would
37
+ * put two findings in front of every developer on day one. An unknown `ULTIMATE_ENV` names
38
+ * nothing (`tryResolveEnvironment`): the boot refuses it first, and doctor must not crash on it.
39
+ */
40
+ export const namedDeployed = (env: EnvTable): boolean => {
41
+ const environment = tryResolveEnvironment({ env });
42
+ return environment === 'staging' || environment === 'production';
43
+ };
44
+
45
+ /** One framework secret: what refuses without it, when it is needed, and the command that mints one. */
46
+ export interface FrameworkSecret {
47
+ readonly key: string;
48
+ /** The diagnostic code `x doctor` and `x env check` report it under. */
49
+ readonly code: 'X_CURSOR_SECRET_DEV' | 'X_STORAGE_SECRET_DEV';
50
+ /** Why the framework needs it, one line, rendered into `.env.example`. */
51
+ readonly why: string;
52
+ /** When the key is needed at all, if not always — rendered beside `why`. */
53
+ readonly condition?: string;
54
+ readonly cause: string;
55
+ /** Pasted into a shell, so it is a literal command and never a paraphrase. */
56
+ readonly fix: string;
57
+ /** False when this deploy's shape never reads the key (object storage configured). */
58
+ needed(env: EnvTable): boolean;
59
+ /** True while the table would leave the process signing with the shipped development key. */
60
+ unsafe(env: EnvTable): boolean;
61
+ }
62
+
63
+ /** In declaration order — the order `.env.example` renders them in. */
64
+ export const FRAMEWORK_SECRETS: readonly FrameworkSecret[] = [
65
+ {
66
+ key: CURSOR_SECRET_KEY,
67
+ code: 'X_CURSOR_SECRET_DEV',
68
+ why: 'Signs pagination cursors. Unset, every role refuses to boot (X_CURSOR_SECRET_DEV).',
69
+ cause:
70
+ 'cursors are signed with the shipped development key, so a client can forge a page position',
71
+ // Core's, verbatim: the boot's own refusal prints this line for this code.
72
+ fix: CURSOR_SECRET_FIX,
73
+ needed: () => true,
74
+ unsafe: (env) => usesDevCursorSecret({ env }),
75
+ },
76
+ {
77
+ key: STORAGE_SIGNING_SECRET_KEY,
78
+ code: 'X_STORAGE_SECRET_DEV',
79
+ why: 'Signs upload grants on the embedded disk. Unset, every role refuses to boot (X_ENV_MISSING).',
80
+ condition: 'Not read when S3_ENDPOINT and S3_BUCKET select object storage.',
81
+ cause: `${STORAGE_SIGNING_SECRET_KEY} is unset or holds the shipped development key, so a local-disk deploy would accept forged upload grants that override its own uploadPolicy`,
82
+ fix: `export ${STORAGE_SIGNING_SECRET_KEY}="$(openssl rand -hex 32)"`,
83
+ // The boot's own storage choice (`runtime-bindings.ts`), never restated. NOT "the app declares
84
+ // no storage": the boot builds its disk before any app module loads, and an app reaches it
85
+ // through a bare `disk()` with no `defineStorage` of its own
86
+ // (`examples/dummy/apps/web/app/orgs/avatar.ts`), so there is no declaration to read.
87
+ needed: (env) => !storageIsExternal(env),
88
+ unsafe: (env) => usesDevStorageSecret({ env }),
89
+ },
90
+ ];
91
+
92
+ /** The doctor/check finding for one secret — one wording per condition, wherever it is reported. */
93
+ export function frameworkSecretFinding(secret: FrameworkSecret): Finding {
94
+ return { code: secret.code, cause: secret.cause, fix: secret.fix, docs: ERROR_DOCS_URL };
95
+ }
96
+
97
+ /** Every secret this table's deploy shape needs and lacks, ungated. */
98
+ const owedFindings = (env: EnvTable, skip: ReadonlySet<string>): readonly Finding[] =>
99
+ FRAMEWORK_SECRETS.filter(
100
+ (secret) => !skip.has(secret.key) && secret.needed(env) && secret.unsafe(env),
101
+ ).map((secret) => ({ ...frameworkSecretFinding(secret), at: ENV_EXAMPLE_PATH }));
102
+
103
+ /**
104
+ * `x env check`'s: what a process booting on this table would refuse — gated by `deployed`, the
105
+ * boot's rule, so an unnamed table owes what production owes. `skip` names keys the caller already
106
+ * reported under another code, so one key is one finding. Throws `X_ENVIRONMENT_INVALID` on an
107
+ * unknown `ULTIMATE_ENV`, as the boot does.
108
+ */
109
+ export function frameworkSecretFindings(
110
+ env: EnvTable,
111
+ skip: ReadonlySet<string> = new Set(),
112
+ ): readonly Finding[] {
113
+ return deployed(env) ? owedFindings(env, skip) : [];
114
+ }
115
+
116
+ /** `x doctor`'s: the same list and wording, gated by `namedDeployed` — this machine, as named. */
117
+ export function machineSecretFindings(env: EnvTable): readonly Finding[] {
118
+ return namedDeployed(env) ? owedFindings(env, new Set()) : [];
119
+ }
120
+
121
+ /**
122
+ * The framework's section of `.env.example`, after the app's own. A key the app's schema already
123
+ * declares is skipped: one key, one line, and the app's declaration is the one it validates.
124
+ * Blank values only — these are secrets, and this file is committed.
125
+ */
126
+ function frameworkSection(schema: EnvSchema): string {
127
+ const owed = FRAMEWORK_SECRETS.filter((secret) => !Object.hasOwn(schema, secret.key));
128
+ if (owed.length === 0) return '';
129
+ const lines = [
130
+ '',
131
+ '# --- Framework ---------------------------------------------------------------',
132
+ '# Required outside development/test, whatever envSchema declares. Generate each with',
133
+ '# `openssl rand -hex 32`; `x env check` and `x doctor` report one that is missing.',
134
+ ];
135
+ for (const secret of owed) {
136
+ lines.push('', `# ${secret.why}`);
137
+ if (secret.condition !== undefined) lines.push(`# ${secret.condition}`);
138
+ lines.push('# required when deployed · string · secret', `${secret.key}=`);
139
+ }
140
+ return `${lines.join('\n')}\n`;
141
+ }
142
+
143
+ /**
144
+ * The bytes `.env.example` must hold: the app's declaration, then the framework's keys. The ONE
145
+ * renderer `x env example`, the drift gate and `x new` share, so the three cannot disagree.
146
+ */
147
+ export const appEnvExample = (schema: EnvSchema): string =>
148
+ `${renderEnvExample(schema)}${frameworkSection(schema)}`;
@@ -0,0 +1,78 @@
1
+ // The two ways a boot meets the framework schema (plan 101, s1-con #5): `applyLockedSchema` runs the
2
+ // DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
3
+ // stamps the build; `verifySchema` runs no DDL and refuses a database this build was never applied
4
+ // to. Which boot takes which is `runtime-queue.ts`'s `SchemaMode`.
5
+
6
+ import { frameworkVersion, logger } from '@ultimat3/core';
7
+ import type { DbClient } from '@ultimat3/db';
8
+ import {
9
+ literal,
10
+ MIGRATION_LOCK_KEY,
11
+ MIGRATION_LOCK_WAIT_MS,
12
+ poolProfileFor,
13
+ raw,
14
+ withTransaction,
15
+ } from '@ultimat3/db';
16
+ import { applyFrameworkSchema } from './framework-schema';
17
+ import {
18
+ FrameworkSchemaUnappliedError,
19
+ frameworkSchemaHash,
20
+ nextStamp,
21
+ parseStamp,
22
+ STAMP_RELATION,
23
+ type StampEntry,
24
+ stampVerdict,
25
+ } from './framework-schema-stamp';
26
+
27
+ const thisBuild = (): StampEntry => ({
28
+ version: frameworkVersion(),
29
+ schema: frameworkSchemaHash(),
30
+ });
31
+
32
+ /** The stamp as the catalog holds it; `null` when the relation or its comment is absent. */
33
+ async function readStamp(client: DbClient): Promise<string | null> {
34
+ const rows = await client.query<{ readonly stamp: string | null }>(
35
+ raw(`select obj_description(to_regclass('${STAMP_RELATION}'), 'pg_class') as stamp`),
36
+ );
37
+ return rows[0]?.stamp ?? null;
38
+ }
39
+
40
+ /**
41
+ * The migration lock, not a lock of its own: a second migrator — another `ROLE=migrate` pod, an
42
+ * `x db migrate` — waits behind this one instead of racing its `create table`s into `23505`. A
43
+ * transaction-scoped lock (`pg_advisory_xact_lock`), so no pinned session: COMMIT releases it.
44
+ *
45
+ * Two `lock_timeout`s: the lock wait is the migrator's own (`MIGRATION_LOCK_WAIT_MS`), and every
46
+ * DDL statement after it is the migrate pool's (`poolProfileFor('migrate')`), so an `alter table`
47
+ * behind an open transaction fails in seconds rather than queueing every enqueue behind it.
48
+ */
49
+ export async function applyLockedSchema(client: DbClient): Promise<void> {
50
+ await withTransaction(
51
+ async (tx) => {
52
+ await tx.execute(raw(`set local lock_timeout = ${Math.round(MIGRATION_LOCK_WAIT_MS)}`));
53
+ await tx.execute(raw(`select pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`));
54
+ const ddlTimeoutMs = Math.round(poolProfileFor('migrate').lockTimeoutMs);
55
+ await tx.execute(raw(`set local lock_timeout = ${ddlTimeoutMs}`));
56
+ await applyFrameworkSchema((statement) => tx.execute(raw(statement)));
57
+ const stamp = nextStamp(await readStamp(tx), thisBuild());
58
+ await tx.execute(raw(`comment on table ${STAMP_RELATION} is ${literal(stamp).text}`));
59
+ },
60
+ { client },
61
+ );
62
+ }
63
+
64
+ /**
65
+ * One catalog read. A build absent from the stamp is refused with the command that applies it; a
66
+ * fleet straddling a major is reported once and served — refusing it would crash-loop the old pods
67
+ * of every rolling deploy across a breaking release.
68
+ */
69
+ export async function verifySchema(client: DbClient): Promise<void> {
70
+ const mine = thisBuild();
71
+ const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
72
+ if (!verdict.applied) throw new FrameworkSchemaUnappliedError({ mine, newest: verdict.newest });
73
+ if (verdict.skew === undefined) return;
74
+ logger.warn('ultimate framework major skew', {
75
+ cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${verdict.skew.version}: a fleet across a major boundary is mid-rollout or half rolled back`,
76
+ fix: 'kubectl rollout status deployment --selector app.kubernetes.io/component=web',
77
+ });
78
+ }
@@ -0,0 +1,96 @@
1
+ // Which builds have applied the framework schema to this database: a stamp `ROLE=migrate` leaves
2
+ // in `x_jobs`' table comment, and the verdict a serving role reads from it instead of running DDL
3
+ // itself (plan 101, s1-con #5). Pure: the statements live in `framework-schema-apply.ts`.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import { FRAMEWORK_SCHEMA } from './framework-schema';
7
+
8
+ /**
9
+ * The relation that carries the stamp. A comment, not a table: a new framework table moves every
10
+ * app's committed schema dump (`packages/db/schema/framework/`), and a comment is in none of them.
11
+ */
12
+ export const STAMP_RELATION = 'x_jobs';
13
+
14
+ /** The first line of a stamp — what tells it apart from a comment someone else wrote. */
15
+ const STAMP_HEADER = 'ultimate-framework-schema';
16
+
17
+ /** Enough for a rolling deploy and its rollback, small enough to stay one readable comment. */
18
+ const STAMP_KEEP = 16;
19
+
20
+ export interface StampEntry {
21
+ readonly version: string;
22
+ readonly schema: string;
23
+ }
24
+
25
+ /** This build's DDL, as one short hash: two builds with the same DDL are the same schema. */
26
+ export function frameworkSchemaHash(): string {
27
+ const hasher = new Bun.CryptoHasher('sha256');
28
+ for (const entry of FRAMEWORK_SCHEMA) for (const ddl of entry.ddl) hasher.update(ddl);
29
+ return hasher.digest('hex').slice(0, 16);
30
+ }
31
+
32
+ /** Every build a stamp records, newest first. Anything that is not a stamp records none. */
33
+ export function parseStamp(comment: string | null | undefined): readonly StampEntry[] {
34
+ if (typeof comment !== 'string') return [];
35
+ const [header, ...lines] = comment.split('\n');
36
+ if (header !== STAMP_HEADER) return [];
37
+ return lines.flatMap((line) => {
38
+ const [version, schema] = line.trim().split(' ');
39
+ return version !== undefined &&
40
+ /^[0-9A-Za-z.+-]{1,64}$/.test(version) &&
41
+ schema !== undefined &&
42
+ /^[0-9a-f]{16}$/.test(schema)
43
+ ? [{ version, schema }]
44
+ : [];
45
+ });
46
+ }
47
+
48
+ /** The stamp after `mine` applied: it first, every other build kept, the oldest dropped. */
49
+ export function nextStamp(previous: string | null | undefined, mine: StampEntry): string {
50
+ const kept = parseStamp(previous).filter((entry) => entry.schema !== mine.schema);
51
+ const entries = [mine, ...kept].slice(0, STAMP_KEEP);
52
+ return [STAMP_HEADER, ...entries.map((entry) => `${entry.version} ${entry.schema}`)].join('\n');
53
+ }
54
+
55
+ const majorOf = (version: string): string => version.split('.')[0] ?? version;
56
+
57
+ export type StampVerdict =
58
+ | { readonly applied: true; readonly skew?: StampEntry }
59
+ | { readonly applied: false; readonly newest?: StampEntry };
60
+
61
+ /**
62
+ * Applied when this build's hash is anywhere in the stamp: an old pod restarting mid-rollout finds
63
+ * its own build there, behind the new one. `skew` is the newest build when its MAJOR differs from
64
+ * this process's — a fleet straddling a breaking release, which a boot reports and never refuses.
65
+ */
66
+ export function stampVerdict(entries: readonly StampEntry[], mine: StampEntry): StampVerdict {
67
+ const newest = entries[0];
68
+ if (!entries.some((entry) => entry.schema === mine.schema)) {
69
+ return newest === undefined ? { applied: false } : { applied: false, newest };
70
+ }
71
+ return newest !== undefined && majorOf(newest.version) !== majorOf(mine.version)
72
+ ? { applied: true, skew: newest }
73
+ : { applied: true };
74
+ }
75
+
76
+ /** A serving role over a database its build's framework tables were never applied to. */
77
+ export class FrameworkSchemaUnappliedError extends UltimateError {
78
+ constructor(input: { readonly mine: StampEntry; readonly newest: StampEntry | undefined }) {
79
+ super({
80
+ code: 'X_FRAMEWORK_SCHEMA_UNAPPLIED',
81
+ cause:
82
+ `this build (${input.mine.version}, framework schema ${input.mine.schema}) has not been applied ` +
83
+ `to this database — ${
84
+ input.newest === undefined
85
+ ? `${STAMP_RELATION} carries no stamp, so no ROLE=migrate of this release ran`
86
+ : `the newest applied build is ${input.newest.version} (${input.newest.schema})`
87
+ }. A serving role verifies and never runs DDL: the migrate step must run first`,
88
+ fix: 'x db migrate',
89
+ meta: {
90
+ version: input.mine.version,
91
+ schema: input.mine.schema,
92
+ ...(input.newest === undefined ? {} : { newest: { ...input.newest } }),
93
+ },
94
+ });
95
+ }
96
+ }
@@ -11,7 +11,12 @@ import { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from '@ultimat3/admin/schema
11
11
  import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
12
12
  import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
13
13
  import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
14
- import { SQL_NOTIFY_DELIVERIES_TABLE, SQL_NOTIFY_INBOX_TABLE } from '@ultimat3/notify';
14
+ import { MCP_CONFIRMATIONS_TABLE, SQL_MCP_CONFIRMATIONS_TABLE } from '@ultimat3/mcp/schema';
15
+ import {
16
+ SQL_NOTIFY_DELIVERIES_TABLE,
17
+ SQL_NOTIFY_DIGESTS_TABLE,
18
+ SQL_NOTIFY_INBOX_TABLE,
19
+ } from '@ultimat3/notify';
15
20
  import { FrameworkSchemaFailedError } from './schema-errors';
16
21
 
17
22
  export interface FrameworkSchema {
@@ -76,11 +81,11 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
76
81
  }),
77
82
  Object.freeze({
78
83
  pkg: '@ultimat3/auth',
79
- tables: Object.freeze(['x_auth_failures', 'x_auth_lockouts']),
84
+ tables: Object.freeze(['x_auth_lockouts']),
80
85
  ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
81
86
  }),
82
87
  /**
83
- * The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
88
+ * The five tables `PostgresAuthAdapter` reads, and the oldest hole in this list.
84
89
  *
85
90
  * Until this row nothing applied them, in dev or in production: they are not `entity()`
86
91
  * declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
@@ -113,6 +118,13 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
113
118
  tables: Object.freeze(['x_notify_inbox']),
114
119
  ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
115
120
  }),
121
+ // `postgresDigestStore`'s windows. Installed whether or not the app picks that store, for the
122
+ // reason every notify row is: `setNotifyStores` is the app's boot line and runs after this one.
123
+ Object.freeze({
124
+ pkg: '@ultimat3/notify',
125
+ tables: Object.freeze(['x_notify_digests']),
126
+ ddl: Object.freeze([SQL_NOTIFY_DIGESTS_TABLE]),
127
+ }),
116
128
  /**
117
129
  * The admin's durable audit trail — what `postgresAuditLog()` writes and every detail page's
118
130
  * history card reads. Without this row an app that chose the durable log met `42P01` on the
@@ -124,6 +136,16 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
124
136
  tables: Object.freeze([ADMIN_AUDIT_TABLE]),
125
137
  ddl: Object.freeze([SQL_ADMIN_AUDIT_TABLE]),
126
138
  }),
139
+ /**
140
+ * `mcpConfirmations`' pending rows — what `postgresConfirmationStore()` reads and writes. Without
141
+ * this row the first gated `tools/call` met `42P01`. Through `@ultimat3/mcp/schema`, a leaf
142
+ * module that imports nothing, for the admin row's reason: no MCP server on a worker's path.
143
+ */
144
+ Object.freeze({
145
+ pkg: '@ultimat3/mcp',
146
+ tables: Object.freeze([MCP_CONFIRMATIONS_TABLE]),
147
+ ddl: Object.freeze([SQL_MCP_CONFIRMATIONS_TABLE]),
148
+ }),
127
149
  ]);
128
150
 
129
151
  /** Every relation this boot creates, flattened. */
@@ -0,0 +1,79 @@
1
+ // The catalogs a generator emits, made true for every locale they target. A template speaks the
2
+ // default locale only, so its strings are copied MARKED into every other locale — the seed
3
+ // `x i18n add` and `x i18n sync` write — and a locale the app has no catalog for yet is refused
4
+ // with `x i18n add <locale>`, the one command that starts a catalog holding every existing key.
5
+
6
+ import { renderFixShellArg } from '@ultimat3/core';
7
+ import { type Catalog, loadCatalog } from '@ultimat3/i18n';
8
+ import { loadAppCatalogs } from '@ultimat3/i18n/app-catalogs';
9
+ import { BadFlagError } from './errors';
10
+ import { resolveDefaultLocale, seedCatalog, serializeCatalog } from './i18n-audit';
11
+ import { catalogLocales } from './i18n-index';
12
+ import type { GeneratedFile } from './templates';
13
+ import { CATALOG_ROOT, catalogPath } from './templates';
14
+
15
+ /** The locale a `merge: 'json'` file is the catalog of, or `undefined` for any other file. */
16
+ const localeOf = (file: GeneratedFile): string | undefined => {
17
+ if (file.merge !== 'json') return undefined;
18
+ const prefix = `${CATALOG_ROOT}/`;
19
+ if (!file.path.startsWith(prefix) || !file.path.endsWith('.json')) return undefined;
20
+ const stem = file.path.slice(prefix.length, -'.json'.length);
21
+ return stem.includes('/') ? undefined : stem;
22
+ };
23
+
24
+ /**
25
+ * The catalogs' declared default (`defineCatalogs({ default })`), or `undefined` when the app has no
26
+ * catalog module or it will not load — `x g` is not the command that refuses an app over it: the
27
+ * manifest load after the write reports that, with its own fix.
28
+ */
29
+ async function declaredDefault(root: string): Promise<string | undefined> {
30
+ try {
31
+ return (await loadAppCatalogs(root))?.defaultLocale;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ const asLocaleSet = (locales: readonly string[]): Readonly<Record<string, Catalog>> =>
38
+ Object.fromEntries(locales.map((locale) => [locale, {}]));
39
+
40
+ /**
41
+ * `files` with every non-default catalog's values marked. The default is resolved by
42
+ * `x i18n add`'s own rule (`resolveDefaultLocale`) over the catalogs on disk, and — in an app with
43
+ * none yet — over the catalogs this run starts, so a first `--locales en,es` writes `en` bare and
44
+ * `es` marked, both holding the same keys.
45
+ *
46
+ * Refuses, before anything is planned to disk, a non-default locale with no catalog in an app that
47
+ * HAS catalogs: a file started here would hold this generator's keys and none of the app's, which
48
+ * the scaffold's "every locale has the same keys" test and `x i18n check` both answer red.
49
+ */
50
+ export async function localiseCatalogs(
51
+ root: string,
52
+ files: readonly GeneratedFile[],
53
+ ): Promise<readonly GeneratedFile[]> {
54
+ const targeted = [...new Set(files.flatMap((file) => localeOf(file) ?? []))];
55
+ if (targeted.length === 0) return files;
56
+ const onDisk = await catalogLocales(root);
57
+ const declared = await declaredDefault(root);
58
+ const fallback =
59
+ resolveDefaultLocale(declared, asLocaleSet(onDisk)) ??
60
+ resolveDefaultLocale(declared, asLocaleSet(targeted));
61
+ if (onDisk.length > 0) {
62
+ const absent = targeted.find((locale) => locale !== fallback && !onDisk.includes(locale));
63
+ if (absent !== undefined) {
64
+ throw new BadFlagError({
65
+ flag: 'locales',
66
+ command: 'g',
67
+ reason: `no catalog for "${absent}" at ${catalogPath(absent)}, and one started by a generator would hold only its own keys`,
68
+ fix: `x i18n add ${renderFixShellArg(absent, '<locale>')}`,
69
+ });
70
+ }
71
+ }
72
+ return files.map((file) => {
73
+ const locale = localeOf(file);
74
+ if (file.merge !== 'json' || locale === undefined || locale === fallback) return file;
75
+ if (fallback === undefined) return file;
76
+ const generated: unknown = JSON.parse(file.contents);
77
+ return { ...file, contents: serializeCatalog(seedCatalog(loadCatalog(generated))) };
78
+ });
79
+ }
@@ -2,7 +2,8 @@
2
2
  // `cmd-generate.ts` so a generator's output can be asserted on — by the generator tests, the
3
3
  // scaffold fixture and `x new` — without a command line, an app root or a filesystem.
4
4
 
5
- import { BadFlagError, CliNotImplementedError } from './errors';
5
+ import { NotImplementedError } from '@ultimat3/core';
6
+ import { BadFlagError } from './errors';
6
7
  import type { Generator } from './generate-kinds';
7
8
  import { assertLiveSupported, assertSurfaceSupported, GENERATORS } from './generate-kinds';
8
9
  import { dedupe } from './generate-write';
@@ -233,8 +234,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
233
234
  }),
234
235
  );
235
236
  default:
236
- throw new CliNotImplementedError({
237
- feature: `generator "${String(options.kind)}"`,
237
+ throw new NotImplementedError({
238
+ cause: `generator "${String(options.kind)}" is not implemented in this build`,
238
239
  fix: `x g ${GENERATORS.join('|')}`,
239
240
  });
240
241
  }
@@ -137,10 +137,28 @@ export function readName(raw: string | undefined, kind: Generator): string {
137
137
  // directory that serves it. Every other generator's name is one directory segment.
138
138
  if (kind === 'route') return readRoutePath(raw);
139
139
  refusePath(raw, 'name', kind);
140
+ refuseNonAscii(raw, kind);
140
141
  refuseBadIdentifier(raw, kind);
142
+ // A plural name is refused on the PLAN (`generate-plural.ts`): which kinds pluralise it into a
143
+ // table is a fact about the files they write, not a list kept here.
141
144
  return raw;
142
145
  }
143
146
 
147
+ /**
148
+ * Every derived spelling keeps `[a-zA-Z0-9]` and drops the rest as a separator, so a letter
149
+ * outside ASCII vanished: `x g entity Über` wrote `ber`. Refused, with the name folded to ASCII.
150
+ */
151
+ function refuseNonAscii(raw: string, kind: Generator): void {
152
+ if (!/[^\p{ASCII}]/u.test(raw)) return;
153
+ const folded = kebab(raw.normalize('NFKD').replace(/[^\p{ASCII}]/gu, ''));
154
+ throw new BadFlagError({
155
+ flag: 'name',
156
+ command: `g ${kind}`,
157
+ reason: `"${raw}" holds a letter outside ASCII, and every file, identifier and table name the generator derives from it keeps ASCII letters and digits only`,
158
+ fix: folded === '' ? exampleFor(kind) : ['x g', kind, folded].join(' '),
159
+ });
160
+ }
161
+
144
162
  /** A static segment, or `[param]` / `[...rest]` — the directory convention `x routes` reads. */
145
163
  const ROUTE_SEGMENT = /^(?:[A-Za-z0-9][A-Za-z0-9_-]*|\[(?:\.\.\.)?[A-Za-z][A-Za-z0-9_-]*\])$/;
146
164
 
@@ -0,0 +1,50 @@
1
+ // A plural slice name pluralised again into a table: `x g resource posts` declared
2
+ // `entity('postses')`, and `x g query top-posts` — whose plan writes the slice's missing
3
+ // `entity.ts` — printed `create top_postses`. Decided on the planned files, so every generator
4
+ // that writes an entity is covered the day it starts to, and none that writes none is judged.
5
+
6
+ import { BadFlagError } from './errors';
7
+ import type { Generator } from './generate-kinds';
8
+ import type { GeneratedFile } from './templates';
9
+ import { kebab, names, singularOf } from './templates/naming';
10
+
11
+ /** The slice directory of every `entity.ts` this plan would actually write. */
12
+ function writtenEntitySlices(
13
+ files: readonly GeneratedFile[],
14
+ exists: (path: string) => boolean,
15
+ ): readonly string[] {
16
+ return files.flatMap((file) => {
17
+ if (!file.path.endsWith('/entity.ts')) return [];
18
+ // A slice module is written only when the slice lacks it: an `entity.ts` already on disk
19
+ // declares whatever table its author chose, and this run adds no table at all.
20
+ if (file.merge === 'if-absent' && exists(file.path)) return [];
21
+ return [file.path.split('/').at(-2) ?? ''];
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Refuses a plan that writes an `entity.ts` into a slice whose name is already plural. The fix
27
+ * names the singular where the plural came from: the generator's name, or `--feature`.
28
+ */
29
+ export function refusePluralTable(
30
+ files: readonly GeneratedFile[],
31
+ kind: Generator,
32
+ name: string,
33
+ feature: string | undefined,
34
+ exists: (path: string) => boolean,
35
+ ): void {
36
+ for (const slice of writtenEntitySlices(files, exists)) {
37
+ const singular = singularOf(slice);
38
+ if (singular === undefined) continue;
39
+ const fromFeature = feature !== undefined && kebab(feature) === slice;
40
+ const word = fromFeature ? feature : name;
41
+ throw new BadFlagError({
42
+ flag: fromFeature ? 'feature' : 'name',
43
+ command: `g ${kind}`,
44
+ reason: `"${word}" is already plural, and the entity this run writes pluralises it again for its table — "${names(slice).table}"`,
45
+ fix: fromFeature
46
+ ? ['x g', kind, name, '--feature', singular].join(' ')
47
+ : ['x g', kind, singular].join(' '),
48
+ });
49
+ }
50
+ }