@ultimat3/cli 24.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 (242) 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 +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
@@ -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)}`;
@@ -11,6 +11,7 @@ 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 { MCP_CONFIRMATIONS_TABLE, SQL_MCP_CONFIRMATIONS_TABLE } from '@ultimat3/mcp/schema';
14
15
  import {
15
16
  SQL_NOTIFY_DELIVERIES_TABLE,
16
17
  SQL_NOTIFY_DIGESTS_TABLE,
@@ -84,7 +85,7 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
84
85
  ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
85
86
  }),
86
87
  /**
87
- * 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.
88
89
  *
89
90
  * Until this row nothing applied them, in dev or in production: they are not `entity()`
90
91
  * declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
@@ -117,7 +118,7 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
117
118
  tables: Object.freeze(['x_notify_inbox']),
118
119
  ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
119
120
  }),
120
- // `createPgDigestStore`'s windows. Installed whether or not the app picks that store, for the
121
+ // `postgresDigestStore`'s windows. Installed whether or not the app picks that store, for the
121
122
  // reason every notify row is: `setNotifyStores` is the app's boot line and runs after this one.
122
123
  Object.freeze({
123
124
  pkg: '@ultimat3/notify',
@@ -135,6 +136,16 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
135
136
  tables: Object.freeze([ADMIN_AUDIT_TABLE]),
136
137
  ddl: Object.freeze([SQL_ADMIN_AUDIT_TABLE]),
137
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
+ }),
138
149
  ]);
139
150
 
140
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
+ }
@@ -11,7 +11,7 @@ import {
11
11
  } from './errors';
12
12
  import { quoteArg } from './shell-quote';
13
13
  import type { Surface } from './templates';
14
- import { camel, kebab, plural, singularOf } from './templates/naming';
14
+ import { camel, kebab } from './templates/naming';
15
15
 
16
16
  export const GENERATORS = [
17
17
  'resource',
@@ -139,7 +139,8 @@ export function readName(raw: string | undefined, kind: Generator): string {
139
139
  refusePath(raw, 'name', kind);
140
140
  refuseNonAscii(raw, kind);
141
141
  refuseBadIdentifier(raw, kind);
142
- refusePlural(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.
143
144
  return raw;
144
145
  }
145
146
 
@@ -158,21 +159,6 @@ function refuseNonAscii(raw: string, kind: Generator): void {
158
159
  });
159
160
  }
160
161
 
161
- /** The generators that pluralise their name into a table and a page. */
162
- const PLURALISED: ReadonlySet<Generator> = new Set<Generator>(['resource', 'entity']);
163
-
164
- /** `x g resource posts` declared `entity('postses')`: a plural name is pluralised again. */
165
- function refusePlural(raw: string, kind: Generator): void {
166
- const singular = PLURALISED.has(kind) ? singularOf(raw) : undefined;
167
- if (singular === undefined) return;
168
- throw new BadFlagError({
169
- flag: 'name',
170
- command: `g ${kind}`,
171
- reason: `"${raw}" is already plural, and the generator pluralises the name for its table and page — "${plural(camel(raw))}"`,
172
- fix: ['x g', kind, singular].join(' '),
173
- });
174
- }
175
-
176
162
  /** A static segment, or `[param]` / `[...rest]` — the directory convention `x routes` reads. */
177
163
  const ROUTE_SEGMENT = /^(?:[A-Za-z0-9][A-Za-z0-9_-]*|\[(?:\.\.\.)?[A-Za-z][A-Za-z0-9_-]*\])$/;
178
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
+ }
@@ -2,11 +2,13 @@
2
2
  // `x g resource promise` imports `type { Promise }` beside `Promise<Row>`, and `row` writes
3
3
  // `type Row = Row`. Decided on the planned files themselves, so a template that starts using
4
4
  // another global is covered the day it does — and the names that clash nowhere stay allowed.
5
+ // The same for a VALUE: `x g job job` imports `{ job }` from `@ultimat3/jobs` and declares
6
+ // `export const job = job(…)` beside it, which is TS2440 in the module the run just wrote.
5
7
 
6
8
  import { BadFlagError } from './errors';
7
9
  import type { Generator } from './generate-kinds';
8
10
  import type { GeneratedFile } from './templates';
9
- import { kebab, names } from './templates/naming';
11
+ import { camel, kebab, names } from './templates/naming';
10
12
 
11
13
  /** The emitted file that imports `type` from the slice AND uses the same name as its own. */
12
14
  function shadowingFile(files: readonly GeneratedFile[], type: string): string | undefined {
@@ -44,3 +46,50 @@ export function refuseShadowedTypes(
44
46
  fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
45
47
  });
46
48
  }
49
+
50
+ /** A `{ … }` value import's local names — `type` specifiers and `import type` lines excluded. */
51
+ const VALUE_IMPORT = /^import\s+\{([^}]*)\}\s+from\s+'[^']+';/gm;
52
+
53
+ const importedValues = (source: string): readonly string[] =>
54
+ [...source.matchAll(VALUE_IMPORT)].flatMap((match) =>
55
+ (match[1] ?? '')
56
+ .split(',')
57
+ .map((specifier) => specifier.trim())
58
+ .filter((specifier) => specifier !== '' && !specifier.startsWith('type '))
59
+ .map((specifier) => specifier.split(/\s+as\s+/).at(-1) ?? specifier),
60
+ );
61
+
62
+ /** The emitted file that imports `value` AND declares a binding of the same name. */
63
+ function redeclaringFile(files: readonly GeneratedFile[], value: string): string | undefined {
64
+ // A camel spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
65
+ const declared = new RegExp(
66
+ `^\\s*(?:export\\s+)?(?:const|let|var|function|class)\\s+${value}\\b`,
67
+ 'm',
68
+ );
69
+ return files.find(
70
+ (file) =>
71
+ typeof file.contents === 'string' &&
72
+ /\.tsx?$/.test(file.path) &&
73
+ declared.test(file.contents) &&
74
+ importedValues(file.contents).includes(value),
75
+ )?.path;
76
+ }
77
+
78
+ /** Refuses `name` when its camel spelling is a value a planned file both imports and declares. */
79
+ export function refuseShadowedValues(
80
+ files: readonly GeneratedFile[],
81
+ kind: Generator,
82
+ name: string,
83
+ ): void {
84
+ const value = camel(name);
85
+ if (value === '') return;
86
+ const file = redeclaringFile(files, value);
87
+ if (file === undefined) return;
88
+ const noun = kind.split(':').at(-1) ?? kind;
89
+ throw new BadFlagError({
90
+ flag: 'name',
91
+ command: `g ${kind}`,
92
+ reason: `"${name}" becomes the binding ${value}, and ${file} imports ${value} and declares its own — a module that declares a name it imports does not compile (TS2440)`,
93
+ fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
94
+ });
95
+ }
package/src/hold.ts CHANGED
@@ -48,6 +48,18 @@ export function holdUntilShutdown(
48
48
  release: () => Promise<void>,
49
49
  options: HoldOptions = {},
50
50
  ): () => Promise<void> {
51
+ return armHold(name, release, options).hold;
52
+ }
53
+
54
+ /** A hold whose handlers are already listening, and the way to give them back unheld. */
55
+ interface ArmedHold {
56
+ readonly hold: () => Promise<void>;
57
+ /** Uninstall the handlers and the hook without waiting for any shutdown — a boot that failed. */
58
+ readonly disarm: () => void;
59
+ }
60
+
61
+ /** The signal handlers and the `accept` hook, installed NOW; the release waits for `hold()`. */
62
+ function armHold(name: string, release: () => Promise<void>, options: HoldOptions): ArmedHold {
51
63
  const uninstall = installSignalHandlers({ exit: false });
52
64
  let unregister = (): void => {};
53
65
  // The hook's own `reason`, not a stopwatch of ours: `deadlineAt` is the instant core computed
@@ -64,7 +76,7 @@ export function holdUntilShutdown(
64
76
  });
65
77
 
66
78
  let held: Promise<void> | undefined;
67
- return () => {
79
+ const hold = (): Promise<void> => {
68
80
  // Memoised: awaiting a hold twice must not release twice, and `dispatch` is not the only
69
81
  // caller a test can be.
70
82
  held ??= (async () => {
@@ -79,6 +91,49 @@ export function holdUntilShutdown(
79
91
  })();
80
92
  return held;
81
93
  };
94
+ return {
95
+ hold,
96
+ disarm: () => {
97
+ unregister();
98
+ uninstall();
99
+ },
100
+ };
101
+ }
102
+
103
+ /** What a boot answers once it is up: the app, and the hold that keeps the process on it. */
104
+ export interface BootedHold<T> {
105
+ readonly app: T;
106
+ readonly hold: () => Promise<void>;
107
+ }
108
+
109
+ /**
110
+ * Boot under the drain's handlers, never ahead of them. `runRole` used to install them after the
111
+ * boot returned, while `startRoles` starts the worker claiming in the middle of it — so a SIGTERM
112
+ * landing between the two (a rollout replacing a pod still booting) met Bun's default and ended a
113
+ * process holding claimed jobs, each one stranded for a whole visibility timeout. Armed first, the
114
+ * same signal is a drain: whatever the boot has started hears core's phases (the worker stops
115
+ * claiming and hands back at its cut-off), and the release — the app's own `stop()` — runs once
116
+ * the boot has finished, so nothing it acquired after the signal is left running.
117
+ *
118
+ * A boot that throws gives the handlers back and rethrows its own error: the refusal is the
119
+ * finding, and handlers left installed would turn the next signal into a drain of nothing.
120
+ */
121
+ export async function holdWhileBooting<T extends { stop(): Promise<void> }>(
122
+ name: string,
123
+ boot: () => Promise<T>,
124
+ options: HoldOptions = {},
125
+ ): Promise<BootedHold<T>> {
126
+ let booted: T | undefined;
127
+ const armed = armHold(name, async () => await booted?.stop(), options);
128
+ let app: T;
129
+ try {
130
+ app = await boot();
131
+ } catch (error) {
132
+ armed.disarm();
133
+ throw error;
134
+ }
135
+ booted = app;
136
+ return { app, hold: armed.hold };
82
137
  }
83
138
 
84
139
  /**
package/src/i18n-audit.ts CHANGED
@@ -4,22 +4,23 @@
4
4
 
5
5
  // `node:` and not Bun: Bun exposes no existence check (`existsSync`, which is how a missing
6
6
  // `catalogs/` directory reads as "nothing shipped" instead of a throw) and no path API at all —
7
- // `join` builds the absolute path `Bun.file` reads, and `relative`/`sep` turn it back into the
7
+ // `join` builds the absolute path `Bun.file` reads; `posixRelative` turns it back into the
8
8
  // root-relative POSIX shape every CLI-reported path is keyed by.
9
9
  import { existsSync } from 'node:fs';
10
- import { join, relative, sep } from 'node:path';
11
- import { renderThrowable } from '@ultimat3/core';
10
+ import { join } from 'node:path';
11
+ import { DEFAULT_LOCALE, renderThrowable } from '@ultimat3/core';
12
12
  import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
13
13
  import {
14
14
  auditCatalogs,
15
15
  catalogInvalid,
16
16
  catalogKeys,
17
- DEFAULT_LOCALE,
18
17
  extractFromFiles,
19
18
  loadCatalog,
20
19
  missingFrom,
21
20
  nestCatalog,
22
21
  } from '@ultimat3/i18n';
22
+ import { isLoudMiss, loudMiss } from './i18n-registration';
23
+ import { posixRelative } from './posix-path';
23
24
  import { eachSourceFile, isTest } from './source-files';
24
25
  import { CATALOG_ROOT, catalogPath } from './templates/locales';
25
26
 
@@ -40,7 +41,7 @@ export async function scanSource(root: string): Promise<Extraction> {
40
41
  if (!isTest(file) && !isGuard(file)) files.push(file);
41
42
  }
42
43
  const extraction = await extractFromFiles(files.map((file) => join(root, file)));
43
- const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
44
+ const toRelative = (file: string): string => posixRelative(root, file);
44
45
  return {
45
46
  usages: extraction.usages.map((usage) => ({ ...usage, file: toRelative(usage.file) })),
46
47
  dynamic: extraction.dynamic.map((entry) => ({ ...entry, file: toRelative(entry.file) })),
@@ -185,11 +186,23 @@ function sortCatalog(catalog: Catalog): Catalog {
185
186
  }
186
187
 
187
188
  /**
188
- * `x i18n add`'s seed: every key the default locale defines, values copied verbatim — an
189
- * untranslated string that renders is strictly better than a missing key that renders `⟦key⟧`.
189
+ * `x i18n add`'s seed, and `x i18n sync`'s source on a real merge: every key the default locale
190
+ * defines, each value copied INSIDE the CLI's
191
+ * one placeholder marker — `⟦Hello {name}⟧`. A bare copy rendered the default locale's string to
192
+ * every `fr` reader under a green gate, an untranslated catalog no audit could tell from a
193
+ * finished one. Marked, the source text still sits where the translation goes, the page shows the
194
+ * brackets, and `withPlaceholdersMissing` counts every key as missing (`X_CATALOG_MISSING_KEYS`)
195
+ * until a human replaces it. `loudMiss` is the marker's one spelling — a second one would be a
196
+ * placeholder this command writes and the gate cannot see.
190
197
  */
191
198
  export function seedCatalog(source: Catalog): Catalog {
192
- return sortCatalog(source);
199
+ const marked: Record<string, string> = {};
200
+ for (const key of catalogKeys(source)) {
201
+ const value = source[key];
202
+ // A value already a placeholder stays one, as written — never `⟦⟦key⟧⟧`.
203
+ if (value !== undefined) marked[key] = isLoudMiss(value) ? value : loudMiss(value);
204
+ }
205
+ return marked;
193
206
  }
194
207
 
195
208
  export interface SyncResult {
package/src/i18n-index.ts CHANGED
@@ -9,12 +9,11 @@
9
9
  // why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides
10
10
  // whether to write at all, before any await the caller could interleave with.
11
11
  import { existsSync } from 'node:fs';
12
+ import { APP_CATALOGS_PATH } from '@ultimat3/i18n/app-catalogs';
12
13
  import { containedPath } from './generate-write';
13
14
  import type { Finding } from './output';
14
15
  import { CATALOG_ROOT, i18nIndex, localeEntry, localeImport } from './templates';
15
16
 
16
- export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
17
-
18
17
  /** Every locale with a catalog on disk, sorted — the file names are the tags. */
19
18
  export async function catalogLocales(root: string): Promise<readonly string[]> {
20
19
  const catalogDir = containedPath(root, CATALOG_ROOT);
@@ -50,7 +49,7 @@ const importedLocales = (source: string): readonly string[] =>
50
49
  * overwritten: it was, on every `x g`, with a template hard-coding `default: 'en'`.
51
50
  */
52
51
  export async function syncI18nIndex(root: string): Promise<IndexSync> {
53
- const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
52
+ const indexAbsolute = containedPath(root, APP_CATALOGS_PATH);
54
53
  if (!existsSync(indexAbsolute)) return { registered: false, findings: [] };
55
54
  const current = await Bun.file(indexAbsolute).text();
56
55
  const onDisk = await catalogLocales(root);
@@ -83,7 +82,7 @@ function withLocales(source: string, locales: readonly string[]): string | undef
83
82
 
84
83
  const refusal = (locales: readonly string[]): Finding => ({
85
84
  code: 'X_CATALOG_UNREGISTERED',
86
- cause: `${I18N_INDEX_PATH} is hand-written in a shape this writer cannot add ${locales.join(', ')} to, so those catalogs are on disk and not selectable`,
87
- fix: `edit ${I18N_INDEX_PATH} — add ${locales.map(localeImport).join(' ')} and ${locales.map(localeEntry).join(', ')} to the locales passed to defineCatalogs`,
88
- at: I18N_INDEX_PATH,
85
+ cause: `${APP_CATALOGS_PATH} is hand-written in a shape this writer cannot add ${locales.join(', ')} to, so those catalogs are on disk and not selectable`,
86
+ fix: `edit ${APP_CATALOGS_PATH} — add ${locales.map(localeImport).join(' ')} and ${locales.map(localeEntry).join(', ')} to the locales passed to defineCatalogs`,
87
+ at: APP_CATALOGS_PATH,
89
88
  });